@@ -13,28 +13,44 @@ import androidx.compose.foundation.layout.size
1313import androidx.compose.foundation.lazy.LazyColumn
1414import androidx.compose.foundation.lazy.items
1515import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Add
1617import androidx.compose.material.icons.filled.CloudOff
1718import androidx.compose.material.icons.filled.CloudQueue
1819import androidx.compose.material.icons.filled.Computer
1920import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.CheckCircle
22+import androidx.compose.material.icons.filled.Error
2023import androidx.compose.material.icons.filled.Hub
2124import androidx.compose.material.icons.filled.Refresh
25+import androidx.compose.material3.AlertDialog
2226import androidx.compose.material3.Card
2327import androidx.compose.material3.CircularProgressIndicator
2428import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FilterChip
30+import androidx.compose.material3.FloatingActionButton
2531import androidx.compose.material3.Icon
2632import androidx.compose.material3.IconButton
2733import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedTextField
2835import androidx.compose.material3.Scaffold
36+import androidx.compose.material3.SnackbarHost
37+import androidx.compose.material3.SnackbarHostState
2938import androidx.compose.material3.Text
39+import androidx.compose.material3.TextButton
3040import androidx.compose.material3.pulltorefresh.PullToRefreshBox
3141import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
3243import 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
3348import androidx.compose.ui.Alignment
3449import androidx.compose.ui.Modifier
3550import androidx.compose.ui.res.stringResource
3651import androidx.compose.ui.semantics.contentDescription
3752import androidx.compose.ui.semantics.semantics
53+import androidx.compose.ui.text.font.FontFamily
3854import androidx.compose.ui.text.style.TextOverflow
3955import androidx.compose.ui.unit.dp
4056import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -42,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
4258import soy.iko.opencode.R
4359import soy.iko.opencode.di.AppContainer
4460import soy.iko.opencode.ui.components.AppTopBar
61+import soy.iko.opencode.ui.components.ConnectionBannerFor
4562import soy.iko.opencode.ui.components.EmptyState
4663import soy.iko.opencode.ui.vmFactory
4764
@@ -51,6 +68,24 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
5168 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
5269 val state by vm.state.collectAsStateWithLifecycle()
5370 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)
76+
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
83+ }
84+ snackbar.showSnackbar(text)
85+ }
86+ }
87+
88+ var showAddDialog by rememberSaveable { mutableStateOf(false) }
5489
5590 Scaffold(
5691 topBar = {
@@ -64,6 +99,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
6499 },
65100 )
66101 },
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))
109+ }
110+ }
111+ },
67112 ) { padding ->
68113 PullToRefreshBox(
69114 isRefreshing = refreshing,
@@ -71,6 +116,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
71116 modifier = Modifier.fillMaxSize().padding(padding),
72117 ) {
73118 Box(modifier = Modifier.fillMaxSize()) {
119+ ConnectionBannerFor(container)
74120 val loadingLabel = stringResource(R.string.loading)
75121 when (val s = state) {
76122 is McpViewModel.State.Loading -> Column(
@@ -105,7 +151,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
105151 } else {
106152 LazyColumn(
107153 modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(16.dp),
154+ contentPadding = PaddingValues(
155+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
156+ ),
109157 verticalArrangement = Arrangement.spacedBy(12.dp),
110158 ) {
111159 items(s.servers, key = { it.name }) { McpServerCard(it) }
@@ -115,40 +163,188 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
115163 }
116164 }
117165 }
166+
167+ if (showAddDialog) {
168+ AddMcpDialog(
169+ adding = adding,
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
176+ },
177+ )
178+ }
118179}
119180
120181@Composable
121182private fun McpServerCard(server: McpServerInfo) {
183+ val remote = server.type.equals("remote", ignoreCase = true)
184+ val connected = server.connected
122185 Card(modifier = Modifier.fillMaxWidth()) {
123− Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
124− val remote = server.type.equals("remote", ignoreCase = true)
125− Icon(
126− if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
127− contentDescription = null,
128− tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
129− )
130− Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
131− Text(server.name, style = MaterialTheme.typography.titleSmall)
132− server.target?.let {
133− Text(
134− it,
135− style = MaterialTheme.typography.bodySmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− maxLines = 2,
138− overflow = TextOverflow.Ellipsis,
139− )
186+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
187+ Row(verticalAlignment = Alignment.CenterVertically) {
188+ Icon(
189+ if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
190+ contentDescription = null,
191+ tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
192+ )
193+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
194+ Text(server.name, style = MaterialTheme.typography.titleSmall)
195+ server.target?.let {
196+ Text(
197+ it,
198+ style = MaterialTheme.typography.bodySmall,
199+ color = MaterialTheme.colorScheme.onSurfaceVariant,
200+ maxLines = 2,
201+ overflow = TextOverflow.Ellipsis,
202+ fontFamily = FontFamily.Monospace,
203+ )
204+ }
140205 }
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)
210+ } else {
211+ stringResource(R.string.mcp_disabled)
212+ }
213+ Text(
214+ stateLabel,
215+ style = MaterialTheme.typography.labelMedium,
216+ color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
217+ )
141218 }
142− val stateLabel = if (server.enabled) {
143− stringResource(R.string.mcp_enabled)
144− } else {
145− stringResource(R.string.mcp_disabled)
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) {
226+ Icon(
227+ Icons.Filled.Error,
228+ contentDescription = null,
229+ modifier = Modifier.size(16.dp),
230+ tint = MaterialTheme.colorScheme.error,
231+ )
232+ Text(
233+ server.error,
234+ style = MaterialTheme.typography.labelSmall,
235+ color = MaterialTheme.colorScheme.error,
236+ maxLines = 2,
237+ overflow = TextOverflow.Ellipsis,
238+ modifier = Modifier.padding(start = 6.dp).weight(1f),
239+ )
240+ } else if (connected == true) {
241+ Icon(
242+ Icons.Filled.CheckCircle,
243+ contentDescription = null,
244+ modifier = Modifier.size(16.dp),
245+ tint = MaterialTheme.colorScheme.primary,
246+ )
247+ val connectedLabel = server.toolCount?.let {
248+ stringResource(R.string.mcp_connected) + " · " +
249+ stringResource(R.string.mcp_tools_count, it)
250+ } ?: stringResource(R.string.mcp_connected)
251+ Text(
252+ connectedLabel,
253+ style = MaterialTheme.typography.labelSmall,
254+ color = MaterialTheme.colorScheme.primary,
255+ modifier = Modifier.padding(start = 6.dp),
256+ )
257+ } else {
258+ Text(
259+ stringResource(R.string.mcp_disconnected),
260+ style = MaterialTheme.typography.labelSmall,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ modifier = Modifier.padding(start = 22.dp),
263+ )
264+ }
265+ }
146266 }
147− Text(
148− stateLabel,
149− style = MaterialTheme.typography.labelMedium,
150− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
151− )
152267 }
153268 }
154269}
270+
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. */
273+@Composable
274+private fun AddMcpDialog(
275+ adding: Boolean,
276+ onDismiss: () -> Unit,
277+ onAdd: (name: String, kind: McpKind, target: String, env: String) -> Unit,
278+) {
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
286+
287+ AlertDialog(
288+ onDismissRequest = onDismiss,
289+ title = { Text(stringResource(R.string.mcp_add)) },
290+ text = {
291+ Column(modifier = Modifier.fillMaxWidth()) {
292+ OutlinedTextField(
293+ value = name,
294+ onValueChange = { name = it },
295+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
296+ label = { Text(stringResource(R.string.mcp_add_name)) },
297+ singleLine = true,
298+ enabled = !adding,
299+ )
300+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
301+ FilterChip(
302+ selected = kind == McpKind.LOCAL,
303+ onClick = { kind = McpKind.LOCAL },
304+ label = { Text(stringResource(R.string.mcp_kind_local)) },
305+ enabled = !adding,
306+ )
307+ FilterChip(
308+ selected = kind == McpKind.REMOTE,
309+ onClick = { kind = McpKind.REMOTE },
310+ label = { Text(stringResource(R.string.mcp_kind_remote)) },
311+ enabled = !adding,
312+ )
313+ }
314+ Spacer(Modifier.size(12.dp))
315+ OutlinedTextField(
316+ value = target,
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)) },
320+ placeholder = {
321+ Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command_hint) else stringResource(R.string.mcp_add_url_hint))
322+ },
323+ singleLine = true,
324+ enabled = !adding,
325+ )
326+ OutlinedTextField(
327+ value = env,
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)) },
332+ enabled = !adding,
333+ minLines = 2,
334+ )
335+ }
336+ },
337+ confirmButton = {
338+ TextButton(onClick = { onAdd(name, kind, target, env) }, enabled = canAdd) {
339+ if (adding) {
340+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
341+ } else {
342+ Text(stringResource(R.string.mcp_add_button))
343+ }
344+ }
345+ },
346+ dismissButton = {
347+ TextButton(onClick = onDismiss, enabled = !adding) { Text(stringResource(R.string.cancel)) }
348+ },
349+ )
350+}