1package soy.iko.opencode.ui.mcp
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.PaddingValues
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Spacer
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.imePadding
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.widthIn
15import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.items
17import androidx.compose.animation.Crossfade
18import androidx.compose.animation.core.tween
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.Add
21import androidx.compose.material.icons.filled.CloudOff
22import androidx.compose.material.icons.filled.CloudQueue
23import androidx.compose.material.icons.filled.Computer
24import androidx.compose.material.icons.filled.ErrorOutline
25import androidx.compose.material.icons.filled.CheckCircle
26import androidx.compose.material.icons.filled.Error
27import androidx.compose.material.icons.filled.Hub
28import androidx.compose.material.icons.filled.MoreVert
29import androidx.compose.material.icons.filled.Refresh
30import androidx.compose.material3.AlertDialog
31import androidx.compose.material3.Card
32import androidx.compose.material3.CircularProgressIndicator
33import androidx.compose.material3.DropdownMenu
34import androidx.compose.material3.DropdownMenuItem
35import androidx.compose.material3.ExperimentalMaterial3Api
36import androidx.compose.material3.FilterChip
37import androidx.compose.material3.ExtendedFloatingActionButton
38import androidx.compose.material3.Icon
39import androidx.compose.material3.IconButton
40import androidx.compose.material3.MaterialTheme
41import androidx.compose.material3.OutlinedTextField
42import androidx.compose.material3.Scaffold
43import androidx.compose.material3.SnackbarHost
44import androidx.compose.material3.SnackbarHostState
45import androidx.compose.material3.Text
46import androidx.compose.material3.TextButton
47import androidx.compose.material3.pulltorefresh.PullToRefreshBox
48import androidx.compose.runtime.Composable
49import androidx.compose.runtime.LaunchedEffect
50import androidx.compose.runtime.getValue
51import androidx.compose.runtime.mutableStateOf
52import androidx.compose.runtime.remember
53import androidx.compose.runtime.saveable.rememberSaveable
54import androidx.compose.runtime.setValue
55import androidx.compose.ui.Alignment
56import androidx.compose.ui.Modifier
57import androidx.compose.ui.res.stringResource
58import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.semantics
60import androidx.compose.ui.platform.testTag
61import androidx.compose.ui.text.font.FontFamily
62import androidx.compose.ui.text.style.TextOverflow
63import androidx.compose.ui.unit.dp
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
65import androidx.lifecycle.viewmodel.compose.viewModel
66import soy.iko.opencode.R
67import soy.iko.opencode.di.AppContainer
68import soy.iko.opencode.ui.components.AppTopBar
69import soy.iko.opencode.ui.components.ConnectionBannerFor
70import soy.iko.opencode.ui.components.EmptyState
71import soy.iko.opencode.ui.components.SkeletonRow
72import soy.iko.opencode.ui.components.reducedMotionAnimateItem
73import soy.iko.opencode.ui.vmFactory
75@OptIn(ExperimentalMaterial3Api::class)
77fun McpScreen(container: AppContainer, onBack: () -> Unit) {
78 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
79 val state by vm.state.collectAsStateWithLifecycle()
80 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
81 val adding by vm.adding.collectAsStateWithLifecycle()
82 val snackbar = remember { SnackbarHostState() }
83 val addedMsg = stringResource(R.string.mcp_added)
84 val addFailedMsg = stringResource(R.string.mcp_add_failed)
85 val notConnectedMsg = stringResource(R.string.not_connected)
87 var showAddDialog by rememberSaveable { mutableStateOf(false) }
89 LaunchedEffect(Unit) {
90 vm.messages.collect { msg ->
91 val text = when (msg) {
92 McpMessage.ADDED -> addedMsg
93 McpMessage.ADD_FAILED -> addFailedMsg
94 McpMessage.NOT_CONNECTED -> notConnectedMsg
96 // Dismiss the Add dialog only on success (the reload makes the new server appear).
97 // On failure keep it open with adding=false so the user can adjust and retry without
98 // re-typing their input — the dialog's own spinner/disabled-state machinery was
99 // designed for this; dismissing synchronously after the (async) POST would lose the
100 // entered name/command/URL/env on every transient failure.
101 if (msg == McpMessage.ADDED) showAddDialog = false
102 snackbar.showSnackbar(text)
109 title = stringResource(R.string.mcp_servers),
112 IconButton(onClick = { vm.load() }) {
113 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
118 snackbarHost = { SnackbarHost(snackbar) },
119 floatingActionButton = {
120 // Register a new MCP server dynamically (POST /mcp). Hidden until a connection is
121 // established — adding requires an active server. Extended FAB (icon + label) matches
122 // the session list ("New session") and server list ("Add server") so a first-time
123 // user isn't left guessing what the + does.
124 if (state !is McpViewModel.State.Disconnected) {
125 ExtendedFloatingActionButton(
126 onClick = { showAddDialog = true },
127 icon = { Icon(Icons.Filled.Add, contentDescription = null) },
128 text = { Text(stringResource(R.string.mcp_add_server)) },
134 isRefreshing = refreshing,
135 onRefresh = { vm.load() },
136 modifier = Modifier.fillMaxSize().imePadding().padding(padding),
139 modifier = Modifier.fillMaxSize().widthIn(max = 600.dp),
140 contentAlignment = Alignment.Center,
142 ConnectionBannerFor(container)
143 // Crossfade between content states so transitions read as a smooth fade instead
144 // of an instant snap. Matches the session list's Crossfade pattern; reduced
145 // motion is honored by Crossfade's default spec.
146 val stateKey = state::class.simpleName
147 @Suppress("UnusedCrossfadeTargetStateParameter")
149 targetState = stateKey,
150 animationSpec = tween(soy.iko.opencode.data.network.NetworkConfig.motionFadeDurationMs.toInt()),
153 when (val s = state) {
154 is McpViewModel.State.Loading -> Column(
155 modifier = Modifier.fillMaxSize().padding(16.dp),
156 verticalArrangement = Arrangement.spacedBy(12.dp),
158 repeat(6) { SkeletonRow() }
160 is McpViewModel.State.Disconnected -> EmptyState(
161 icon = Icons.Filled.CloudOff,
162 title = stringResource(R.string.not_connected),
163 modifier = Modifier.align(Alignment.Center),
165 is McpViewModel.State.Error -> EmptyState(
166 icon = Icons.Filled.ErrorOutline,
167 title = stringResource(R.string.mcp_failed),
168 description = s.message,
169 modifier = Modifier.align(Alignment.Center),
170 actionLabel = stringResource(R.string.retry),
171 onAction = { vm.load() },
173 is McpViewModel.State.Ready ->
174 if (s.servers.isEmpty()) {
176 icon = Icons.Filled.Hub,
177 title = stringResource(R.string.mcp_empty),
178 modifier = Modifier.align(Alignment.Center),
179 actionIcon = Icons.Filled.Add,
180 actionLabel = stringResource(R.string.mcp_add_server),
181 onAction = { showAddDialog = true },
185 modifier = Modifier.fillMaxWidth(),
186 contentPadding = PaddingValues(
187 start = 16.dp, end = 16.dp, top = 16.dp, bottom = soy.iko.opencode.data.network.NetworkConfig.listFabInsetDp.dp,
189 verticalArrangement = Arrangement.spacedBy(12.dp),
191 items(s.servers, key = { it.name }) {
192 McpServerCard(it, modifier = Modifier.then(reducedMotionAnimateItem()))
205 onDismiss = { if (!adding) showAddDialog = false },
206 onAdd = { name, kind, target, env ->
207 vm.addServer(name, kind, target, env)
208 // Don't dismiss synchronously: addServer launches a coroutine, so the POST is
209 // still in flight. The dialog stays open showing the spinner (adding=true);
210 // the messages collector above dismisses on ADDED, or re-enables the fields
211 // on ADD_FAILED so the user can retry without re-typing.
218private fun McpServerCard(server: McpServerInfo, modifier: Modifier = Modifier) {
219 val remote = server.type.equals("remote", ignoreCase = true)
220 val connected = server.connected
221 // Build a single merged content description so TalkBack reads the card as one node
222 // (type, name, target, enabled, status) instead of ~5 separate stops.
223 val typeLabel = stringResource(if (remote) R.string.mcp_remote else R.string.mcp_local)
224 val stateLabel = if (server.enabled) {
225 stringResource(R.string.mcp_enabled)
227 stringResource(R.string.mcp_disabled)
229 val statusLabel = when {
230 server.error != null && connected != true -> server.error
231 connected == true -> stringResource(R.string.mcp_connected)
232 connected == false -> stringResource(R.string.mcp_disconnected)
235 val cardDesc = buildString {
236 append(typeLabel).append(", ")
238 server.target?.let { append(", ").append(it) }
239 append(", ").append(stateLabel)
240 statusLabel?.let { append(", ").append(it) }
242 var menuExpanded by rememberSaveable { mutableStateOf(false) }
243 var showTools by rememberSaveable { mutableStateOf(false) }
248 .semantics(mergeDescendants = true) { contentDescription = cardDesc },
250 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
251 Row(verticalAlignment = Alignment.CenterVertically) {
253 if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
254 contentDescription = typeLabel,
255 tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
257 Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
258 Row(verticalAlignment = Alignment.CenterVertically) {
259 Text(server.name, style = MaterialTheme.typography.titleSmall)
260 // Small type label next to the name so a user can tell local from
261 // remote without recognizing the cloud/computer icon.
264 style = MaterialTheme.typography.labelSmall,
265 color = MaterialTheme.colorScheme.onSurfaceVariant,
266 modifier = Modifier.padding(start = 6.dp),
272 style = MaterialTheme.typography.bodySmall,
273 color = MaterialTheme.colorScheme.onSurfaceVariant,
275 overflow = TextOverflow.Ellipsis,
276 fontFamily = FontFamily.Monospace,
282 style = MaterialTheme.typography.labelMedium,
283 color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
285 // A "Remove" item would go here once the opencode server exposes DELETE /mcp
286 // (or a config PATCH to drop an entry). Only GET and POST /mcp exist today, so
287 // removing a server isn't reachable from the client; the mcp_remove* strings are
288 // already declared for when it is.
290 IconButton(onClick = { menuExpanded = true }) {
292 Icons.Filled.MoreVert,
293 contentDescription = stringResource(R.string.more_options_for, server.name),
297 expanded = menuExpanded,
298 onDismissRequest = { menuExpanded = false },
301 text = { Text(stringResource(R.string.mcp_view_tools)) },
302 onClick = { menuExpanded = false; showTools = true },
307 // Live status row: connected (with tool count) / error / unknown. Only shown when the
308 // server reported a runtime state via /mcp, so a static config-only view isn't
309 // cluttered with "unknown" chips.
310 if (connected != null || server.error != null) {
311 Spacer(Modifier.size(10.dp))
312 McpStatusRow(server = server, connected = connected)
317 McpToolsDialog(server = server, onDismiss = { showTools = false })
322private fun McpToolsDialog(server: McpServerInfo, onDismiss: () -> Unit) {
324 onDismissRequest = onDismiss,
325 title = { Text(stringResource(R.string.mcp_view_tools)) },
327 val tools = server.tools
328 if (tools.isNullOrEmpty()) {
329 Text(stringResource(R.string.mcp_no_tools))
331 Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
333 stringResource(R.string.mcp_tools_count, tools.size),
334 style = MaterialTheme.typography.labelMedium,
335 color = MaterialTheme.colorScheme.onSurfaceVariant,
337 Spacer(Modifier.size(4.dp))
338 tools.forEach { name ->
341 style = MaterialTheme.typography.bodySmall,
342 fontFamily = FontFamily.Monospace,
349 TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
354/** Live status row for an MCP server card: connected (with tool count) / error / disconnected.
355 * Extracted from McpServerCard to keep its complexity under detekt's threshold. */
357private fun McpStatusRow(server: McpServerInfo, connected: Boolean?) {
358 Row(verticalAlignment = Alignment.CenterVertically) {
359 if (server.error != null && connected != true) {
362 contentDescription = stringResource(R.string.mcp_failed_state),
363 modifier = Modifier.size(16.dp),
364 tint = MaterialTheme.colorScheme.error,
368 style = MaterialTheme.typography.labelSmall,
369 color = MaterialTheme.colorScheme.error,
371 overflow = TextOverflow.Ellipsis,
372 modifier = Modifier.padding(start = 6.dp).weight(1f),
374 } else if (connected == true) {
376 Icons.Filled.CheckCircle,
377 contentDescription = stringResource(R.string.mcp_connected),
378 modifier = Modifier.size(16.dp),
379 tint = MaterialTheme.colorScheme.primary,
381 val connectedLabel = server.toolCount?.let {
382 stringResource(R.string.mcp_connected) + " · " +
383 stringResource(R.string.mcp_tools_count, it)
384 } ?: stringResource(R.string.mcp_connected)
387 style = MaterialTheme.typography.labelSmall,
388 color = MaterialTheme.colorScheme.primary,
389 modifier = Modifier.padding(start = 6.dp),
393 stringResource(R.string.mcp_disconnected),
394 style = MaterialTheme.typography.labelSmall,
395 color = MaterialTheme.colorScheme.onSurfaceVariant,
396 modifier = Modifier.padding(start = 22.dp),
402/** Dialog collecting the bits needed to register an MCP server via POST /mcp: a name, whether
403 * it's a local command or a remote URL, the command line / URL, and optional env vars. */
405private fun AddMcpDialog(
407 onDismiss: () -> Unit,
408 onAdd: (name: String, kind: McpKind, target: String, env: String) -> Unit,
410 var name by rememberSaveable { mutableStateOf("") }
411 var kind by rememberSaveable { mutableStateOf(McpKind.LOCAL) }
412 var target by rememberSaveable { mutableStateOf("") }
413 var env by rememberSaveable { mutableStateOf("") }
414 val nameValid = name.trim().isNotEmpty()
415 val targetValid = target.trim().isNotEmpty()
416 val canAdd = !adding && nameValid && targetValid
419 onDismissRequest = onDismiss,
420 title = { Text(stringResource(R.string.mcp_add)) },
422 Column(modifier = Modifier.fillMaxWidth()) {
425 onValueChange = { name = it },
426 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
427 label = { Text(stringResource(R.string.mcp_add_name)) },
430 isError = !nameValid,
431 supportingText = if (!nameValid) {
432 { Text(stringResource(R.string.mcp_add_name_required)) }
435 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
437 selected = kind == McpKind.LOCAL,
438 onClick = { kind = McpKind.LOCAL },
439 label = { Text(stringResource(R.string.mcp_kind_local)) },
443 selected = kind == McpKind.REMOTE,
444 onClick = { kind = McpKind.REMOTE },
445 label = { Text(stringResource(R.string.mcp_kind_remote)) },
449 Spacer(Modifier.size(12.dp))
452 onValueChange = { target = it },
453 modifier = Modifier.fillMaxWidth().padding(bottom = if (kind == McpKind.LOCAL) 4.dp else 12.dp),
454 label = { Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command) else stringResource(R.string.mcp_add_url)) },
456 Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command_hint) else stringResource(R.string.mcp_add_url_hint))
460 isError = !targetValid,
461 supportingText = if (!targetValid) {
462 { Text(stringResource(R.string.mcp_add_target_required)) }
465 // Warn that the local command is split on whitespace and quoted args aren't
466 // supported, so a user entering `"my program" --arg` isn't surprised when it's
467 // split into 4 tokens. Directs them to config.mcp for complex commands.
468 if (kind == McpKind.LOCAL) {
470 stringResource(R.string.mcp_command_split_hint),
471 style = MaterialTheme.typography.labelSmall,
472 color = MaterialTheme.colorScheme.onSurfaceVariant,
473 modifier = Modifier.padding(bottom = 12.dp),
478 onValueChange = { env = it },
479 modifier = Modifier.fillMaxWidth(),
480 label = { Text(stringResource(R.string.mcp_add_env)) },
481 placeholder = { Text(stringResource(R.string.mcp_add_env_hint)) },
482 supportingText = { Text(stringResource(R.string.mcp_env_hint)) },
485 keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
486 capitalization = androidx.compose.ui.text.input.KeyboardCapitalization.None,
487 imeAction = androidx.compose.ui.text.input.ImeAction.Default,
493 TextButton(onClick = { onAdd(name, kind, target, env) }, enabled = canAdd) {
495 CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
497 Text(stringResource(R.string.mcp_add_button))
502 TextButton(onClick = onDismiss, enabled = !adding) { Text(stringResource(R.string.cancel)) }
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list