git.iko.soy/opencode-android
on master help sign in
Add NavigationRail for large screens, chat swipe-to-reply and in-chat delete undo, MCP live status and add-server via /mcp, server/diagnostics/usage filters, and assorted consistency/a11y polish (SnackbarHosts, ConnectionBanner on all screens, image viewer double-tap zoom, app-lock screen, RTL-aware nav transitions, dead-code removal)
commita9e66cfd24063c47cc2ac3736019d1ce596fc56f authoriko <mail@iko.soy> dateFri 3 Jul 2026, 15:45 (3 months ago) parentb42208d
22 files changed+992−132
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+29 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+34−6 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+38−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+84−12 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−12 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt+74 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt−22 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+53−9 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−2 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+9−3 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+223−27 app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+23 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+94−2 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+4−1 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+68−25 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+14−1 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+69−1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+26−4 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+58−3 app/src/main/res/values-es/strings.xml+23 app/src/main/res/values/strings.xml+23
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12
@@ -221,6 +221,18 @@ object NetworkConfig {
221221 * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
222222 * eye, so popping the keyboard for it is just friction. */
223223 const val pickerSearchAutofocusThreshold = 8
224+
225+ /** Minimum number of saved server profiles before the server list shows its search/filter
226+ * field. A typical user has one or two servers, where scanning by eye beats typing; power
227+ * users juggling many profiles get the filter once it earns its space. */
228+ const val serverListSearchThreshold = 5
229+
230+ /** Minimum number of crash reports before the diagnostics list shows its search field. */
231+ const val diagnosticsSearchThreshold = 8
232+
233+ /** Minimum number of sessions in the usage report before the per-session list shows its
234+ * filter field. A short history is faster to scan than type into. */
235+ const val usageSessionSearchThreshold = 8
224236 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
225237 * jump-to-latest FAB so it never floats over the last message. */
226238 const val chatListFabInsetDp = 80
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+29
@@ -42,6 +42,8 @@ import io.ktor.http.contentType
4242import kotlinx.coroutines.delay
4343import kotlinx.coroutines.sync.Mutex
4444import kotlinx.coroutines.sync.withLock
45+import kotlinx.serialization.json.buildJsonObject
46+import kotlinx.serialization.json.put
4547import kotlin.random.Random
4648import java.net.URLEncoder
4749
@@ -312,6 +314,33 @@ open class OpencodeApiClient private constructor(
312314 client!!.get("config").body()
313315 }
314316
317+ /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
318+ * available tools, any error). Decoded as a raw JSON object so a schema change on the
319+ * server never breaks the viewer. */
320+ open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
321+ client!!.get("mcp").body()
322+ }
323+
324+ /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
325+ * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
326+ * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
327+ * response was lost) doesn't register a duplicate. */
328+ open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
329+ val idempotencyKey = java.util.UUID.randomUUID().toString()
330+ return withRetry {
331+ client!!.post("mcp") {
332+ contentType(ContentType.Application.Json)
333+ header("Idempotency-Key", idempotencyKey)
334+ setBody(
335+ buildJsonObject {
336+ put("name", name)
337+ put("config", config)
338+ },
339+ )
340+ }.body()
341+ }
342+ }
343+
315344 open suspend fun agents(): List<Agent> {
316345 val generation: Int
317346 agentsMutex.withLock {
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+34−6
@@ -5,6 +5,7 @@ import android.content.ContextWrapper
55import android.os.Build
66import androidx.biometric.BiometricManager
77import androidx.biometric.BiometricPrompt
8+import androidx.compose.foundation.clickable
89import androidx.compose.foundation.layout.Arrangement
910import androidx.compose.foundation.layout.Column
1011import androidx.compose.foundation.layout.Spacer
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
1213import androidx.compose.foundation.layout.padding
1314import androidx.compose.foundation.layout.size
1415import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Fingerprint
1517import androidx.compose.material.icons.filled.Lock
1618import androidx.compose.material3.Button
1719import androidx.compose.material3.CircularProgressIndicator
@@ -31,6 +33,7 @@ import androidx.compose.ui.Modifier
3133import androidx.compose.ui.platform.LocalContext
3234import androidx.compose.ui.res.stringResource
3335import androidx.compose.ui.semantics.LiveRegionMode
36+import androidx.compose.ui.semantics.Role
3437import androidx.compose.ui.semantics.liveRegion
3538import androidx.compose.ui.semantics.semantics
3639import androidx.compose.ui.unit.dp
@@ -217,20 +220,39 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
217220 if (unlocked) {
218221 stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
219222 } else {
223+ // A Fingerprint glyph hints at the primary unlock method when a biometric is enrolled;
224+ // fall back to the generic lock on credential-only setups. Computed once: enrollment
225+ // doesn't change while the gate is shown, and re-prompting on each frame is wasteful.
226+ val biometricAvailable = remember(context) {
227+ BiometricManager.from(context)
228+ .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
229+ BiometricManager.BIOMETRIC_SUCCESS
230+ }
231+ val unlockLabel = stringResource(R.string.app_unlock)
220232 Surface(
221233 modifier = Modifier.fillMaxSize(),
222234 color = MaterialTheme.colorScheme.background,
223235 ) {
236+ // The whole surface is tappable to re-trigger the prompt (in addition to the button),
237+ // so a user who dismissed the system prompt can retry by tapping anywhere — not just
238+ // by finding the Unlock button.
224239 Column(
225− modifier = Modifier.fillMaxSize().padding(32.dp),
240+ modifier = Modifier
241+ .fillMaxSize()
242+ .clickable(
243+ enabled = !authInFlight,
244+ role = Role.Button,
245+ onClickLabel = unlockLabel,
246+ ) { authenticate() }
247+ .padding(32.dp),
226248 horizontalAlignment = Alignment.CenterHorizontally,
227249 verticalArrangement = Arrangement.Center,
228250 ) {
229251 Icon(
230− Icons.Filled.Lock,
231− contentDescription = stringResource(R.string.app_locked),
232− modifier = Modifier.size(48.dp),
233− tint = MaterialTheme.colorScheme.onSurfaceVariant,
252+ if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
253+ contentDescription = null,
254+ modifier = Modifier.size(56.dp),
255+ tint = MaterialTheme.colorScheme.primary,
234256 )
235257 Text(
236258 stringResource(R.string.app_locked),
@@ -239,6 +261,12 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
239261 .padding(top = 16.dp)
240262 .semantics { liveRegion = LiveRegionMode.Polite },
241263 )
264+ Text(
265+ stringResource(R.string.app_locked_hint),
266+ style = MaterialTheme.typography.bodyMedium,
267+ color = MaterialTheme.colorScheme.onSurfaceVariant,
268+ modifier = Modifier.padding(top = 4.dp),
269+ )
242270 Button(
243271 onClick = { authenticate() },
244272 enabled = !authInFlight,
@@ -252,7 +280,7 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
252280 )
253281 Spacer(Modifier.size(8.dp))
254282 }
255− Text(stringResource(R.string.app_unlock))
283+ Text(unlockLabel)
256284 }
257285 }
258286 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+38−2
@@ -7,7 +7,11 @@ import androidx.compose.animation.fadeIn
77import androidx.compose.animation.fadeOut
88import androidx.compose.animation.slideInHorizontally
99import androidx.compose.animation.slideOutHorizontally
10+import androidx.compose.foundation.layout.Box
1011import androidx.compose.foundation.layout.BoxWithConstraints
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxHeight
14+import androidx.compose.foundation.layout.fillMaxSize
1115import androidx.compose.runtime.Composable
1216import androidx.compose.runtime.CompositionLocalProvider
1317import androidx.compose.runtime.LaunchedEffect
@@ -16,11 +20,15 @@ import androidx.compose.runtime.mutableStateOf
1620import androidx.compose.runtime.rememberCoroutineScope
1721import androidx.compose.runtime.saveable.rememberSaveable
1822import androidx.compose.runtime.setValue
23+import androidx.compose.ui.Modifier
1924import androidx.compose.ui.platform.LocalDensity
2025import androidx.compose.ui.unit.dp
26+import androidx.compose.ui.unit.LayoutDirection
27+import androidx.compose.ui.platform.LocalLayoutDirection
2128import androidx.navigation.NavType
2229import androidx.navigation.compose.NavHost
2330import androidx.navigation.compose.composable
31+import androidx.navigation.compose.currentBackStackEntryAsState
2432import androidx.navigation.compose.rememberNavController
2533import androidx.navigation.navArgument
2634import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -33,6 +41,7 @@ import soy.iko.opencode.ui.file.FileViewScreen
3341import soy.iko.opencode.ui.server.ServerEditScreen
3442import soy.iko.opencode.ui.server.ServerListScreen
3543import soy.iko.opencode.ui.search.GlobalSearchScreen
44+import soy.iko.opencode.ui.components.LargeScreenNavRail
3645import soy.iko.opencode.ui.components.LocalChatTextScale
3746import soy.iko.opencode.ui.components.LocalCodeWrap
3847import soy.iko.opencode.ui.components.isReducedMotion
@@ -151,10 +160,21 @@ fun OpencodeApp(container: AppContainer) {
151160 // Capture the reduced-motion preference once (in the composable scope) so the
152161 // non-composable NavHost transition lambdas can use it to skip animations.
153162 val reducedMotion = isReducedMotion()
163+ // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
164+ // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
165+ val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
154166 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
155167 // the open conversation side by side instead of a single-pane back stack.
156168 BoxWithConstraints {
157169 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
170+ // On large screens, surface a navigation rail alongside the content so top-level
171+ // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
172+ // screen's overflow menu. Mirrors the standard large-screen M3 layout.
173+ val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
174+ // Observe the current destination reactively so the rail's selected item tracks navigation
175+ // (reading currentDestination once would go stale after the first composition).
176+ val currentEntry by navController.currentBackStackEntryAsState()
177+ val currentRoute = currentEntry?.destination?.route
158178
159179 // Open a session requested by a notification tap or deep link, once connected.
160180 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
@@ -191,7 +211,9 @@ fun OpencodeApp(container: AppContainer) {
191211 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
192212 }
193213
214+ val navHost = @Composable {
194215 NavHost(
216+ modifier = Modifier.fillMaxSize(),
195217 navController = navController,
196218 startDestination = Routes.SERVERS,
197219 // Motion-aware transitions: respect the user's reduced-motion setting
@@ -206,7 +228,7 @@ fun OpencodeApp(container: AppContainer) {
206228 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
207229 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
208230 } else {
209− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
231+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
210232 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
211233 }
212234 },
@@ -228,7 +250,7 @@ fun OpencodeApp(container: AppContainer) {
228250 if (reducedMotion) {
229251 ExitTransition.None
230252 } else {
231− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
253+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
232254 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
233255 }
234256 },
@@ -392,6 +414,20 @@ fun OpencodeApp(container: AppContainer) {
392414 )
393415 }
394416 }
417+ }
418+
419+ if (showNavRail) {
420+ Row(modifier = Modifier.fillMaxSize()) {
421+ LargeScreenNavRail(
422+ currentRoute = currentRoute,
423+ connected = connection != null,
424+ onNavigate = { route -> navController.navigate(route) { launchSingleTop = true } },
425+ )
426+ Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
427+ }
428+ } else {
429+ navHost()
430+ }
395431 }
396432 }
397433}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+84−12
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.filled.PhotoLibrary
5151import androidx.compose.material.icons.filled.Refresh
5252import androidx.compose.material.icons.filled.Restore
5353import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
54+import androidx.compose.material.icons.automirrored.filled.Reply
5455import androidx.compose.material.icons.filled.Schedule
5556import androidx.compose.material.icons.filled.Stop
5657import androidx.compose.material3.AlertDialog
@@ -69,8 +70,13 @@ import androidx.compose.material3.IconButton
6970import androidx.compose.material3.MaterialTheme
7071import androidx.compose.material3.OutlinedTextField
7172import androidx.compose.material3.Scaffold
73+import androidx.compose.material3.SnackbarDuration
74+import androidx.compose.material3.SwipeToDismissBox
75+import androidx.compose.material3.SwipeToDismissBoxValue
76+import androidx.compose.material3.rememberSwipeToDismissBoxState
7277import androidx.compose.material3.SnackbarHost
7378import androidx.compose.material3.SnackbarHostState
79+import androidx.compose.material3.SnackbarResult
7480import androidx.compose.material3.Surface
7581import androidx.compose.material3.Text
7682import androidx.compose.material3.TextButton
@@ -116,6 +122,8 @@ import androidx.compose.ui.unit.dp
116122import androidx.lifecycle.compose.collectAsStateWithLifecycle
117123import androidx.lifecycle.viewmodel.compose.viewModel
118124import kotlinx.coroutines.Dispatchers
125+import kotlinx.coroutines.coroutineScope
126+import kotlinx.coroutines.delay
119127import kotlinx.coroutines.flow.distinctUntilChanged
120128import kotlinx.coroutines.launch
121129import kotlinx.coroutines.withContext
@@ -290,6 +298,30 @@ fun ChatScreen(
290298 }
291299 }
292300
301+ // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
302+ // window on the container; show the snackbar over the conversation the user is viewing so
303+ // they can cancel without being navigated to the session list first. If the window expires
304+ // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
305+ val undoLabel = stringResource(R.string.undo)
306+ val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
307+ LaunchedEffect(Unit) {
308+ vm.deleteUndoEvents.collect {
309+ coroutineScope {
310+ val dismisser = launch {
311+ delay(NetworkConfig.undoDeleteDelayMs)
312+ snackbar.currentSnackbarData?.dismiss()
313+ }
314+ val result = snackbar.showSnackbar(
315+ message = sessionDeletedLabel,
316+ actionLabel = undoLabel,
317+ duration = SnackbarDuration.Indefinite,
318+ )
319+ dismisser.cancel()
320+ if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
321+ }
322+ }
323+ }
324+
293325 // --- Attachments, voice, and share-link plumbing ---
294326 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
295327 val attachFailedMsg = stringResource(R.string.attachment_failed)
@@ -457,7 +489,6 @@ fun ChatScreen(
457489 // Texts and announced only "Change model and agent".
458490 modifier = Modifier
459491 .clickable(
460− enabled = models.isNotEmpty(),
461492 role = Role.Button,
462493 onClickLabel = changeLabel,
463494 ) { showTitleMenu = true }
@@ -480,17 +511,17 @@ fun ChatScreen(
480511 overflow = TextOverflow.Ellipsis,
481512 )
482513 }
483− // Visual affordance that the title is tappable; without it the
484− // title is indistinguishable from plain text and the pickers are
485− // effectively undiscoverable.
486− if (models.isNotEmpty()) {
487− Icon(
488− Icons.Filled.KeyboardArrowDown,
489− contentDescription = null,
490− tint = MaterialTheme.colorScheme.onSurfaceVariant,
491− modifier = Modifier.size(20.dp),
492− )
493− }
514+ // Always show the affordance so the title reads as tappable even when
515+ // the model catalog failed to load (models empty). Without it the title
516+ // is indistinguishable from plain text and the pickers are effectively
517+ // undiscoverable — and the dropdown items already disable themselves
518+ // while a catalog is loading or unavailable.
519+ Icon(
520+ Icons.Filled.KeyboardArrowDown,
521+ contentDescription = null,
522+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
523+ modifier = Modifier.size(20.dp),
524+ )
494525 }
495526 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
496527 DropdownMenuItem(
@@ -991,6 +1022,46 @@ fun ChatScreen(
9911022 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
9921023 ?.let { resolveModelLabel(it, models) }
9931024 }
1025+ // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1026+ // image/code-only messages, which then opt out of the swipe gesture.
1027+ val quoteText = remember(message.parts) {
1028+ message.parts
1029+ .filterIsInstance<TextPart>()
1030+ .joinToString("\n\n") { it.text }
1031+ .trim()
1032+ .takeIf { it.isNotEmpty() }
1033+ }
1034+ // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1035+ // snapping back (the message isn't dismissed — the quote fills the composer).
1036+ // Only enabled when there's text to quote, so non-text messages don't
1037+ // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1038+ val swipeState = rememberSwipeToDismissBoxState(
1039+ confirmValueChange = { value ->
1040+ if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1041+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1042+ vm.quoteReply(quoteText)
1043+ runCatching { inputFocusRequester.requestFocus() }
1044+ }
1045+ false
1046+ },
1047+ )
1048+ SwipeToDismissBox(
1049+ state = swipeState,
1050+ enableDismissFromEndToStart = false,
1051+ enableDismissFromStartToEnd = quoteText != null,
1052+ backgroundContent = {
1053+ Box(
1054+ Modifier.fillMaxSize().padding(horizontal = 16.dp),
1055+ contentAlignment = Alignment.CenterStart,
1056+ ) {
1057+ Icon(
1058+ Icons.AutoMirrored.Filled.Reply,
1059+ contentDescription = null,
1060+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1061+ )
1062+ }
1063+ },
1064+ ) {
9941065 MessageBubble(
9951066 message,
9961067 isRunning = running && message.info.id == lastMessageId,
@@ -1039,6 +1110,7 @@ fun ChatScreen(
10391110 }
10401111 },
10411112 )
1113+ }
10421114 }
10431115 }
10441116 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−12
@@ -32,9 +32,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
3232import kotlinx.coroutines.channels.BufferOverflow
3333import kotlinx.coroutines.channels.Channel
3434import kotlinx.coroutines.flow.Flow
35+import kotlinx.coroutines.flow.MutableSharedFlow
3536import kotlinx.coroutines.flow.MutableStateFlow
37+import kotlinx.coroutines.flow.SharedFlow
3638import kotlinx.coroutines.flow.SharingStarted
3739import kotlinx.coroutines.flow.StateFlow
40+import kotlinx.coroutines.flow.asSharedFlow
3841import kotlinx.coroutines.flow.asStateFlow
3942import kotlinx.coroutines.flow.receiveAsFlow
4043import kotlinx.coroutines.CancellationException
@@ -361,6 +364,15 @@ class ChatViewModel(
361364 )
362365 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
363366
367+ /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
368+ * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
369+ * already viewing, instead of navigating away first and surfacing Undo on the session list. */
370+ private val _deleteUndoEvents = MutableSharedFlow<Unit>(
371+ extraBufferCapacity = 1,
372+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
373+ )
374+ val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
375+
364376 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
365377 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
366378
@@ -1433,27 +1445,30 @@ class ChatViewModel(
14331445 }
14341446 }
14351447
1436− /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1437− * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1438− * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1439− * already viewing the session, so a confirmation dialog guards the action instead. */
1448+ /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1449+ * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1450+ * *over the conversation the user is viewing* — they can cancel without first being
1451+ * navigated back to the session list. The actual REST delete runs after
1452+ * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1453+ * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1454+ * A failure surfaces as a snackbar. */
14401455 fun deleteSession() {
14411456 val conn = connection ?: return
1442− // Schedule a deferred delete with an undo window (matching SessionListViewModel's
1443− // pattern), so the user can undo from the session list after navigating back. The
1444− // actual REST delete runs after undoDeleteDelayMs; cancel via cancelSessionDelete.
14451457 container.scheduleSessionDelete(
14461458 id = sessionId,
14471459 delayMs = NetworkConfig.undoDeleteDelayMs,
1448− onDeleted = { /* cleanup handled by container.scheduleSessionDelete */ },
1460+ // Navigate away only once the deferred delete actually commits — so an in-place Undo
1461+ // before the window expires keeps the user on this conversation.
1462+ onDeleted = { _sessionDeleted.value = true },
14491463 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
14501464 )
1451− // Let the SessionListScreen show the undo snackbar for this externally-initiated delete.
1452− container.emitExternalSessionUndo(sessionId)
1453− // Navigate back immediately so the user lands on the session list where the Undo shows.
1454− _sessionDeleted.value = true
1465+ _deleteUndoEvents.tryEmit(Unit)
14551466 }
14561467
1468+ /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1469+ * delete was still pending (Undo succeeded). */
1470+ fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1471+
14571472 /** Reconnect to the most recently used server profile (used when the connection is gone). */
14581473 fun reconnect() {
14591474 if (container.activeConnection.value != null) return
app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.ktnew file+74
@@ -0,0 +1,74 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.fillMaxHeight
4+import androidx.compose.material.icons.Icons
5+import androidx.compose.material.icons.automirrored.filled.Chat
6+import androidx.compose.material.icons.filled.Dns
7+import androidx.compose.material.icons.filled.Folder
8+import androidx.compose.material.icons.filled.Hub
9+import androidx.compose.material.icons.filled.QueryStats
10+import androidx.compose.material.icons.filled.Search
11+import androidx.compose.material.icons.filled.Settings
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.NavigationRail
14+import androidx.compose.material3.NavigationRailItem
15+import androidx.compose.material3.NavigationRailItemDefaults
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.graphics.vector.ImageVector
20+import androidx.compose.ui.res.stringResource
21+import soy.iko.opencode.R
22+
23+/** A top-level destination surfaced on the large-screen navigation rail. */
24+private data class RailDest(
25+ val route: String,
26+ val labelRes: Int,
27+ val icon: ImageVector,
28+ /** Whether the destination needs an active server connection. */
29+ val requiresConnection: Boolean,
30+)
31+
32+private val RAIL_DESTINATIONS = listOf(
33+ RailDest("sessions", R.string.sessions_title, Icons.AutoMirrored.Filled.Chat, true),
34+ RailDest("search", R.string.search_all_title, Icons.Filled.Search, true),
35+ RailDest("files", R.string.files, Icons.Filled.Folder, true),
36+ RailDest("usage", R.string.usage_title, Icons.Filled.QueryStats, true),
37+ RailDest("mcp", R.string.mcp_servers, Icons.Filled.Hub, true),
38+ RailDest("settings", R.string.settings, Icons.Filled.Settings, false),
39+ RailDest("servers", R.string.servers_title, Icons.Filled.Dns, false),
40+)
41+
42+/**
43+ * A [NavigationRail] shown alongside the nav content on large screens (tablets / foldables), so
44+ * the top-level destinations are one tap away instead of buried in a screen's overflow menu.
45+ *
46+ * @param currentRoute the current NavHost destination route, used to highlight the active item.
47+ * @param connected whether a server connection is active; connection-dependent items (sessions,
48+ * files, …) are disabled until one exists.
49+ * @param onNavigate invoked with the destination's route when an item is selected.
50+ */
51+@Composable
52+fun LargeScreenNavRail(
53+ currentRoute: String?,
54+ connected: Boolean,
55+ onNavigate: (String) -> Unit,
56+ modifier: Modifier = Modifier,
57+) {
58+ NavigationRail(modifier = modifier.fillMaxHeight()) {
59+ RAIL_DESTINATIONS.forEach { dest ->
60+ // Match exact top-level routes. Sub-routes (chat/{id}, file_view) don't highlight any
61+ // rail item — the rail tracks top-level position, not the detail back stack.
62+ val selected = currentRoute == dest.route ||
63+ (dest.route == "sessions" && currentRoute != null && currentRoute.startsWith("chat/"))
64+ NavigationRailItem(
65+ selected = selected,
66+ enabled = !dest.requiresConnection || connected,
67+ onClick = { onNavigate(dest.route) },
68+ icon = { Icon(dest.icon, contentDescription = null) },
69+ label = { Text(stringResource(dest.labelRes)) },
70+ colors = NavigationRailItemDefaults.colors(),
71+ )
72+ }
73+ }
74+}
app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt−22
@@ -1,15 +1,6 @@
11package soy.iko.opencode.ui.components
22
33import android.provider.Settings
4−import androidx.compose.animation.ContentTransform
5−import androidx.compose.animation.EnterTransition
6−import androidx.compose.animation.ExitTransition
7−import androidx.compose.animation.core.tween
8−import androidx.compose.animation.fadeIn
9−import androidx.compose.animation.fadeOut
10−import androidx.compose.animation.slideInHorizontally
11−import androidx.compose.animation.slideOutHorizontally
12−import androidx.compose.animation.togetherWith
134import androidx.compose.runtime.Composable
145import androidx.compose.runtime.remember
156import androidx.compose.ui.platform.LocalContext
@@ -32,16 +23,3 @@ fun isReducedMotion(): Boolean {
3223 }.getOrDefault(false)
3324 }
3425}
35−
36−/**
37− * Nav transition that respects reduced motion: a horizontal slide + fade normally, but an
38− * instant swap when the user has disabled animations. Uses the shared [NetworkConfig] durations.
39− */
40−@Composable
41−fun motionAwareNavTransition(): ContentTransform {
42− if (isReducedMotion()) {
43− return EnterTransition.None togetherWith ExitTransition.None
44− }
45− return slideInHorizontally(tween(220)) { it } + fadeIn(tween(180)) togetherWith
46− slideOutHorizontally(tween(220)) { -it } + fadeOut(tween(180))
47−}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+53−9
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.components
33import android.util.Base64
44import androidx.compose.foundation.background
55import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.gestures.detectTapGestures
67import androidx.compose.foundation.gestures.detectTransformGestures
78import androidx.compose.foundation.layout.Box
89import androidx.compose.foundation.layout.Column
@@ -22,6 +23,7 @@ import androidx.compose.runtime.mutableIntStateOf
2223import androidx.compose.runtime.mutableStateOf
2324import androidx.compose.runtime.produceState
2425import androidx.compose.runtime.remember
26+import androidx.compose.runtime.rememberCoroutineScope
2527import androidx.compose.runtime.setValue
2628import androidx.compose.ui.Alignment
2729import androidx.compose.ui.Modifier
@@ -40,6 +42,9 @@ import androidx.compose.ui.window.DialogProperties
4042import coil.compose.SubcomposeAsyncImage
4143import coil.request.ImageRequest
4244import kotlinx.coroutines.Dispatchers
45+import kotlinx.coroutines.launch
46+import androidx.compose.animation.core.Animatable
47+import androidx.compose.animation.core.tween
4348import kotlinx.coroutines.withContext
4449import androidx.compose.material.icons.Icons
4550import androidx.compose.material.icons.filled.BrokenImage
@@ -231,8 +236,9 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
231236
232237/**
233238 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
234− * pinch-to-zoom and pan via [detectTransformGestures], with a close button and tap-to-dismiss.
235− * Uses a non-interactive Dialog window so it overlays the whole screen.
239+ * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
240+ * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
241+ * the whole screen.
236242 */
237243@Composable
238244private fun FullscreenImageViewer(
@@ -240,9 +246,12 @@ private fun FullscreenImageViewer(
240246 contentDescription: String,
241247 onDismiss: () -> Unit,
242248) {
243− var scale by remember { mutableFloatStateOf(1f) }
249+ // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
250+ // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
251+ val scale = remember { Animatable(1f) }
244252 var offsetX by remember { mutableFloatStateOf(0f) }
245253 var offsetY by remember { mutableFloatStateOf(0f) }
254+ val scope = rememberCoroutineScope()
246255 Dialog(
247256 onDismissRequest = onDismiss,
248257 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -253,12 +262,40 @@ private fun FullscreenImageViewer(
253262 .background(androidx.compose.ui.graphics.Color.Black)
254263 .pointerInput(Unit) {
255264 detectTransformGestures { _, pan, zoom, _ ->
256− scale *= zoom
257− offsetX += pan.x
258− offsetY += pan.y
265+ // Clamp the zoom so the image can't be scaled down below 1× (which would
266+ // leave empty letterbox) or blown up so far it becomes a handful of pixels.
267+ val newScale = (scale.value * zoom).coerceIn(1f, MAX_IMAGE_ZOOM)
268+ scope.launch { scale.snapTo(newScale) }
269+ if (newScale <= 1f) {
270+ // Returning to 1× recenters so a prior pan doesn't leave the image
271+ // stuck off-center at its rest size.
272+ offsetX = 0f
273+ offsetY = 0f
274+ } else {
275+ offsetX += pan.x
276+ offsetY += pan.y
277+ }
259278 }
260279 }
261− .clickable(role = Role.Button, onClick = onDismiss),
280+ // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
281+ // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
282+ // defers the single-tap callback until it knows the touch isn't the first of a pair.
283+ .pointerInput(Unit) {
284+ detectTapGestures(
285+ onDoubleTap = {
286+ scope.launch {
287+ if (scale.value > 1.001f) {
288+ offsetX = 0f
289+ offsetY = 0f
290+ scale.animateTo(1f, tween(IMAGE_ZOOM_ANIM_MS))
291+ } else {
292+ scale.animateTo(DOUBLE_TAP_ZOOM, tween(IMAGE_ZOOM_ANIM_MS))
293+ }
294+ }
295+ },
296+ onTap = { onDismiss() },
297+ )
298+ },
262299 contentAlignment = Alignment.Center,
263300 ) {
264301 SubcomposeAsyncImage(
@@ -268,8 +305,8 @@ private fun FullscreenImageViewer(
268305 modifier = Modifier
269306 .fillMaxSize()
270307 .graphicsLayer(
271− scaleX = scale,
272− scaleY = scale,
308+ scaleX = scale.value,
309+ scaleY = scale.value,
273310 translationX = offsetX,
274311 translationY = offsetY,
275312 ),
@@ -289,6 +326,13 @@ private fun FullscreenImageViewer(
289326 }
290327}
291328
329+/** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
330+private const val MAX_IMAGE_ZOOM = 5f
331+/** Zoom factor a double-tap jumps to when starting from 1×. */
332+private const val DOUBLE_TAP_ZOOM = 2.5f
333+/** Duration (ms) of the animated double-tap zoom transition. */
334+private const val IMAGE_ZOOM_ANIM_MS = 220
335+
292336/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
293337 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
294338@Composable
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−2
@@ -57,6 +57,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5757import androidx.compose.ui.platform.testTag
5858import androidx.compose.ui.semantics.Role
5959import androidx.compose.ui.semantics.contentDescription
60+import androidx.compose.ui.semantics.heading
6061import androidx.compose.ui.semantics.role
6162import androidx.compose.ui.semantics.semantics
6263import androidx.compose.ui.text.SpanStyle
@@ -337,7 +338,9 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
337338 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
338339 style = MaterialTheme.typography.labelSmall,
339340 color = MaterialTheme.colorScheme.onSurfaceVariant,
340− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
341+ modifier = Modifier.fillMaxWidth()
342+ .padding(horizontal = 16.dp, vertical = 6.dp)
343+ .semantics { heading() },
341344 )
342345 }
343346 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
@@ -408,7 +411,9 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
408411 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
409412 style = MaterialTheme.typography.labelSmall,
410413 color = MaterialTheme.colorScheme.onSurfaceVariant,
411− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
414+ modifier = Modifier.fillMaxWidth()
415+ .padding(horizontal = 16.dp, vertical = 6.dp)
416+ .semantics { heading() },
412417 )
413418 }
414419 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+9−3
@@ -1,7 +1,6 @@
11package soy.iko.opencode.ui.file
22
33import android.content.Intent
4−import soy.iko.opencode.ui.components.showToast
54import androidx.compose.animation.AnimatedContent
65import androidx.compose.animation.core.tween
76import androidx.compose.animation.fadeIn
@@ -50,6 +49,8 @@ import androidx.compose.material3.IconButton
5049import androidx.compose.material3.MaterialTheme
5150import androidx.compose.material3.OutlinedTextField
5251import androidx.compose.material3.Scaffold
52+import androidx.compose.material3.SnackbarHost
53+import androidx.compose.material3.SnackbarHostState
5354import androidx.compose.material3.Text
5455import androidx.compose.material3.TextButton
5556import androidx.compose.material3.TopAppBar
@@ -61,6 +62,7 @@ import androidx.compose.runtime.mutableIntStateOf
6162import androidx.compose.runtime.mutableStateOf
6263import androidx.compose.runtime.produceState
6364import androidx.compose.runtime.remember
65+import androidx.compose.runtime.rememberCoroutineScope
6466import androidx.compose.runtime.saveable.rememberSaveable
6567import androidx.compose.runtime.setValue
6668import androidx.compose.ui.Alignment
@@ -92,6 +94,7 @@ import soy.iko.opencode.util.runCatchingCancellable
9294import kotlinx.coroutines.Dispatchers
9395import kotlinx.coroutines.delay
9496import kotlinx.coroutines.flow.collect
97+import kotlinx.coroutines.launch
9598import kotlinx.coroutines.withContext
9699
97100import soy.iko.opencode.data.network.NetworkConfig
@@ -110,6 +113,8 @@ fun FileViewScreen(
110113 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
111114 val state by vm.state.collectAsStateWithLifecycle()
112115 val context = LocalContext.current
116+ val scope = rememberCoroutineScope()
117+ val snackbar = remember { SnackbarHostState() }
113118 val filename = remember(path) {
114119 val trimmed = path.trimEnd('/')
115120 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
@@ -188,7 +193,7 @@ fun FileViewScreen(
188193 // lives when the user is in diff mode.
189194 onToggleFind = {
190195 if (showToggle && showDiff) {
191− showToast(context, context.getString(R.string.switch_to_raw_to_search))
196+ scope.launch { snackbar.showSnackbar(context.getString(R.string.switch_to_raw_to_search)) }
192197 } else {
193198 findActive = !findActive
194199 if (!findActive) findQuery = ""
@@ -203,10 +208,11 @@ fun FileViewScreen(
203208 putExtra(Intent.EXTRA_TEXT, rawText)
204209 }
205210 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
206− .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
211+ .onFailure { scope.launch { snackbar.showSnackbar(context.getString(R.string.no_share_app)) } }
207212 },
208213 )
209214 },
215+ snackbarHost = { SnackbarHost(snackbar) },
210216 ) { padding ->
211217 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
212218 // can reload the file, mirroring the file browser and session list gestures. The
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+223−27
@@ -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+}
app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt+23
@@ -11,6 +11,9 @@ import kotlinx.serialization.json.contentOrNull
1111 * A configured MCP (Model Context Protocol) server, as read from the server's `config.mcp`
1212 * map. [type] is "local" (spawns a command) or "remote" (connects to a URL); [target] is the
1313 * command line or URL, whichever applies. Best-effort — fields the server omits are null.
14+ *
15+ * The live [connected]/[error]/[toolCount] fields come from `GET /mcp` status (merged in by the
16+ * ViewModel) and are null when status wasn't available, so the card degrades to a static view.
1417 */
1518@Immutable
1619data class McpServerInfo(
@@ -18,6 +21,9 @@ data class McpServerInfo(
1821 val type: String?,
1922 val enabled: Boolean,
2023 val target: String?,
24+ val connected: Boolean? = null,
25+ val error: String? = null,
26+ val toolCount: Int? = null,
2127)
2228
2329/**
@@ -43,3 +49,20 @@ fun parseMcpServers(config: JsonObject): List<McpServerInfo> {
4349 }
4450 }.sortedBy { it.name.lowercase() }
4551}
52+
53+/**
54+ * Merge live status from `GET /mcp` into the configured-server list. The status object maps
55+ * server name → status object with (best-effort) `connected`, `error`, and a `tools` array.
56+ * Servers present in config but missing from status keep null fields (e.g. while they're still
57+ * connecting, or on a server build that doesn't expose `/mcp`). Pure for testability.
58+ */
59+fun mergeMcpStatus(servers: List<McpServerInfo>, status: JsonObject?): List<McpServerInfo> {
60+ if (status == null) return servers
61+ return servers.map { server ->
62+ val st = status[server.name] as? JsonObject ?: return@map server
63+ val connected = (st["connected"] as? JsonPrimitive)?.booleanOrNull
64+ val error = (st["error"] as? JsonPrimitive)?.contentOrNull
65+ val tools = (st["tools"] as? JsonArray)?.size
66+ server.copy(connected = connected, error = error, toolCount = tools)
67+ }
68+}
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt+94−2
@@ -4,17 +4,34 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import kotlinx.coroutines.ExperimentalCoroutinesApi
66import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.flow.MutableSharedFlow
78import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharedFlow
810import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asSharedFlow
912import kotlinx.coroutines.flow.asStateFlow
1013import kotlinx.coroutines.flow.collectLatest
1114import kotlinx.coroutines.flow.merge
1215import kotlinx.coroutines.launch
1316import kotlinx.coroutines.withContext
17+import kotlinx.serialization.json.JsonObject
18+import kotlinx.serialization.json.JsonPrimitive
19+import kotlinx.serialization.json.add
20+import kotlinx.serialization.json.buildJsonObject
21+import kotlinx.serialization.json.put
22+import kotlinx.serialization.json.putJsonArray
23+import kotlinx.serialization.json.putJsonObject
1424import soy.iko.opencode.di.AppContainer
1525import soy.iko.opencode.util.runCatchingCancellable
1626
17−/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
27+/** The shape of an MCP server the user is registering from the Add dialog. */
28+enum class McpKind { LOCAL, REMOTE }
29+
30+/** User-facing outcomes of an Add-server attempt; the screen maps these to localized strings. */
31+enum class McpMessage { ADDED, ADD_FAILED, NOT_CONNECTED }
32+
33+/** Loads the configured MCP servers from the active connection's `config`, enriched with live
34+ * status from `GET /mcp`, and supports dynamically registering a new server via `POST /mcp`. */
1835@OptIn(ExperimentalCoroutinesApi::class)
1936class McpViewModel(private val container: AppContainer) : ViewModel() {
2037
@@ -33,6 +50,18 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
3350 private val _refreshing = MutableStateFlow(false)
3451 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
3552
53+ /** True while an Add-server POST is in flight, so the dialog can show a spinner and block a
54+ * second concurrent submit. */
55+ private val _adding = MutableStateFlow(false)
56+ val adding: StateFlow<Boolean> = _adding.asStateFlow()
57+
58+ /** One-shot user-facing messages (add success/failure) for the snackbar host. */
59+ private val _messages = MutableSharedFlow<McpMessage>(
60+ extraBufferCapacity = 1,
61+ onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
62+ )
63+ val messages: SharedFlow<McpMessage> = _messages.asSharedFlow()
64+
3665 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
3766 private val _reload = MutableStateFlow(0)
3867
@@ -59,10 +88,73 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
5988 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
6089 runCatchingCancellable {
6190 val config = conn.api.config()
62− withContext(Dispatchers.Default) { parseMcpServers(config) }
91+ // Fetch live status separately so a missing/unstable /mcp endpoint (older server
92+ // builds, transient error) never blocks the config-driven view — it just omits the
93+ // connected/error indicators for that load.
94+ val status = runCatchingCancellable { conn.api.mcpStatus() }.getOrNull()
95+ withContext(Dispatchers.Default) {
96+ mergeMcpStatus(parseMcpServers(config), status)
97+ }
6398 }
6499 .onSuccess { _state.value = State.Ready(it) }
65100 .onFailure { _state.value = State.Error }
66101 _refreshing.value = false
67102 }
103+
104+ /** Register a new MCP server via `POST /mcp`. Builds the server config object from the
105+ * kind/target the user entered. A name collision is left for the server to reject (it
106+ * overwrites/validates server-side); the result surfaces as a snackbar. */
107+ fun addServer(name: String, kind: McpKind, target: String, env: String) {
108+ if (_adding.value) return
109+ val conn = container.activeConnection.value ?: run {
110+ viewModelScope.launch { _messages.tryEmit(McpMessage.NOT_CONNECTED) }
111+ return
112+ }
113+ val cleanName = name.trim()
114+ val cleanTarget = target.trim()
115+ if (cleanName.isEmpty() || cleanTarget.isEmpty()) return
116+ val config = buildMcpConfig(kind, cleanTarget, env)
117+ viewModelScope.launch {
118+ _adding.value = true
119+ val ok = runCatchingCancellable { conn.api.addMcp(cleanName, config) }.isSuccess
120+ _adding.value = false
121+ if (ok) {
122+ _messages.tryEmit(McpMessage.ADDED)
123+ load()
124+ } else {
125+ _messages.tryEmit(McpMessage.ADD_FAILED)
126+ }
127+ }
128+ }
129+}
130+
131+/** Build a `config.mcp`-shaped object for a new server. Local servers carry a `command` token
132+ * array; remote servers carry a `url`. Env vars (one KEY=VALUE per line) are parsed tolerantly. */
133+private fun buildMcpConfig(kind: McpKind, target: String, env: String): JsonObject = buildJsonObject {
134+ when (kind) {
135+ McpKind.LOCAL -> {
136+ put("type", "local")
137+ putJsonArray("command") {
138+ // Split the command line into argv the way a shell roughly would: on whitespace.
139+ // This intentionally doesn't handle quoting — MCP command fields are normally a
140+ // simple program + args, and over-engineering a parser here would surprise more
141+ // than it helps. Users needing quotes can edit config.mcp directly.
142+ target.split(Regex("\\s+")).filter { it.isNotEmpty() }.forEach { add(it) }
143+ }
144+ }
145+ McpKind.REMOTE -> {
146+ put("type", "remote")
147+ put("url", target)
148+ }
149+ }
150+ val envPairs = env.lineSequence()
151+ .mapNotNull { line ->
152+ val eq = line.indexOf('=')
153+ if (eq <= 0) null else line.substring(0, eq).trim() to line.substring(eq + 1).trim()
154+ }
155+ .filter { it.first.isNotEmpty() }
156+ .toList()
157+ if (envPairs.isNotEmpty()) {
158+ putJsonObject("env") { envPairs.forEach { (k, v) -> put(k, JsonPrimitive(v)) } }
159+ }
68160}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+4−1
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.pluralStringResource
4040import androidx.compose.ui.res.stringResource
4141import androidx.compose.ui.semantics.Role
4242import androidx.compose.ui.semantics.contentDescription
43+import androidx.compose.ui.semantics.heading
4344import androidx.compose.ui.semantics.semantics
4445import androidx.compose.ui.text.AnnotatedString
4546import androidx.compose.ui.text.SpanStyle
@@ -54,6 +55,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
5455import soy.iko.opencode.R
5556import soy.iko.opencode.di.AppContainer
5657import soy.iko.opencode.ui.components.AppTopBar
58+import soy.iko.opencode.ui.components.ConnectionBannerFor
5759import soy.iko.opencode.ui.components.EmptyState
5860import soy.iko.opencode.ui.components.RelativeTimeText
5961import soy.iko.opencode.ui.vmFactory
@@ -103,6 +105,7 @@ fun GlobalSearchScreen(
103105 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
104106 )
105107 Box(modifier = Modifier.fillMaxSize()) {
108+ ConnectionBannerFor(container)
106109 val searchingLabel = stringResource(R.string.searching)
107110 when {
108111 state.searching -> CircularProgressIndicator(
@@ -144,7 +147,7 @@ fun GlobalSearchScreen(
144147 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
145148 style = MaterialTheme.typography.labelMedium,
146149 color = MaterialTheme.colorScheme.onSurfaceVariant,
147− modifier = Modifier.padding(top = 8.dp),
150+ modifier = Modifier.padding(top = 8.dp).semantics { heading() },
148151 )
149152 }
150153 if (state.truncated) {
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+68−25
@@ -16,11 +16,13 @@ import androidx.compose.foundation.lazy.items
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.filled.Add
1818import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Close
1920import androidx.compose.material.icons.filled.Error
2021import androidx.compose.material.icons.filled.Delete
2122import androidx.compose.material.icons.filled.Dns
2223import androidx.compose.material.icons.filled.MoreVert
2324import androidx.compose.material.icons.filled.QrCodeScanner
25+import androidx.compose.material.icons.filled.Search
2426import androidx.compose.material3.AlertDialog
2527import androidx.compose.material3.Button
2628import androidx.compose.material3.Card
@@ -31,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
3133import androidx.compose.material3.FloatingActionButton
3234import androidx.compose.material3.Icon
3335import androidx.compose.material3.IconButton
36+import androidx.compose.material3.OutlinedTextField
3437import androidx.compose.material3.LinearProgressIndicator
3538import androidx.compose.material3.MaterialTheme
3639import androidx.compose.material3.Scaffold
@@ -77,6 +80,7 @@ import soy.iko.opencode.data.network.NetworkConfig
7780import soy.iko.opencode.util.runCatchingCancellable
7881import soy.iko.opencode.di.AppContainer
7982import soy.iko.opencode.R
83+import soy.iko.opencode.ui.components.EmptyState
8084import soy.iko.opencode.ui.components.rememberRelativeTime
8185import soy.iko.opencode.ui.vmFactory
8286
@@ -105,6 +109,10 @@ fun ServerListScreen(
105109 val undoLabel = stringResource(R.string.undo)
106110 val serverRemovedLabel = stringResource(R.string.server_removed)
107111 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
112+ // Optional name/URL filter. Only surfaced once the user has enough profiles that scanning
113+ // by eye is slower than typing (a handful of servers is faster to just tap), matching the
114+ // threshold logic the file/session pickers use for their search fields.
115+ var serverQuery by rememberSaveable { mutableStateOf("") }
108116 val connectedId = activeConnection?.profile?.id
109117 // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
110118 // payload awaiting the user's confirm before it's saved (Import from image action).
@@ -224,6 +232,7 @@ fun ServerListScreen(
224232 modifier = Modifier.fillMaxSize(),
225233 )
226234 } else {
235+ val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
227236 PullToRefreshBox(
228237 isRefreshing = connectingId != null,
229238 onRefresh = { vm.refresh(onConnected) },
@@ -237,7 +246,26 @@ fun ServerListScreen(
237246 ),
238247 verticalArrangement = Arrangement.spacedBy(12.dp),
239248 ) {
240− items(profiles, key = { it.id }) { profile ->
249+ // Show the filter field only past a small threshold so a typical
250+ // 1–2 server setup isn't cluttered with a search bar.
251+ if (profiles.size >= NetworkConfig.serverListSearchThreshold) {
252+ item(key = "__search") {
253+ ServerListSearchField(
254+ query = serverQuery,
255+ onQueryChange = { serverQuery = it },
256+ )
257+ }
258+ }
259+ if (filtered.isEmpty()) {
260+ item(key = "__no_match") {
261+ EmptyState(
262+ icon = Icons.Filled.Search,
263+ title = stringResource(R.string.no_server_matches),
264+ modifier = Modifier.fillMaxWidth().padding(24.dp),
265+ )
266+ }
267+ }
268+ items(filtered, key = { it.id }) { profile ->
241269 val isActive = profile.id == connectedId
242270 if (isActive) {
243271 // The active server can't be swipe-deleted (deleting it also
@@ -509,30 +537,14 @@ private fun LastUsedText(lastUsed: Long) {
509537@Composable
510538private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
511539 Box(modifier = modifier, contentAlignment = Alignment.Center) {
512− Column(
513− modifier = Modifier.padding(32.dp),
514− horizontalAlignment = Alignment.CenterHorizontally,
515− ) {
516− Icon(
517− Icons.Filled.Dns,
518− contentDescription = null,
519− modifier = Modifier.size(56.dp),
520− tint = MaterialTheme.colorScheme.onSurfaceVariant,
521− )
522− Spacer(Modifier.size(16.dp))
523− Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
524− Spacer(Modifier.size(8.dp))
525− Text(
526− stringResource(R.string.no_servers_hint),
527− style = MaterialTheme.typography.bodyMedium,
528− color = MaterialTheme.colorScheme.onSurfaceVariant,
529− )
530− Spacer(Modifier.size(20.dp))
531− Button(onClick = onAdd) {
532− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
533− Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
534− }
535− }
540+ EmptyState(
541+ icon = Icons.Filled.Dns,
542+ title = stringResource(R.string.no_servers_yet),
543+ description = stringResource(R.string.no_servers_hint),
544+ actionIcon = Icons.Filled.Add,
545+ actionLabel = stringResource(R.string.add_server),
546+ onAction = onAdd,
547+ )
536548 }
537549}
538550
@@ -598,3 +610,34 @@ private fun ServerQrImportHandler(
598610 }
599611 return launcher
600612}
613+
614+/** Filter server profiles by label/base URL. Extracted from [ServerListScreen] to keep its
615+ * cyclomatic complexity under the detekt threshold. Empty query returns all profiles. */
616+private fun filterServerProfiles(profiles: List<ServerProfile>, query: String): List<ServerProfile> {
617+ val q = query.trim()
618+ if (q.isEmpty()) return profiles
619+ return profiles.filter {
620+ it.label.contains(q, ignoreCase = true) || it.baseUrl.contains(q, ignoreCase = true)
621+ }
622+}
623+
624+/** The server-list filter field with a clear button. Extracted from [ServerListScreen] to keep
625+ * its cyclomatic complexity under the detekt threshold. */
626+@Composable
627+private fun ServerListSearchField(query: String, onQueryChange: (String) -> Unit) {
628+ OutlinedTextField(
629+ value = query,
630+ onValueChange = onQueryChange,
631+ modifier = Modifier.fillMaxWidth(),
632+ placeholder = { Text(stringResource(R.string.search_servers)) },
633+ singleLine = true,
634+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
635+ trailingIcon = {
636+ if (query.isNotEmpty()) {
637+ IconButton(onClick = { onQueryChange("") }) {
638+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
639+ }
640+ }
641+ },
642+ )
643+}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+14−1
@@ -39,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
3939import androidx.compose.ui.unit.dp
4040import androidx.compose.ui.res.stringResource
4141import soy.iko.opencode.R
42+import soy.iko.opencode.data.network.NetworkConfig
4243
4344/** The user's directory choice in the new-session picker. */
4445private sealed interface DirChoice {
@@ -109,6 +110,7 @@ fun NewSessionDialog(
109110 // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
110111 // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
111112 var titleText by rememberSaveable { mutableStateOf("") }
113+ val titleLimit = NetworkConfig.maxSessionTitleChars
112114
113115 // The initializer above may have run while options were still loading (serverDefault
114116 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
@@ -143,13 +145,14 @@ fun NewSessionDialog(
143145 ) {
144146 OutlinedTextField(
145147 value = titleText,
146− onValueChange = { titleText = it.take(120) },
148+ onValueChange = { titleText = it.take(titleLimit) },
147149 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
148150 label = { Text(stringResource(R.string.session_title_hint)) },
149151 placeholder = { Text(stringResource(R.string.new_session)) },
150152 singleLine = true,
151153 enabled = !creating,
152154 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
155+ supportingText = { TitleCharCounter(titleText.length, titleLimit) },
153156 )
154157 Text(
155158 stringResource(R.string.directory_label),
@@ -234,6 +237,16 @@ fun NewSessionDialog(
234237 )
235238}
236239
240+/** Character counter for the session-title field, shown only once the title nears the cap so a
241+ * normal short title isn't cluttered. Extracted from [NewSessionDialog] to keep its complexity
242+ * under the detekt threshold. */
243+@Composable
244+private fun TitleCharCounter(length: Int, limit: Int) {
245+ if (length >= (limit * 0.8f).toInt()) {
246+ Text("$length / $limit")
247+ }
248+}
249+
237250@Composable
238251private fun DirectoryRow(
239252 label: String,
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+69−1
@@ -21,7 +21,9 @@ import androidx.compose.foundation.rememberScrollState
2121import androidx.compose.foundation.verticalScroll
2222import androidx.compose.material.icons.Icons
2323import androidx.compose.material.icons.filled.BugReport
24+import androidx.compose.material.icons.filled.Close
2425import androidx.compose.material.icons.filled.DeleteSweep
26+import androidx.compose.material.icons.filled.Search
2527import androidx.compose.material.icons.filled.Share
2628import androidx.compose.material3.AlertDialog
2729import androidx.compose.material3.CircularProgressIndicator
@@ -30,6 +32,7 @@ import androidx.compose.material3.HorizontalDivider
3032import androidx.compose.material3.Icon
3133import androidx.compose.material3.IconButton
3234import androidx.compose.material3.MaterialTheme
35+import androidx.compose.material3.OutlinedTextField
3336import androidx.compose.material3.Scaffold
3437import androidx.compose.material3.SnackbarDuration
3538import androidx.compose.material3.SnackbarHost
@@ -91,6 +94,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
9194 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
9295 var showClearAll by rememberSaveable { mutableStateOf(false) }
9396 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
97+ // Optional text filter over the crash report previews/file names. Only surfaced once there
98+ // are enough reports that scrolling is slower than typing.
99+ var crashQuery by rememberSaveable { mutableStateOf("") }
94100 val shareScope = rememberCoroutineScope()
95101 val haptics = LocalHapticFeedback.current
96102 val snackbar = remember { SnackbarHostState() }
@@ -207,8 +213,12 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
207213 modifier = Modifier.fillMaxWidth().padding(24.dp),
208214 )
209215 } else {
216+ val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
210217 LazyColumn(modifier = Modifier.fillMaxSize()) {
211− items(reports, key = { it.fileName }) { report ->
218+ crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
219+ crashQuery = it
220+ }
221+ items(filtered, key = { it.fileName }) { report ->
212222 // Swipe end-to-start reveals a delete affordance. The delete is
213223 // deferred for an undo window (matching the session/server lists);
214224 // confirmValueChange snaps back so the row remains while the undo
@@ -428,3 +438,61 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
428438 )
429439 }
430440}
441+
442+/** Filter crash reports by preview/file name. Extracted from [DiagnosticsScreen] to keep its
443+ * cyclomatic complexity under the detekt threshold. Empty query returns all reports. */
444+private fun filterCrashReports(
445+ reports: List<CrashLogger.CrashReport>,
446+ query: String,
447+): List<CrashLogger.CrashReport> {
448+ val q = query.trim()
449+ if (q.isEmpty()) return reports
450+ return reports.filter {
451+ it.preview.contains(q, ignoreCase = true) || it.fileName.contains(q, ignoreCase = true)
452+ }
453+}
454+
455+/** The diagnostics filter field with a clear button. Extracted from [DiagnosticsScreen] to keep
456+ * its cyclomatic complexity under the detekt threshold. */
457+@Composable
458+private fun CrashQueryField(query: String, onQueryChange: (String) -> Unit) {
459+ OutlinedTextField(
460+ value = query,
461+ onValueChange = onQueryChange,
462+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
463+ placeholder = { Text(stringResource(R.string.search_crash_reports)) },
464+ singleLine = true,
465+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
466+ trailingIcon = {
467+ if (query.isNotEmpty()) {
468+ IconButton(onClick = { onQueryChange("") }) {
469+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
470+ }
471+ }
472+ },
473+ )
474+}
475+
476+/** Conditionally emits the search field and the no-match placeholder into the crash list.
477+ * Extracted from [DiagnosticsScreen] to keep its cyclomatic complexity under the threshold. */
478+private fun androidx.compose.foundation.lazy.LazyListScope.crashSearchHeader(
479+ reports: List<CrashLogger.CrashReport>,
480+ filtered: List<CrashLogger.CrashReport>,
481+ crashQuery: String,
482+ onQueryChange: (String) -> Unit,
483+) {
484+ if (reports.size >= NetworkConfig.diagnosticsSearchThreshold) {
485+ item(key = "__search") {
486+ CrashQueryField(query = crashQuery, onQueryChange = onQueryChange)
487+ }
488+ }
489+ if (crashQuery.isNotBlank() && filtered.isEmpty()) {
490+ item(key = "__no_match") {
491+ EmptyState(
492+ icon = Icons.Filled.Search,
493+ title = stringResource(R.string.no_crash_matches),
494+ modifier = Modifier.fillMaxWidth().padding(24.dp),
495+ )
496+ }
497+ }
498+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+26−4
@@ -40,6 +40,8 @@ import androidx.compose.material3.IconButton
4040import androidx.compose.material3.MaterialTheme
4141import androidx.compose.material3.RadioButton
4242import androidx.compose.material3.Scaffold
43+import androidx.compose.material3.SnackbarHost
44+import androidx.compose.material3.SnackbarHostState
4345import androidx.compose.material3.Slider
4446import androidx.compose.material3.Switch
4547import androidx.compose.material3.Text
@@ -58,6 +60,7 @@ import androidx.compose.ui.Modifier
5860import androidx.compose.ui.draw.clip
5961import androidx.compose.ui.graphics.Color
6062import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalDensity
6164import androidx.compose.ui.res.pluralStringResource
6265import androidx.compose.ui.res.stringResource
6366import androidx.compose.ui.semantics.Role
@@ -80,8 +83,8 @@ import soy.iko.opencode.data.repo.SettingsStore
8083import soy.iko.opencode.data.repo.ThemeMode
8184import soy.iko.opencode.di.AppContainer
8285import soy.iko.opencode.R
86+import soy.iko.opencode.data.network.NetworkConfig
8387import soy.iko.opencode.ui.canAuthenticateForAppLock
84−import soy.iko.opencode.ui.components.showToast
8588import soy.iko.opencode.ui.theme.LightPaletteSwatches
8689import soy.iko.opencode.ui.theme.DarkPaletteSwatches
8790import soy.iko.opencode.util.runCatchingCancellable
@@ -98,6 +101,7 @@ fun SettingsScreen(
98101 onOpenMcp: () -> Unit = {},
99102) {
100103 val scope = rememberCoroutineScope()
104+ val snackbar = remember { SnackbarHostState() }
101105 // Combine the three settings into a single nullable state so the appearance section
102106 // renders only after the persisted values have loaded, avoiding a brief flash of
103107 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
@@ -166,7 +170,8 @@ fun SettingsScreen(
166170 }
167171
168172 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
169− // import reads a chosen file and applies it. Feedback is a toast (this screen has no snackbar).
173+ // import reads a chosen file and applies it. Feedback goes through the snackbar host so it's
174+ // accessible to TalkBack and consistent with the rest of the app (Toast isn't).
170175 var includePasswords by rememberSaveable { mutableStateOf(false) }
171176 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
172177 // and only applied once the user confirms the replace dialog below. Export with passwords
@@ -187,7 +192,7 @@ fun SettingsScreen(
187192 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
188193 ?: error("no output stream")
189194 }.isSuccess
190− showToast(context, if (ok) exportedMsg else exportFailedMsg)
195+ snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
191196 }
192197 }
193198 val importLauncher = rememberLauncherForActivityResult(
@@ -216,6 +221,7 @@ fun SettingsScreen(
216221 },
217222 )
218223 },
224+ snackbarHost = { SnackbarHost(snackbar) },
219225 ) { padding ->
220226 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
221227 val s = settings
@@ -576,7 +582,7 @@ fun SettingsScreen(
576582 ?: error("no input stream")
577583 container.backupManager.import(text)
578584 }.isSuccess
579− showToast(context, if (ok) importedMsg else importFailedMsg)
585+ snackbar.showSnackbar(if (ok) importedMsg else importFailedMsg)
580586 }
581587 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
582588 },
@@ -680,6 +686,22 @@ private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
680686 },
681687 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
682688 ) { Text(stringResource(R.string.reset)) }
689+ // The app text scale multiplies on top of the OS font scale, and the effective size is
690+ // clamped so the product can't exceed the combined cap (see OpencodeApp). When the user's
691+ // system font is already large, the value they pick here would be silently reduced — so
692+ // surface that the effective size is capped rather than letting the slider appear broken.
693+ val systemFontScale = LocalDensity.current.fontScale
694+ if (systemFontScale > 0f) {
695+ val effective = (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
696+ if (effective < sliderValue) {
697+ Text(
698+ stringResource(R.string.text_size_capped, (effective * 100).roundToInt()),
699+ style = MaterialTheme.typography.bodySmall,
700+ color = MaterialTheme.colorScheme.onSurfaceVariant,
701+ modifier = Modifier.padding(top = 2.dp),
702+ )
703+ }
704+ }
683705}
684706
685707@Composable
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+58−3
@@ -16,21 +16,31 @@ import androidx.compose.foundation.layout.size
1616import androidx.compose.foundation.lazy.LazyColumn
1717import androidx.compose.foundation.lazy.items
1818import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.filled.Close
1920import androidx.compose.material.icons.filled.CloudOff
2021import androidx.compose.material.icons.filled.ErrorOutline
2122import androidx.compose.material.icons.filled.QueryStats
2223import androidx.compose.material.icons.filled.Refresh
24+import androidx.compose.material.icons.filled.Search
25+import androidx.compose.material.icons.filled.SearchOff
2326import androidx.compose.material3.Card
2427import androidx.compose.material3.CircularProgressIndicator
2528import androidx.compose.material3.ExperimentalMaterial3Api
2629import androidx.compose.material3.Icon
2730import androidx.compose.material3.IconButton
2831import androidx.compose.material3.MaterialTheme
32+import androidx.compose.material3.OutlinedTextField
2933import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarHost
35+import androidx.compose.material3.SnackbarHostState
3036import androidx.compose.material3.Text
3137import androidx.compose.material3.pulltorefresh.PullToRefreshBox
3238import androidx.compose.runtime.Composable
3339import androidx.compose.runtime.getValue
40+import androidx.compose.runtime.mutableStateOf
41+import androidx.compose.runtime.remember
42+import androidx.compose.runtime.saveable.rememberSaveable
43+import androidx.compose.runtime.setValue
3444import androidx.compose.ui.Alignment
3545import androidx.compose.ui.Modifier
3646import androidx.compose.ui.draw.clip
@@ -38,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
3848import androidx.compose.ui.res.stringResource
3949import androidx.compose.ui.semantics.Role
4050import androidx.compose.ui.semantics.contentDescription
51+import androidx.compose.ui.semantics.heading
4152import androidx.compose.ui.semantics.semantics
4253import androidx.compose.ui.text.style.TextOverflow
4354import androidx.compose.ui.unit.dp
@@ -45,8 +56,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
4556import androidx.lifecycle.viewmodel.compose.viewModel
4657import soy.iko.opencode.R
4758import soy.iko.opencode.data.model.Tokens
59+import soy.iko.opencode.data.network.NetworkConfig
4860import soy.iko.opencode.di.AppContainer
4961import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.ConnectionBannerFor
5063import soy.iko.opencode.ui.components.EmptyState
5164import soy.iko.opencode.ui.components.SectionHeader
5265import soy.iko.opencode.ui.vmFactory
@@ -59,6 +72,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
5972 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
6073 val state by vm.state.collectAsStateWithLifecycle()
6174 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
75+ val snackbar = remember { SnackbarHostState() }
6276
6377 Scaffold(
6478 topBar = {
@@ -72,6 +86,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
7286 },
7387 )
7488 },
89+ snackbarHost = { SnackbarHost(snackbar) },
7590 ) { padding ->
7691 PullToRefreshBox(
7792 isRefreshing = refreshing,
@@ -79,6 +94,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
7994 modifier = Modifier.fillMaxSize().padding(padding),
8095 ) {
8196 Box(modifier = Modifier.fillMaxSize()) {
97+ ConnectionBannerFor(container)
8298 val loadingLabel = stringResource(R.string.usage_loading)
8399 when (val s = state) {
84100 is UsageViewModel.State.Loading -> Centered {
@@ -125,6 +141,15 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
125141
126142@Composable
127143private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
144+ // Optional session-title filter. The global totals (cost/tokens/by-model) always reflect
145+ // the whole history, but a user with many sessions can narrow the per-session list to find
146+ // a specific conversation's spend without scrolling through all of it.
147+ var sessionQuery by rememberSaveable { mutableStateOf("") }
148+ val filteredSessions = remember(report.bySession, sessionQuery) {
149+ val q = sessionQuery.trim()
150+ if (q.isEmpty()) report.bySession
151+ else report.bySession.filter { it.title.contains(q, ignoreCase = true) }
152+ }
128153 LazyColumn(
129154 modifier = Modifier.fillMaxSize(),
130155 contentPadding = PaddingValues(16.dp),
@@ -133,7 +158,7 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
133158 item { TotalsCard(report) }
134159 item { TokenBreakdownCard(report.totalTokens) }
135160 if (report.byModel.isNotEmpty()) {
136− item { SectionHeader(stringResource(R.string.usage_by_model)) }
161+ item { SectionHeader(stringResource(R.string.usage_by_model), Modifier.semantics { heading() }) }
137162 // Key on provider+model: the same model id served by two providers yields two
138163 // rows, so keying on the bare model would duplicate keys and crash the list.
139164 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
@@ -147,8 +172,38 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
147172 }
148173 }
149174 if (report.bySession.isNotEmpty()) {
150− item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
151− items(report.bySession, key = { it.sessionId }) { s ->
175+ item { SectionHeader(stringResource(R.string.usage_all_sessions), Modifier.semantics { heading() }) }
176+ // Only show the filter field once there are enough sessions to justify it, so a
177+ // short history isn't cluttered with a search bar.
178+ if (report.bySession.size >= NetworkConfig.usageSessionSearchThreshold) {
179+ item(key = "__session_search") {
180+ OutlinedTextField(
181+ value = sessionQuery,
182+ onValueChange = { sessionQuery = it },
183+ modifier = Modifier.fillMaxWidth(),
184+ placeholder = { Text(stringResource(R.string.usage_filter_sessions)) },
185+ singleLine = true,
186+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
187+ trailingIcon = {
188+ if (sessionQuery.isNotEmpty()) {
189+ IconButton(onClick = { sessionQuery = "" }) {
190+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
191+ }
192+ }
193+ },
194+ )
195+ }
196+ }
197+ if (sessionQuery.isNotBlank() && filteredSessions.isEmpty()) {
198+ item(key = "__no_match") {
199+ EmptyState(
200+ icon = Icons.Filled.SearchOff,
201+ title = stringResource(R.string.usage_no_sessions),
202+ modifier = Modifier.fillMaxWidth().padding(24.dp),
203+ )
204+ }
205+ }
206+ items(filteredSessions, key = { it.sessionId }) { s ->
152207 UsageRow(
153208 title = s.title,
154209 cost = s.cost,
app/src/main/res/values-es/strings.xml+23
@@ -46,11 +46,14 @@
4646
4747 <!-- Server list -->
4848 <string name="servers_title">Servidores de opencode</string>
49+ <string name="sessions_title">Sesiones</string>
4950 <string name="add_server">Añadir servidor</string>
5051 <string name="edit_server">Editar servidor</string>
5152 <string name="connected">Conectado</string>
5253 <string name="no_servers_yet">Aún no hay servidores</string>
5354 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
55+ <string name="search_servers">Filtrar servidores</string>
56+ <string name="no_server_matches">Ningún servidor coincide</string>
5457 <string name="remove_server_title">¿Quitar servidor?</string>
5558 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
5659 <string name="remove_server_active_text">«%1$s» es tu servidor actual. Al quitarlo también te desconectarás.</string>
@@ -228,6 +231,8 @@
228231 <string name="diagnostics_desc">Los informes de fallos se guardan localmente en este dispositivo.</string>
229232 <string name="crash_reports">Informes de fallos</string>
230233 <string name="no_crash_reports">No hay fallos registrados.</string>
234+ <string name="search_crash_reports">Filtrar informes de fallos</string>
235+ <string name="no_crash_matches">Ningún informe coincide</string>
231236 <string name="clear_all">Borrar todo</string>
232237 <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados?</string>
233238 <string name="reports_cleared">Informes de fallos eliminados</string>
@@ -363,6 +368,7 @@
363368 <string name="app_lock_prompt_subtitle">Autentícate para acceder a tus servidores.</string>
364369 <string name="app_locked">opencode está bloqueado</string>
365370 <string name="app_unlock">Desbloquear</string>
371+ <string name="app_locked_hint">Autentícate para continuar</string>
366372
367373 <!-- Nuevas funciones -->
368374 <string name="edit_message">Editar</string>
@@ -409,6 +415,7 @@
409415 <string name="reset">Restablecer</string>
410416 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
411417 <string name="text_scale_value">%1$d%%</string>
418+ <string name="text_size_capped">Limitado al %1$d%% por el tamaño de fuente del sistema para mantener la legibilidad.</string>
412419
413420 <!-- Message actions -->
414421 <string name="message_actions">Acciones del mensaje</string>
@@ -438,6 +445,8 @@
438445 <string name="usage_by_model">Por modelo</string>
439446 <string name="usage_this_session">Esta sesión</string>
440447 <string name="usage_all_sessions">Todas las sesiones</string>
448+ <string name="usage_filter_sessions">Filtrar sesiones</string>
449+ <string name="usage_no_sessions">Ninguna sesión coincide</string>
441450 <string name="usage_empty">Aún no hay uso registrado</string>
442451 <plurals name="usage_messages_count">
443452 <item quantity="one">%1$d mensaje del asistente</item>
@@ -476,6 +485,20 @@
476485 <string name="mcp_empty">No hay servidores MCP configurados</string>
477486 <string name="mcp_failed">No se pudieron cargar los servidores MCP</string>
478487 <string name="mcp_connected">Conectado</string>
488+ <string name="mcp_disconnected">Desconectado</string>
489+ <string name="mcp_add">Añadir servidor MCP</string>
490+ <string name="mcp_add_button">Añadir</string>
491+ <string name="mcp_added">Servidor MCP añadido</string>
492+ <string name="mcp_add_failed">No se pudo añadir el servidor MCP</string>
493+ <string name="mcp_add_name">Nombre</string>
494+ <string name="mcp_kind_local">Local (comando)</string>
495+ <string name="mcp_kind_remote">Remoto (URL)</string>
496+ <string name="mcp_add_command">Comando</string>
497+ <string name="mcp_add_command_hint">npx -y @modelcontextprotocol/server-everything</string>
498+ <string name="mcp_add_url">URL</string>
499+ <string name="mcp_add_url_hint">https://example.com/sse</string>
500+ <string name="mcp_add_env">Variables de entorno (opcional)</string>
501+ <string name="mcp_add_env_hint">API_KEY=valor (una por línea)</string>
479502 <string name="mcp_failed_state">Falló</string>
480503 <string name="mcp_disabled">Desactivado</string>
481504 <string name="mcp_tools_count">%1$d herramientas</string>
app/src/main/res/values/strings.xml+23
@@ -47,11 +47,14 @@
4747
4848 <!-- Server list -->
4949 <string name="servers_title">opencode servers</string>
50+ <string name="sessions_title">Sessions</string>
5051 <string name="add_server">Add server</string>
5152 <string name="edit_server">Edit server</string>
5253 <string name="connected">Connected</string>
5354 <string name="no_servers_yet">No servers yet</string>
5455 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
56+ <string name="search_servers">Filter servers</string>
57+ <string name="no_server_matches">No matching servers</string>
5558 <string name="remove_server_title">Remove server?</string>
5659 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
5760 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
@@ -237,6 +240,8 @@
237240 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
238241 <string name="crash_reports">Crash reports</string>
239242 <string name="no_crash_reports">No crashes recorded.</string>
243+ <string name="search_crash_reports">Filter crash reports</string>
244+ <string name="no_crash_matches">No matching crash reports</string>
240245 <string name="clear_all">Clear all</string>
241246 <string name="clear_all_confirm">Delete all stored crash reports?</string>
242247 <string name="reports_cleared">Crash reports cleared</string>
@@ -408,6 +413,7 @@
408413 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
409414 <string name="app_locked">opencode is locked</string>
410415 <string name="app_unlock">Unlock</string>
416+ <string name="app_locked_hint">Authenticate to continue</string>
411417
412418 <!-- Agent task plan (todowrite tool) -->
413419 <string name="plan">Plan</string>
@@ -431,6 +437,7 @@
431437 <string name="reset">Reset</string>
432438 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
433439 <string name="text_scale_value">%1$d%%</string>
440+ <string name="text_size_capped">Capped to %1$d%% by your system font size to keep layouts readable.</string>
434441
435442 <!-- Message actions -->
436443 <string name="message_actions">Message actions</string>
@@ -460,6 +467,8 @@
460467 <string name="usage_by_model">By model</string>
461468 <string name="usage_this_session">This session</string>
462469 <string name="usage_all_sessions">All sessions</string>
470+ <string name="usage_filter_sessions">Filter sessions</string>
471+ <string name="usage_no_sessions">No matching sessions</string>
463472 <string name="usage_empty">No usage recorded yet</string>
464473 <plurals name="usage_messages_count">
465474 <item quantity="one">%1$d assistant message</item>
@@ -498,6 +507,20 @@
498507 <string name="mcp_empty">No MCP servers configured</string>
499508 <string name="mcp_failed">Couldn\'t load MCP servers</string>
500509 <string name="mcp_connected">Connected</string>
510+ <string name="mcp_disconnected">Disconnected</string>
511+ <string name="mcp_add">Add MCP server</string>
512+ <string name="mcp_add_button">Add</string>
513+ <string name="mcp_added">MCP server added</string>
514+ <string name="mcp_add_failed">Couldn\'t add MCP server</string>
515+ <string name="mcp_add_name">Name</string>
516+ <string name="mcp_kind_local">Local (command)</string>
517+ <string name="mcp_kind_remote">Remote (URL)</string>
518+ <string name="mcp_add_command">Command</string>
519+ <string name="mcp_add_command_hint">npx -y @modelcontextprotocol/server-everything</string>
520+ <string name="mcp_add_url">URL</string>
521+ <string name="mcp_add_url_hint">https://example.com/sse</string>
522+ <string name="mcp_add_env">Environment variables (optional)</string>
523+ <string name="mcp_add_env_hint">API_KEY=value (one per line)</string>
501524 <string name="mcp_failed_state">Failed</string>
502525 <string name="mcp_disabled">Disabled</string>
503526 <string name="mcp_tools_count">%1$d tools</string>