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)
a9e66cfiko
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+12
@@ -221,6 +221,18 @@ object NetworkConfig {
221 * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
221 * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
222 * eye, so popping the keyboard for it is just friction. */
222 * eye, so popping the keyboard for it is just friction. */
223 const val pickerSearchAutofocusThreshold = 8
223 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
224 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
236 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
225 * jump-to-latest FAB so it never floats over the last message. */
237 * jump-to-latest FAB so it never floats over the last message. */
226 const val chatListFabInsetDp = 80
238 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
42import kotlinx.coroutines.delay
42import kotlinx.coroutines.delay
43import kotlinx.coroutines.sync.Mutex
43import kotlinx.coroutines.sync.Mutex
44import kotlinx.coroutines.sync.withLock
44import kotlinx.coroutines.sync.withLock
45+import kotlinx.serialization.json.buildJsonObject
46+import kotlinx.serialization.json.put
45import kotlin.random.Random
47import kotlin.random.Random
46import java.net.URLEncoder
48import java.net.URLEncoder
47
49
@@ -312,6 +314,33 @@ open class OpencodeApiClient private constructor(
312 client!!.get("config").body()
314 client!!.get("config").body()
313 }
315 }
314
316
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+
315 open suspend fun agents(): List<Agent> {
344 open suspend fun agents(): List<Agent> {
316 val generation: Int
345 val generation: Int
317 agentsMutex.withLock {
346 agentsMutex.withLock {
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+34−6
@@ -5,6 +5,7 @@ import android.content.ContextWrapper
5import android.os.Build
5import android.os.Build
6import androidx.biometric.BiometricManager
6import androidx.biometric.BiometricManager
7import androidx.biometric.BiometricPrompt
7import androidx.biometric.BiometricPrompt
8+import androidx.compose.foundation.clickable
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.Spacer
11import androidx.compose.foundation.layout.Spacer
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxSize
12import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.size
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Fingerprint
15import androidx.compose.material.icons.filled.Lock
17import androidx.compose.material.icons.filled.Lock
16import androidx.compose.material3.Button
18import androidx.compose.material3.Button
17import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.CircularProgressIndicator
@@ -31,6 +33,7 @@ import androidx.compose.ui.Modifier
31import androidx.compose.ui.platform.LocalContext
33import androidx.compose.ui.platform.LocalContext
32import androidx.compose.ui.res.stringResource
34import androidx.compose.ui.res.stringResource
33import androidx.compose.ui.semantics.LiveRegionMode
35import androidx.compose.ui.semantics.LiveRegionMode
36+import androidx.compose.ui.semantics.Role
34import androidx.compose.ui.semantics.liveRegion
37import androidx.compose.ui.semantics.liveRegion
35import androidx.compose.ui.semantics.semantics
38import androidx.compose.ui.semantics.semantics
36import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
@@ -217,20 +220,39 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
217 if (unlocked) {
220 if (unlocked) {
218 stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
221 stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
219 } else {
222 } 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)
220 Surface(
232 Surface(
221 modifier = Modifier.fillMaxSize(),
233 modifier = Modifier.fillMaxSize(),
222 color = MaterialTheme.colorScheme.background,
234 color = MaterialTheme.colorScheme.background,
223 ) {
235 ) {
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.
224 Column(
239 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),
226 horizontalAlignment = Alignment.CenterHorizontally,
248 horizontalAlignment = Alignment.CenterHorizontally,
227 verticalArrangement = Arrangement.Center,
249 verticalArrangement = Arrangement.Center,
228 ) {
250 ) {
229 Icon(
251 Icon(
230− Icons.Filled.Lock,
252+ if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
231− contentDescription = stringResource(R.string.app_locked),
253+ contentDescription = null,
232− modifier = Modifier.size(48.dp),
254+ modifier = Modifier.size(56.dp),
233− tint = MaterialTheme.colorScheme.onSurfaceVariant,
255+ tint = MaterialTheme.colorScheme.primary,
234 )
256 )
235 Text(
257 Text(
236 stringResource(R.string.app_locked),
258 stringResource(R.string.app_locked),
@@ -239,6 +261,12 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
239 .padding(top = 16.dp)
261 .padding(top = 16.dp)
240 .semantics { liveRegion = LiveRegionMode.Polite },
262 .semantics { liveRegion = LiveRegionMode.Polite },
241 )
263 )
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+ )
242 Button(
270 Button(
243 onClick = { authenticate() },
271 onClick = { authenticate() },
244 enabled = !authInFlight,
272 enabled = !authInFlight,
@@ -252,7 +280,7 @@ fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composa
252 )
280 )
253 Spacer(Modifier.size(8.dp))
281 Spacer(Modifier.size(8.dp))
254 }
282 }
255− Text(stringResource(R.string.app_unlock))
283+ Text(unlockLabel)
256 }
284 }
257 }
285 }
258 }
286 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+38−2
@@ -7,7 +7,11 @@ import androidx.compose.animation.fadeIn
7import androidx.compose.animation.fadeOut
7import androidx.compose.animation.fadeOut
8import androidx.compose.animation.slideInHorizontally
8import androidx.compose.animation.slideInHorizontally
9import androidx.compose.animation.slideOutHorizontally
9import androidx.compose.animation.slideOutHorizontally
10+import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.BoxWithConstraints
11import 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
11import androidx.compose.runtime.Composable
15import androidx.compose.runtime.Composable
12import androidx.compose.runtime.CompositionLocalProvider
16import androidx.compose.runtime.CompositionLocalProvider
13import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.LaunchedEffect
@@ -16,11 +20,15 @@ import androidx.compose.runtime.mutableStateOf
16import androidx.compose.runtime.rememberCoroutineScope
20import androidx.compose.runtime.rememberCoroutineScope
17import androidx.compose.runtime.saveable.rememberSaveable
21import androidx.compose.runtime.saveable.rememberSaveable
18import androidx.compose.runtime.setValue
22import androidx.compose.runtime.setValue
23+import androidx.compose.ui.Modifier
19import androidx.compose.ui.platform.LocalDensity
24import androidx.compose.ui.platform.LocalDensity
20import androidx.compose.ui.unit.dp
25import androidx.compose.ui.unit.dp
26+import androidx.compose.ui.unit.LayoutDirection
27+import androidx.compose.ui.platform.LocalLayoutDirection
21import androidx.navigation.NavType
28import androidx.navigation.NavType
22import androidx.navigation.compose.NavHost
29import androidx.navigation.compose.NavHost
23import androidx.navigation.compose.composable
30import androidx.navigation.compose.composable
31+import androidx.navigation.compose.currentBackStackEntryAsState
24import androidx.navigation.compose.rememberNavController
32import androidx.navigation.compose.rememberNavController
25import androidx.navigation.navArgument
33import androidx.navigation.navArgument
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
34import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -33,6 +41,7 @@ import soy.iko.opencode.ui.file.FileViewScreen
33import soy.iko.opencode.ui.server.ServerEditScreen
41import soy.iko.opencode.ui.server.ServerEditScreen
34import soy.iko.opencode.ui.server.ServerListScreen
42import soy.iko.opencode.ui.server.ServerListScreen
35import soy.iko.opencode.ui.search.GlobalSearchScreen
43import soy.iko.opencode.ui.search.GlobalSearchScreen
44+import soy.iko.opencode.ui.components.LargeScreenNavRail
36import soy.iko.opencode.ui.components.LocalChatTextScale
45import soy.iko.opencode.ui.components.LocalChatTextScale
37import soy.iko.opencode.ui.components.LocalCodeWrap
46import soy.iko.opencode.ui.components.LocalCodeWrap
38import soy.iko.opencode.ui.components.isReducedMotion
47import soy.iko.opencode.ui.components.isReducedMotion
@@ -151,10 +160,21 @@ fun OpencodeApp(container: AppContainer) {
151 // Capture the reduced-motion preference once (in the composable scope) so the
160 // Capture the reduced-motion preference once (in the composable scope) so the
152 // non-composable NavHost transition lambdas can use it to skip animations.
161 // non-composable NavHost transition lambdas can use it to skip animations.
153 val reducedMotion = isReducedMotion()
162 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
154 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
166 // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
155 // the open conversation side by side instead of a single-pane back stack.
167 // the open conversation side by side instead of a single-pane back stack.
156 BoxWithConstraints {
168 BoxWithConstraints {
157 val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
169 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
158
178
159 // Open a session requested by a notification tap or deep link, once connected.
179 // Open a session requested by a notification tap or deep link, once connected.
160 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
180 LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
@@ -191,7 +211,9 @@ fun OpencodeApp(container: AppContainer) {
191 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
211 navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
192 }
212 }
193
213
214+ val navHost = @Composable {
194 NavHost(
215 NavHost(
216+ modifier = Modifier.fillMaxSize(),
195 navController = navController,
217 navController = navController,
196 startDestination = Routes.SERVERS,
218 startDestination = Routes.SERVERS,
197 // Motion-aware transitions: respect the user's reduced-motion setting
219 // Motion-aware transitions: respect the user's reduced-motion setting
@@ -206,7 +228,7 @@ fun OpencodeApp(container: AppContainer) {
206 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
228 } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
207 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
229 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
208 } else {
230 } else {
209− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
231+ slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
210 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
232 fadeIn(tween(NetworkConfig.motionFadeDurationMs))
211 }
233 }
212 },
234 },
@@ -228,7 +250,7 @@ fun OpencodeApp(container: AppContainer) {
228 if (reducedMotion) {
250 if (reducedMotion) {
229 ExitTransition.None
251 ExitTransition.None
230 } else {
252 } else {
231− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it } +
253+ slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
232 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
254 fadeOut(tween(NetworkConfig.motionFadeDurationMs))
233 }
255 }
234 },
256 },
@@ -392,6 +414,20 @@ fun OpencodeApp(container: AppContainer) {
392 )
414 )
393 }
415 }
394 }
416 }
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+ }
395 }
431 }
396 }
432 }
397}
433}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+84−12
@@ -51,6 +51,7 @@ import androidx.compose.material.icons.filled.PhotoLibrary
51import androidx.compose.material.icons.filled.Refresh
51import androidx.compose.material.icons.filled.Refresh
52import androidx.compose.material.icons.filled.Restore
52import androidx.compose.material.icons.filled.Restore
53import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
53import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
54+import androidx.compose.material.icons.automirrored.filled.Reply
54import androidx.compose.material.icons.filled.Schedule
55import androidx.compose.material.icons.filled.Schedule
55import androidx.compose.material.icons.filled.Stop
56import androidx.compose.material.icons.filled.Stop
56import androidx.compose.material3.AlertDialog
57import androidx.compose.material3.AlertDialog
@@ -69,8 +70,13 @@ import androidx.compose.material3.IconButton
69import androidx.compose.material3.MaterialTheme
70import androidx.compose.material3.MaterialTheme
70import androidx.compose.material3.OutlinedTextField
71import androidx.compose.material3.OutlinedTextField
71import androidx.compose.material3.Scaffold
72import 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
72import androidx.compose.material3.SnackbarHost
77import androidx.compose.material3.SnackbarHost
73import androidx.compose.material3.SnackbarHostState
78import androidx.compose.material3.SnackbarHostState
79+import androidx.compose.material3.SnackbarResult
74import androidx.compose.material3.Surface
80import androidx.compose.material3.Surface
75import androidx.compose.material3.Text
81import androidx.compose.material3.Text
76import androidx.compose.material3.TextButton
82import androidx.compose.material3.TextButton
@@ -116,6 +122,8 @@ import androidx.compose.ui.unit.dp
116import androidx.lifecycle.compose.collectAsStateWithLifecycle
122import androidx.lifecycle.compose.collectAsStateWithLifecycle
117import androidx.lifecycle.viewmodel.compose.viewModel
123import androidx.lifecycle.viewmodel.compose.viewModel
118import kotlinx.coroutines.Dispatchers
124import kotlinx.coroutines.Dispatchers
125+import kotlinx.coroutines.coroutineScope
126+import kotlinx.coroutines.delay
119import kotlinx.coroutines.flow.distinctUntilChanged
127import kotlinx.coroutines.flow.distinctUntilChanged
120import kotlinx.coroutines.launch
128import kotlinx.coroutines.launch
121import kotlinx.coroutines.withContext
129import kotlinx.coroutines.withContext
@@ -290,6 +298,30 @@ fun ChatScreen(
290 }
298 }
291 }
299 }
292
300
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+
293 // --- Attachments, voice, and share-link plumbing ---
325 // --- Attachments, voice, and share-link plumbing ---
294 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
326 val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
295 val attachFailedMsg = stringResource(R.string.attachment_failed)
327 val attachFailedMsg = stringResource(R.string.attachment_failed)
@@ -457,7 +489,6 @@ fun ChatScreen(
457 // Texts and announced only "Change model and agent".
489 // Texts and announced only "Change model and agent".
458 modifier = Modifier
490 modifier = Modifier
459 .clickable(
491 .clickable(
460− enabled = models.isNotEmpty(),
461 role = Role.Button,
492 role = Role.Button,
462 onClickLabel = changeLabel,
493 onClickLabel = changeLabel,
463 ) { showTitleMenu = true }
494 ) { showTitleMenu = true }
@@ -480,17 +511,17 @@ fun ChatScreen(
480 overflow = TextOverflow.Ellipsis,
511 overflow = TextOverflow.Ellipsis,
481 )
512 )
482 }
513 }
483− // Visual affordance that the title is tappable; without it the
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
484− // title is indistinguishable from plain text and the pickers are
516+ // is indistinguishable from plain text and the pickers are effectively
485− // effectively undiscoverable.
517+ // undiscoverable — and the dropdown items already disable themselves
486− if (models.isNotEmpty()) {
518+ // while a catalog is loading or unavailable.
487− Icon(
519+ Icon(
488− Icons.Filled.KeyboardArrowDown,
520+ Icons.Filled.KeyboardArrowDown,
489− contentDescription = null,
521+ contentDescription = null,
490− tint = MaterialTheme.colorScheme.onSurfaceVariant,
522+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
491− modifier = Modifier.size(20.dp),
523+ modifier = Modifier.size(20.dp),
492− )
524+ )
493− }
494 }
525 }
495 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
526 DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
496 DropdownMenuItem(
527 DropdownMenuItem(
@@ -991,6 +1022,46 @@ fun ChatScreen(
991 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1022 (message.info as? soy.iko.opencode.data.model.AssistantMessage)
992 ?.let { resolveModelLabel(it, models) }
1023 ?.let { resolveModelLabel(it, models) }
993 }
1024 }
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+ ) {
994 MessageBubble(
1065 MessageBubble(
995 message,
1066 message,
996 isRunning = running && message.info.id == lastMessageId,
1067 isRunning = running && message.info.id == lastMessageId,
@@ -1039,6 +1110,7 @@ fun ChatScreen(
1039 }
1110 }
1040 },
1111 },
1041 )
1112 )
1113+ }
1042 }
1114 }
1043 }
1115 }
1044 }
1116 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+27−12
@@ -32,9 +32,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
32import kotlinx.coroutines.channels.BufferOverflow
32import kotlinx.coroutines.channels.BufferOverflow
33import kotlinx.coroutines.channels.Channel
33import kotlinx.coroutines.channels.Channel
34import kotlinx.coroutines.flow.Flow
34import kotlinx.coroutines.flow.Flow
35+import kotlinx.coroutines.flow.MutableSharedFlow
35import kotlinx.coroutines.flow.MutableStateFlow
36import kotlinx.coroutines.flow.MutableStateFlow
37+import kotlinx.coroutines.flow.SharedFlow
36import kotlinx.coroutines.flow.SharingStarted
38import kotlinx.coroutines.flow.SharingStarted
37import kotlinx.coroutines.flow.StateFlow
39import kotlinx.coroutines.flow.StateFlow
40+import kotlinx.coroutines.flow.asSharedFlow
38import kotlinx.coroutines.flow.asStateFlow
41import kotlinx.coroutines.flow.asStateFlow
39import kotlinx.coroutines.flow.receiveAsFlow
42import kotlinx.coroutines.flow.receiveAsFlow
40import kotlinx.coroutines.CancellationException
43import kotlinx.coroutines.CancellationException
@@ -361,6 +364,15 @@ class ChatViewModel(
361 )
364 )
362 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
365 val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
363
366
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+
364 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
376 private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
365 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
377 val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
366
378
@@ -1433,27 +1445,30 @@ class ChatViewModel(
1433 }
1445 }
1434 }
1446 }
1435
1447
1436− /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
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.
1437− * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1454+ * 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. */
1440 fun deleteSession() {
1455 fun deleteSession() {
1441 val conn = connection ?: return
1456 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.
1445 container.scheduleSessionDelete(
1457 container.scheduleSessionDelete(
1446 id = sessionId,
1458 id = sessionId,
1447 delayMs = NetworkConfig.undoDeleteDelayMs,
1459 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 },
1449 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1463 onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1450 )
1464 )
1451− // Let the SessionListScreen show the undo snackbar for this externally-initiated delete.
1465+ _deleteUndoEvents.tryEmit(Unit)
1452− container.emitExternalSessionUndo(sessionId)
1453− // Navigate back immediately so the user lands on the session list where the Undo shows.
1454− _sessionDeleted.value = true
1455 }
1466 }
1456
1467
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+
1457 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1472 /** Reconnect to the most recently used server profile (used when the connection is gone). */
1458 fun reconnect() {
1473 fun reconnect() {
1459 if (container.activeConnection.value != null) return
1474 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 @@
1package soy.iko.opencode.ui.components
1package soy.iko.opencode.ui.components
2
2
3import android.provider.Settings
3import 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
13import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Composable
14import androidx.compose.runtime.remember
5import androidx.compose.runtime.remember
15import androidx.compose.ui.platform.LocalContext
6import androidx.compose.ui.platform.LocalContext
@@ -32,16 +23,3 @@ fun isReducedMotion(): Boolean {
32 }.getOrDefault(false)
23 }.getOrDefault(false)
33 }
24 }
34}
25}
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
3import android.util.Base64
3import android.util.Base64
4import androidx.compose.foundation.background
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.gestures.detectTapGestures
6import androidx.compose.foundation.gestures.detectTransformGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
7import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Box
8import androidx.compose.foundation.layout.Column
9import androidx.compose.foundation.layout.Column
@@ -22,6 +23,7 @@ import androidx.compose.runtime.mutableIntStateOf
22import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.mutableStateOf
23import androidx.compose.runtime.produceState
24import androidx.compose.runtime.produceState
24import androidx.compose.runtime.remember
25import androidx.compose.runtime.remember
26+import androidx.compose.runtime.rememberCoroutineScope
25import androidx.compose.runtime.setValue
27import androidx.compose.runtime.setValue
26import androidx.compose.ui.Alignment
28import androidx.compose.ui.Alignment
27import androidx.compose.ui.Modifier
29import androidx.compose.ui.Modifier
@@ -40,6 +42,9 @@ import androidx.compose.ui.window.DialogProperties
40import coil.compose.SubcomposeAsyncImage
42import coil.compose.SubcomposeAsyncImage
41import coil.request.ImageRequest
43import coil.request.ImageRequest
42import kotlinx.coroutines.Dispatchers
44import kotlinx.coroutines.Dispatchers
45+import kotlinx.coroutines.launch
46+import androidx.compose.animation.core.Animatable
47+import androidx.compose.animation.core.tween
43import kotlinx.coroutines.withContext
48import kotlinx.coroutines.withContext
44import androidx.compose.material.icons.Icons
49import androidx.compose.material.icons.Icons
45import androidx.compose.material.icons.filled.BrokenImage
50import androidx.compose.material.icons.filled.BrokenImage
@@ -231,8 +236,9 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
231
236
232/**
237/**
233 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
238 * 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.
239+ * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
235− * Uses a non-interactive Dialog window so it overlays the whole screen.
240+ * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
241+ * the whole screen.
236 */
242 */
237@Composable
243@Composable
238private fun FullscreenImageViewer(
244private fun FullscreenImageViewer(
@@ -240,9 +246,12 @@ private fun FullscreenImageViewer(
240 contentDescription: String,
246 contentDescription: String,
241 onDismiss: () -> Unit,
247 onDismiss: () -> Unit,
242) {
248) {
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.
243− var scale by remember { mutableFloatStateOf(1f) }
251+ val scale = remember { Animatable(1f) }
244 var offsetX by remember { mutableFloatStateOf(0f) }
252 var offsetX by remember { mutableFloatStateOf(0f) }
245 var offsetY by remember { mutableFloatStateOf(0f) }
253 var offsetY by remember { mutableFloatStateOf(0f) }
254+ val scope = rememberCoroutineScope()
246 Dialog(
255 Dialog(
247 onDismissRequest = onDismiss,
256 onDismissRequest = onDismiss,
248 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
257 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
@@ -253,12 +262,40 @@ private fun FullscreenImageViewer(
253 .background(androidx.compose.ui.graphics.Color.Black)
262 .background(androidx.compose.ui.graphics.Color.Black)
254 .pointerInput(Unit) {
263 .pointerInput(Unit) {
255 detectTransformGestures { _, pan, zoom, _ ->
264 detectTransformGestures { _, pan, zoom, _ ->
256− scale *= zoom
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 {
257− offsetX += pan.x
275+ offsetX += pan.x
258− offsetY += pan.y
276+ offsetY += pan.y
277+ }
259 }
278 }
260 }
279 }
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+ },
262 contentAlignment = Alignment.Center,
299 contentAlignment = Alignment.Center,
263 ) {
300 ) {
264 SubcomposeAsyncImage(
301 SubcomposeAsyncImage(
@@ -268,8 +305,8 @@ private fun FullscreenImageViewer(
268 modifier = Modifier
305 modifier = Modifier
269 .fillMaxSize()
306 .fillMaxSize()
270 .graphicsLayer(
307 .graphicsLayer(
271− scaleX = scale,
308+ scaleX = scale.value,
272− scaleY = scale,
309+ scaleY = scale.value,
273 translationX = offsetX,
310 translationX = offsetX,
274 translationY = offsetY,
311 translationY = offsetY,
275 ),
312 ),
@@ -289,6 +326,13 @@ private fun FullscreenImageViewer(
289 }
326 }
290}
327}
291
328
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+
292/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
336/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
293 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
337 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
294@Composable
338@Composable
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+7−2
@@ -57,6 +57,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
57import androidx.compose.ui.platform.testTag
57import androidx.compose.ui.platform.testTag
58import androidx.compose.ui.semantics.Role
58import androidx.compose.ui.semantics.Role
59import androidx.compose.ui.semantics.contentDescription
59import androidx.compose.ui.semantics.contentDescription
60+import androidx.compose.ui.semantics.heading
60import androidx.compose.ui.semantics.role
61import androidx.compose.ui.semantics.role
61import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.semantics.semantics
62import androidx.compose.ui.text.SpanStyle
63import androidx.compose.ui.text.SpanStyle
@@ -337,7 +338,9 @@ private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen:
337 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
338 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
338 style = MaterialTheme.typography.labelSmall,
339 style = MaterialTheme.typography.labelSmall,
339 color = MaterialTheme.colorScheme.onSurfaceVariant,
340 color = MaterialTheme.colorScheme.onSurfaceVariant,
341+ modifier = Modifier.fillMaxWidth()
340− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
342+ .padding(horizontal = 16.dp, vertical = 6.dp)
343+ .semantics { heading() },
341 )
344 )
342 }
345 }
343 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
346 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
@@ -408,7 +411,9 @@ private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, on
408 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
411 pluralStringResource(R.plurals.file_search_results, results.size, results.size),
409 style = MaterialTheme.typography.labelSmall,
412 style = MaterialTheme.typography.labelSmall,
410 color = MaterialTheme.colorScheme.onSurfaceVariant,
413 color = MaterialTheme.colorScheme.onSurfaceVariant,
414+ modifier = Modifier.fillMaxWidth()
411− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
415+ .padding(horizontal = 16.dp, vertical = 6.dp)
416+ .semantics { heading() },
412 )
417 )
413 }
418 }
414 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
419 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 @@
1package soy.iko.opencode.ui.file
1package soy.iko.opencode.ui.file
2
2
3import android.content.Intent
3import android.content.Intent
4−import soy.iko.opencode.ui.components.showToast
5import androidx.compose.animation.AnimatedContent
4import androidx.compose.animation.AnimatedContent
6import androidx.compose.animation.core.tween
5import androidx.compose.animation.core.tween
7import androidx.compose.animation.fadeIn
6import androidx.compose.animation.fadeIn
@@ -50,6 +49,8 @@ import androidx.compose.material3.IconButton
50import androidx.compose.material3.MaterialTheme
49import androidx.compose.material3.MaterialTheme
51import androidx.compose.material3.OutlinedTextField
50import androidx.compose.material3.OutlinedTextField
52import androidx.compose.material3.Scaffold
51import androidx.compose.material3.Scaffold
52+import androidx.compose.material3.SnackbarHost
53+import androidx.compose.material3.SnackbarHostState
53import androidx.compose.material3.Text
54import androidx.compose.material3.Text
54import androidx.compose.material3.TextButton
55import androidx.compose.material3.TextButton
55import androidx.compose.material3.TopAppBar
56import androidx.compose.material3.TopAppBar
@@ -61,6 +62,7 @@ import androidx.compose.runtime.mutableIntStateOf
61import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.mutableStateOf
62import androidx.compose.runtime.produceState
63import androidx.compose.runtime.produceState
63import androidx.compose.runtime.remember
64import androidx.compose.runtime.remember
65+import androidx.compose.runtime.rememberCoroutineScope
64import androidx.compose.runtime.saveable.rememberSaveable
66import androidx.compose.runtime.saveable.rememberSaveable
65import androidx.compose.runtime.setValue
67import androidx.compose.runtime.setValue
66import androidx.compose.ui.Alignment
68import androidx.compose.ui.Alignment
@@ -92,6 +94,7 @@ import soy.iko.opencode.util.runCatchingCancellable
92import kotlinx.coroutines.Dispatchers
94import kotlinx.coroutines.Dispatchers
93import kotlinx.coroutines.delay
95import kotlinx.coroutines.delay
94import kotlinx.coroutines.flow.collect
96import kotlinx.coroutines.flow.collect
97+import kotlinx.coroutines.launch
95import kotlinx.coroutines.withContext
98import kotlinx.coroutines.withContext
96
99
97import soy.iko.opencode.data.network.NetworkConfig
100import soy.iko.opencode.data.network.NetworkConfig
@@ -110,6 +113,8 @@ fun FileViewScreen(
110 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
113 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
111 val state by vm.state.collectAsStateWithLifecycle()
114 val state by vm.state.collectAsStateWithLifecycle()
112 val context = LocalContext.current
115 val context = LocalContext.current
116+ val scope = rememberCoroutineScope()
117+ val snackbar = remember { SnackbarHostState() }
113 val filename = remember(path) {
118 val filename = remember(path) {
114 val trimmed = path.trimEnd('/')
119 val trimmed = path.trimEnd('/')
115 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
120 trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
@@ -188,7 +193,7 @@ fun FileViewScreen(
188 // lives when the user is in diff mode.
193 // lives when the user is in diff mode.
189 onToggleFind = {
194 onToggleFind = {
190 if (showToggle && showDiff) {
195 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)) }
192 } else {
197 } else {
193 findActive = !findActive
198 findActive = !findActive
194 if (!findActive) findQuery = ""
199 if (!findActive) findQuery = ""
@@ -203,10 +208,11 @@ fun FileViewScreen(
203 putExtra(Intent.EXTRA_TEXT, rawText)
208 putExtra(Intent.EXTRA_TEXT, rawText)
204 }
209 }
205 runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
210 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)) } }
207 },
212 },
208 )
213 )
209 },
214 },
215+ snackbarHost = { SnackbarHost(snackbar) },
210 ) { padding ->
216 ) { padding ->
211 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
217 // Pull-to-refresh wraps the content so a user who pull-to-refreshes out of habit
212 // can reload the file, mirroring the file browser and session list gestures. The
218 // 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
13import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Add
16import androidx.compose.material.icons.filled.CloudOff
17import androidx.compose.material.icons.filled.CloudOff
17import androidx.compose.material.icons.filled.CloudQueue
18import androidx.compose.material.icons.filled.CloudQueue
18import androidx.compose.material.icons.filled.Computer
19import androidx.compose.material.icons.filled.Computer
19import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.CheckCircle
22+import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.Hub
23import androidx.compose.material.icons.filled.Hub
21import androidx.compose.material.icons.filled.Refresh
24import androidx.compose.material.icons.filled.Refresh
25+import androidx.compose.material3.AlertDialog
22import androidx.compose.material3.Card
26import androidx.compose.material3.Card
23import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FilterChip
30+import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
32import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedTextField
28import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Scaffold
36+import androidx.compose.material3.SnackbarHost
37+import androidx.compose.material3.SnackbarHostState
29import androidx.compose.material3.Text
38import androidx.compose.material3.Text
39+import androidx.compose.material3.TextButton
30import androidx.compose.material3.pulltorefresh.PullToRefreshBox
40import androidx.compose.material3.pulltorefresh.PullToRefreshBox
31import androidx.compose.runtime.Composable
41import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
43import androidx.compose.runtime.getValue
44+import androidx.compose.runtime.mutableStateOf
45+import androidx.compose.runtime.remember
46+import androidx.compose.runtime.saveable.rememberSaveable
47+import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
35import androidx.compose.ui.res.stringResource
50import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.semantics.contentDescription
51import androidx.compose.ui.semantics.contentDescription
37import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
53+import androidx.compose.ui.text.font.FontFamily
38import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
40import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -42,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
42import soy.iko.opencode.R
58import soy.iko.opencode.R
43import soy.iko.opencode.di.AppContainer
59import soy.iko.opencode.di.AppContainer
44import soy.iko.opencode.ui.components.AppTopBar
60import soy.iko.opencode.ui.components.AppTopBar
61+import soy.iko.opencode.ui.components.ConnectionBannerFor
45import soy.iko.opencode.ui.components.EmptyState
62import soy.iko.opencode.ui.components.EmptyState
46import soy.iko.opencode.ui.vmFactory
63import soy.iko.opencode.ui.vmFactory
47
64
@@ -51,6 +68,24 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
51 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
68 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
52 val state by vm.state.collectAsStateWithLifecycle()
69 val state by vm.state.collectAsStateWithLifecycle()
53 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
70 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
71+ val adding by vm.adding.collectAsStateWithLifecycle()
72+ val snackbar = remember { SnackbarHostState() }
73+ val addedMsg = stringResource(R.string.mcp_added)
74+ val addFailedMsg = stringResource(R.string.mcp_add_failed)
75+ val notConnectedMsg = stringResource(R.string.not_connected)
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) }
54
89
55 Scaffold(
90 Scaffold(
56 topBar = {
91 topBar = {
@@ -64,6 +99,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
64 },
99 },
65 )
100 )
66 },
101 },
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+ },
67 ) { padding ->
112 ) { padding ->
68 PullToRefreshBox(
113 PullToRefreshBox(
69 isRefreshing = refreshing,
114 isRefreshing = refreshing,
@@ -71,6 +116,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
71 modifier = Modifier.fillMaxSize().padding(padding),
116 modifier = Modifier.fillMaxSize().padding(padding),
72 ) {
117 ) {
73 Box(modifier = Modifier.fillMaxSize()) {
118 Box(modifier = Modifier.fillMaxSize()) {
119+ ConnectionBannerFor(container)
74 val loadingLabel = stringResource(R.string.loading)
120 val loadingLabel = stringResource(R.string.loading)
75 when (val s = state) {
121 when (val s = state) {
76 is McpViewModel.State.Loading -> Column(
122 is McpViewModel.State.Loading -> Column(
@@ -105,7 +151,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
105 } else {
151 } else {
106 LazyColumn(
152 LazyColumn(
107 modifier = Modifier.fillMaxSize(),
153 modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(16.dp),
154+ contentPadding = PaddingValues(
155+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
156+ ),
109 verticalArrangement = Arrangement.spacedBy(12.dp),
157 verticalArrangement = Arrangement.spacedBy(12.dp),
110 ) {
158 ) {
111 items(s.servers, key = { it.name }) { McpServerCard(it) }
159 items(s.servers, key = { it.name }) { McpServerCard(it) }
@@ -115,40 +163,188 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
115 }
163 }
116 }
164 }
117 }
165 }
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+ }
118}
179}
119
180
120@Composable
181@Composable
121private fun McpServerCard(server: McpServerInfo) {
182private fun McpServerCard(server: McpServerInfo) {
183+ val remote = server.type.equals("remote", ignoreCase = true)
184+ val connected = server.connected
122 Card(modifier = Modifier.fillMaxWidth()) {
185 Card(modifier = Modifier.fillMaxWidth()) {
186+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
123− Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
187+ Row(verticalAlignment = Alignment.CenterVertically) {
124− val remote = server.type.equals("remote", ignoreCase = true)
125− Icon(
188+ Icon(
126− if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
189+ if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
127− contentDescription = null,
190+ contentDescription = null,
128− tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
191+ tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
129− )
192+ )
130− Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
193+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
131− Text(server.name, style = MaterialTheme.typography.titleSmall)
194+ Text(server.name, style = MaterialTheme.typography.titleSmall)
132− server.target?.let {
195+ server.target?.let {
133− Text(
196+ Text(
134− it,
197+ it,
135− style = MaterialTheme.typography.bodySmall,
198+ style = MaterialTheme.typography.bodySmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
199+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137− maxLines = 2,
200+ maxLines = 2,
138− overflow = TextOverflow.Ellipsis,
201+ overflow = TextOverflow.Ellipsis,
202+ fontFamily = FontFamily.Monospace,
139− )
203+ )
204+ }
140 }
205 }
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+ )
141 }
218 }
142− val stateLabel = if (server.enabled) {
219+ // Live status row: connected (with tool count) / error / unknown. Only shown when the
220+ // server reported a runtime state via /mcp, so a static config-only view isn't
221+ // cluttered with "unknown" chips.
222+ if (connected != null || server.error != null) {
223+ Spacer(Modifier.size(10.dp))
224+ Row(verticalAlignment = Alignment.CenterVertically) {
225+ if (server.error != null && connected != true) {
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) + " · " +
143− stringResource(R.string.mcp_enabled)
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+ )
144− } else {
257+ } else {
258+ Text(
145− stringResource(R.string.mcp_disabled)
259+ stringResource(R.string.mcp_disconnected),
260+ style = MaterialTheme.typography.labelSmall,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ modifier = Modifier.padding(start = 22.dp),
263+ )
264+ }
265+ }
146 }
266 }
147− Text(
148− stateLabel,
149− style = MaterialTheme.typography.labelMedium,
150− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
151− )
152 }
267 }
153 }
268 }
154}
269}
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
11 * A configured MCP (Model Context Protocol) server, as read from the server's `config.mcp`
11 * A configured MCP (Model Context Protocol) server, as read from the server's `config.mcp`
12 * map. [type] is "local" (spawns a command) or "remote" (connects to a URL); [target] is the
12 * map. [type] is "local" (spawns a command) or "remote" (connects to a URL); [target] is the
13 * command line or URL, whichever applies. Best-effort — fields the server omits are null.
13 * 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.
14 */
17 */
15@Immutable
18@Immutable
16data class McpServerInfo(
19data class McpServerInfo(
@@ -18,6 +21,9 @@ data class McpServerInfo(
18 val type: String?,
21 val type: String?,
19 val enabled: Boolean,
22 val enabled: Boolean,
20 val target: String?,
23 val target: String?,
24+ val connected: Boolean? = null,
25+ val error: String? = null,
26+ val toolCount: Int? = null,
21)
27)
22
28
23/**
29/**
@@ -43,3 +49,20 @@ fun parseMcpServers(config: JsonObject): List<McpServerInfo> {
43 }
49 }
44 }.sortedBy { it.name.lowercase() }
50 }.sortedBy { it.name.lowercase() }
45}
51}
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
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import kotlinx.coroutines.ExperimentalCoroutinesApi
5import kotlinx.coroutines.ExperimentalCoroutinesApi
6import kotlinx.coroutines.Dispatchers
6import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.flow.MutableSharedFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharedFlow
8import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asSharedFlow
9import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
10import kotlinx.coroutines.flow.collectLatest
13import kotlinx.coroutines.flow.collectLatest
11import kotlinx.coroutines.flow.merge
14import kotlinx.coroutines.flow.merge
12import kotlinx.coroutines.launch
15import kotlinx.coroutines.launch
13import kotlinx.coroutines.withContext
16import 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
14import soy.iko.opencode.di.AppContainer
24import soy.iko.opencode.di.AppContainer
15import soy.iko.opencode.util.runCatchingCancellable
25import soy.iko.opencode.util.runCatchingCancellable
16
26
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+
17−/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
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`. */
18@OptIn(ExperimentalCoroutinesApi::class)
35@OptIn(ExperimentalCoroutinesApi::class)
19class McpViewModel(private val container: AppContainer) : ViewModel() {
36class McpViewModel(private val container: AppContainer) : ViewModel() {
20
37
@@ -33,6 +50,18 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
33 private val _refreshing = MutableStateFlow(false)
50 private val _refreshing = MutableStateFlow(false)
34 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
51 val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
35
52
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+
36 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
65 /** Bumped by [load] (refresh/retry) to re-run without waiting for a connection change. */
37 private val _reload = MutableStateFlow(0)
66 private val _reload = MutableStateFlow(0)
38
67
@@ -59,10 +88,73 @@ class McpViewModel(private val container: AppContainer) : ViewModel() {
59 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
88 if (_state.value is State.Ready) _refreshing.value = true else _state.value = State.Loading
60 runCatchingCancellable {
89 runCatchingCancellable {
61 val config = conn.api.config()
90 val config = conn.api.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()
62− withContext(Dispatchers.Default) { parseMcpServers(config) }
95+ withContext(Dispatchers.Default) {
96+ mergeMcpStatus(parseMcpServers(config), status)
97+ }
63 }
98 }
64 .onSuccess { _state.value = State.Ready(it) }
99 .onSuccess { _state.value = State.Ready(it) }
65 .onFailure { _state.value = State.Error }
100 .onFailure { _state.value = State.Error }
66 _refreshing.value = false
101 _refreshing.value = false
67 }
102 }
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+ }
68}
160}
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+4−1
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.pluralStringResource
40import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
42import androidx.compose.ui.semantics.contentDescription
43+import androidx.compose.ui.semantics.heading
43import androidx.compose.ui.semantics.semantics
44import androidx.compose.ui.semantics.semantics
44import androidx.compose.ui.text.AnnotatedString
45import androidx.compose.ui.text.AnnotatedString
45import androidx.compose.ui.text.SpanStyle
46import androidx.compose.ui.text.SpanStyle
@@ -54,6 +55,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
54import soy.iko.opencode.R
55import soy.iko.opencode.R
55import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.ui.components.AppTopBar
57import soy.iko.opencode.ui.components.AppTopBar
58+import soy.iko.opencode.ui.components.ConnectionBannerFor
57import soy.iko.opencode.ui.components.EmptyState
59import soy.iko.opencode.ui.components.EmptyState
58import soy.iko.opencode.ui.components.RelativeTimeText
60import soy.iko.opencode.ui.components.RelativeTimeText
59import soy.iko.opencode.ui.vmFactory
61import soy.iko.opencode.ui.vmFactory
@@ -103,6 +105,7 @@ fun GlobalSearchScreen(
103 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
105 keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }),
104 )
106 )
105 Box(modifier = Modifier.fillMaxSize()) {
107 Box(modifier = Modifier.fillMaxSize()) {
108+ ConnectionBannerFor(container)
106 val searchingLabel = stringResource(R.string.searching)
109 val searchingLabel = stringResource(R.string.searching)
107 when {
110 when {
108 state.searching -> CircularProgressIndicator(
111 state.searching -> CircularProgressIndicator(
@@ -144,7 +147,7 @@ fun GlobalSearchScreen(
144 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
147 pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
145 style = MaterialTheme.typography.labelMedium,
148 style = MaterialTheme.typography.labelMedium,
146 color = MaterialTheme.colorScheme.onSurfaceVariant,
149 color = MaterialTheme.colorScheme.onSurfaceVariant,
147− modifier = Modifier.padding(top = 8.dp),
150+ modifier = Modifier.padding(top = 8.dp).semantics { heading() },
148 )
151 )
149 }
152 }
150 if (state.truncated) {
153 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
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18import androidx.compose.material.icons.filled.CheckCircle
18import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.Delete
21import androidx.compose.material.icons.filled.Delete
21import androidx.compose.material.icons.filled.Dns
22import androidx.compose.material.icons.filled.Dns
22import androidx.compose.material.icons.filled.MoreVert
23import androidx.compose.material.icons.filled.MoreVert
23import androidx.compose.material.icons.filled.QrCodeScanner
24import androidx.compose.material.icons.filled.QrCodeScanner
25+import androidx.compose.material.icons.filled.Search
24import androidx.compose.material3.AlertDialog
26import androidx.compose.material3.AlertDialog
25import androidx.compose.material3.Button
27import androidx.compose.material3.Button
26import androidx.compose.material3.Card
28import androidx.compose.material3.Card
@@ -31,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
31import androidx.compose.material3.FloatingActionButton
33import androidx.compose.material3.FloatingActionButton
32import androidx.compose.material3.Icon
34import androidx.compose.material3.Icon
33import androidx.compose.material3.IconButton
35import androidx.compose.material3.IconButton
36+import androidx.compose.material3.OutlinedTextField
34import androidx.compose.material3.LinearProgressIndicator
37import androidx.compose.material3.LinearProgressIndicator
35import androidx.compose.material3.MaterialTheme
38import androidx.compose.material3.MaterialTheme
36import androidx.compose.material3.Scaffold
39import androidx.compose.material3.Scaffold
@@ -77,6 +80,7 @@ import soy.iko.opencode.data.network.NetworkConfig
77import soy.iko.opencode.util.runCatchingCancellable
80import soy.iko.opencode.util.runCatchingCancellable
78import soy.iko.opencode.di.AppContainer
81import soy.iko.opencode.di.AppContainer
79import soy.iko.opencode.R
82import soy.iko.opencode.R
83+import soy.iko.opencode.ui.components.EmptyState
80import soy.iko.opencode.ui.components.rememberRelativeTime
84import soy.iko.opencode.ui.components.rememberRelativeTime
81import soy.iko.opencode.ui.vmFactory
85import soy.iko.opencode.ui.vmFactory
82
86
@@ -105,6 +109,10 @@ fun ServerListScreen(
105 val undoLabel = stringResource(R.string.undo)
109 val undoLabel = stringResource(R.string.undo)
106 val serverRemovedLabel = stringResource(R.string.server_removed)
110 val serverRemovedLabel = stringResource(R.string.server_removed)
107 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
111 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("") }
108 val connectedId = activeConnection?.profile?.id
116 val connectedId = activeConnection?.profile?.id
109 // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
117 // Profile being shown as a scannable QR (Share as QR overflow action), and a decoded QR
110 // payload awaiting the user's confirm before it's saved (Import from image action).
118 // payload awaiting the user's confirm before it's saved (Import from image action).
@@ -224,6 +232,7 @@ fun ServerListScreen(
224 modifier = Modifier.fillMaxSize(),
232 modifier = Modifier.fillMaxSize(),
225 )
233 )
226 } else {
234 } else {
235+ val filtered = remember(profiles, serverQuery) { filterServerProfiles(profiles, serverQuery) }
227 PullToRefreshBox(
236 PullToRefreshBox(
228 isRefreshing = connectingId != null,
237 isRefreshing = connectingId != null,
229 onRefresh = { vm.refresh(onConnected) },
238 onRefresh = { vm.refresh(onConnected) },
@@ -237,7 +246,26 @@ fun ServerListScreen(
237 ),
246 ),
238 verticalArrangement = Arrangement.spacedBy(12.dp),
247 verticalArrangement = Arrangement.spacedBy(12.dp),
239 ) {
248 ) {
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+ }
240− items(profiles, key = { it.id }) { profile ->
268+ items(filtered, key = { it.id }) { profile ->
241 val isActive = profile.id == connectedId
269 val isActive = profile.id == connectedId
242 if (isActive) {
270 if (isActive) {
243 // The active server can't be swipe-deleted (deleting it also
271 // The active server can't be swipe-deleted (deleting it also
@@ -509,30 +537,14 @@ private fun LastUsedText(lastUsed: Long) {
509@Composable
537@Composable
510private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
538private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
511 Box(modifier = modifier, contentAlignment = Alignment.Center) {
539 Box(modifier = modifier, contentAlignment = Alignment.Center) {
512− Column(
540+ EmptyState(
513− modifier = Modifier.padding(32.dp),
514− horizontalAlignment = Alignment.CenterHorizontally,
515− ) {
516− Icon(
517− Icons.Filled.Dns,
541+ icon = 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)
542+ title = stringResource(R.string.no_servers_yet),
524− Spacer(Modifier.size(8.dp))
525− Text(
526− stringResource(R.string.no_servers_hint),
543+ description = stringResource(R.string.no_servers_hint),
527− style = MaterialTheme.typography.bodyMedium,
544+ actionIcon = Icons.Filled.Add,
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))
545+ actionLabel = stringResource(R.string.add_server),
534− }
546+ onAction = onAdd,
535− }
547+ )
536 }
548 }
537}
549}
538
550
@@ -598,3 +610,34 @@ private fun ServerQrImportHandler(
598 }
610 }
599 return launcher
611 return launcher
600}
612}
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
39import androidx.compose.ui.unit.dp
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.res.stringResource
41import soy.iko.opencode.R
41import soy.iko.opencode.R
42+import soy.iko.opencode.data.network.NetworkConfig
42
43
43/** The user's directory choice in the new-session picker. */
44/** The user's directory choice in the new-session picker. */
44private sealed interface DirChoice {
45private sealed interface DirChoice {
@@ -109,6 +110,7 @@ fun NewSessionDialog(
109 // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
110 // Optional title so a new session doesn't land in the list as "Untitled session" requiring a
110 // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
111 // rename. Trimmed to a sane max; an all-whitespace value is treated as no title.
111 var titleText by rememberSaveable { mutableStateOf("") }
112 var titleText by rememberSaveable { mutableStateOf("") }
113+ val titleLimit = NetworkConfig.maxSessionTitleChars
112
114
113 // The initializer above may have run while options were still loading (serverDefault
115 // The initializer above may have run while options were still loading (serverDefault
114 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
116 // null), pinning choice to DirChoice.Known(preselect). Once the real serverDefault
@@ -143,13 +145,14 @@ fun NewSessionDialog(
143 ) {
145 ) {
144 OutlinedTextField(
146 OutlinedTextField(
145 value = titleText,
147 value = titleText,
146− onValueChange = { titleText = it.take(120) },
148+ onValueChange = { titleText = it.take(titleLimit) },
147 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
149 modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
148 label = { Text(stringResource(R.string.session_title_hint)) },
150 label = { Text(stringResource(R.string.session_title_hint)) },
149 placeholder = { Text(stringResource(R.string.new_session)) },
151 placeholder = { Text(stringResource(R.string.new_session)) },
150 singleLine = true,
152 singleLine = true,
151 enabled = !creating,
153 enabled = !creating,
152 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
154 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
155+ supportingText = { TitleCharCounter(titleText.length, titleLimit) },
153 )
156 )
154 Text(
157 Text(
155 stringResource(R.string.directory_label),
158 stringResource(R.string.directory_label),
@@ -234,6 +237,16 @@ fun NewSessionDialog(
234 )
237 )
235}
238}
236
239
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+
237@Composable
250@Composable
238private fun DirectoryRow(
251private fun DirectoryRow(
239 label: String,
252 label: String,
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+69−1
@@ -21,7 +21,9 @@ import androidx.compose.foundation.rememberScrollState
21import androidx.compose.foundation.verticalScroll
21import androidx.compose.foundation.verticalScroll
22import androidx.compose.material.icons.Icons
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.filled.BugReport
23import androidx.compose.material.icons.filled.BugReport
24+import androidx.compose.material.icons.filled.Close
24import androidx.compose.material.icons.filled.DeleteSweep
25import androidx.compose.material.icons.filled.DeleteSweep
26+import androidx.compose.material.icons.filled.Search
25import androidx.compose.material.icons.filled.Share
27import androidx.compose.material.icons.filled.Share
26import androidx.compose.material3.AlertDialog
28import androidx.compose.material3.AlertDialog
27import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.CircularProgressIndicator
@@ -30,6 +32,7 @@ import androidx.compose.material3.HorizontalDivider
30import androidx.compose.material3.Icon
32import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
33import androidx.compose.material3.IconButton
32import androidx.compose.material3.MaterialTheme
34import androidx.compose.material3.MaterialTheme
35+import androidx.compose.material3.OutlinedTextField
33import androidx.compose.material3.Scaffold
36import androidx.compose.material3.Scaffold
34import androidx.compose.material3.SnackbarDuration
37import androidx.compose.material3.SnackbarDuration
35import androidx.compose.material3.SnackbarHost
38import androidx.compose.material3.SnackbarHost
@@ -91,6 +94,9 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
91 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
94 var viewing by rememberSaveable { mutableStateOf<String?>(null) }
92 var showClearAll by rememberSaveable { mutableStateOf(false) }
95 var showClearAll by rememberSaveable { mutableStateOf(false) }
93 var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
96 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("") }
94 val shareScope = rememberCoroutineScope()
100 val shareScope = rememberCoroutineScope()
95 val haptics = LocalHapticFeedback.current
101 val haptics = LocalHapticFeedback.current
96 val snackbar = remember { SnackbarHostState() }
102 val snackbar = remember { SnackbarHostState() }
@@ -207,8 +213,12 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
207 modifier = Modifier.fillMaxWidth().padding(24.dp),
213 modifier = Modifier.fillMaxWidth().padding(24.dp),
208 )
214 )
209 } else {
215 } else {
216+ val filtered = remember(reports, crashQuery) { filterCrashReports(reports, crashQuery) }
210 LazyColumn(modifier = Modifier.fillMaxSize()) {
217 LazyColumn(modifier = Modifier.fillMaxSize()) {
218+ crashSearchHeader(reports = reports, filtered = filtered, crashQuery = crashQuery) {
219+ crashQuery = it
220+ }
211− items(reports, key = { it.fileName }) { report ->
221+ items(filtered, key = { it.fileName }) { report ->
212 // Swipe end-to-start reveals a delete affordance. The delete is
222 // Swipe end-to-start reveals a delete affordance. The delete is
213 // deferred for an undo window (matching the session/server lists);
223 // deferred for an undo window (matching the session/server lists);
214 // confirmValueChange snaps back so the row remains while the undo
224 // confirmValueChange snaps back so the row remains while the undo
@@ -428,3 +438,61 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
428 )
438 )
429 }
439 }
430}
440}
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
40import androidx.compose.material3.MaterialTheme
40import androidx.compose.material3.MaterialTheme
41import androidx.compose.material3.RadioButton
41import androidx.compose.material3.RadioButton
42import androidx.compose.material3.Scaffold
42import androidx.compose.material3.Scaffold
43+import androidx.compose.material3.SnackbarHost
44+import androidx.compose.material3.SnackbarHostState
43import androidx.compose.material3.Slider
45import androidx.compose.material3.Slider
44import androidx.compose.material3.Switch
46import androidx.compose.material3.Switch
45import androidx.compose.material3.Text
47import androidx.compose.material3.Text
@@ -58,6 +60,7 @@ import androidx.compose.ui.Modifier
58import androidx.compose.ui.draw.clip
60import androidx.compose.ui.draw.clip
59import androidx.compose.ui.graphics.Color
61import androidx.compose.ui.graphics.Color
60import androidx.compose.ui.platform.LocalContext
62import androidx.compose.ui.platform.LocalContext
63+import androidx.compose.ui.platform.LocalDensity
61import androidx.compose.ui.res.pluralStringResource
64import androidx.compose.ui.res.pluralStringResource
62import androidx.compose.ui.res.stringResource
65import androidx.compose.ui.res.stringResource
63import androidx.compose.ui.semantics.Role
66import androidx.compose.ui.semantics.Role
@@ -80,8 +83,8 @@ import soy.iko.opencode.data.repo.SettingsStore
80import soy.iko.opencode.data.repo.ThemeMode
83import soy.iko.opencode.data.repo.ThemeMode
81import soy.iko.opencode.di.AppContainer
84import soy.iko.opencode.di.AppContainer
82import soy.iko.opencode.R
85import soy.iko.opencode.R
86+import soy.iko.opencode.data.network.NetworkConfig
83import soy.iko.opencode.ui.canAuthenticateForAppLock
87import soy.iko.opencode.ui.canAuthenticateForAppLock
84−import soy.iko.opencode.ui.components.showToast
85import soy.iko.opencode.ui.theme.LightPaletteSwatches
88import soy.iko.opencode.ui.theme.LightPaletteSwatches
86import soy.iko.opencode.ui.theme.DarkPaletteSwatches
89import soy.iko.opencode.ui.theme.DarkPaletteSwatches
87import soy.iko.opencode.util.runCatchingCancellable
90import soy.iko.opencode.util.runCatchingCancellable
@@ -98,6 +101,7 @@ fun SettingsScreen(
98 onOpenMcp: () -> Unit = {},
101 onOpenMcp: () -> Unit = {},
99) {
102) {
100 val scope = rememberCoroutineScope()
103 val scope = rememberCoroutineScope()
104+ val snackbar = remember { SnackbarHostState() }
101 // Combine the three settings into a single nullable state so the appearance section
105 // Combine the three settings into a single nullable state so the appearance section
102 // renders only after the persisted values have loaded, avoiding a brief flash of
106 // renders only after the persisted values have loaded, avoiding a brief flash of
103 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
107 // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
@@ -166,7 +170,8 @@ fun SettingsScreen(
166 }
170 }
167
171
168 // Backup/restore via the Storage Access Framework. Export writes the JSON the user names;
172 // 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).
170 var includePasswords by rememberSaveable { mutableStateOf(false) }
175 var includePasswords by rememberSaveable { mutableStateOf(false) }
171 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
176 // Import overwrites all profiles/settings with no undo, so a picked file is staged here
172 // and only applied once the user confirms the replace dialog below. Export with passwords
177 // and only applied once the user confirms the replace dialog below. Export with passwords
@@ -187,7 +192,7 @@ fun SettingsScreen(
187 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
192 context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
188 ?: error("no output stream")
193 ?: error("no output stream")
189 }.isSuccess
194 }.isSuccess
190− showToast(context, if (ok) exportedMsg else exportFailedMsg)
195+ snackbar.showSnackbar(if (ok) exportedMsg else exportFailedMsg)
191 }
196 }
192 }
197 }
193 val importLauncher = rememberLauncherForActivityResult(
198 val importLauncher = rememberLauncherForActivityResult(
@@ -216,6 +221,7 @@ fun SettingsScreen(
216 },
221 },
217 )
222 )
218 },
223 },
224+ snackbarHost = { SnackbarHost(snackbar) },
219 ) { padding ->
225 ) { padding ->
220 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
226 Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
221 val s = settings
227 val s = settings
@@ -576,7 +582,7 @@ fun SettingsScreen(
576 ?: error("no input stream")
582 ?: error("no input stream")
577 container.backupManager.import(text)
583 container.backupManager.import(text)
578 }.isSuccess
584 }.isSuccess
579− showToast(context, if (ok) importedMsg else importFailedMsg)
585+ snackbar.showSnackbar(if (ok) importedMsg else importFailedMsg)
580 }
586 }
581 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
587 }) { Text(stringResource(R.string.import_confirm_replace), color = MaterialTheme.colorScheme.error) }
582 },
588 },
@@ -680,6 +686,22 @@ private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
680 },
686 },
681 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
687 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
682 ) { Text(stringResource(R.string.reset)) }
688 ) { 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+ }
683}
705}
684
706
685@Composable
707@Composable
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+58−3
@@ -16,21 +16,31 @@ import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.lazy.LazyColumn
16import androidx.compose.foundation.lazy.LazyColumn
17import androidx.compose.foundation.lazy.items
17import androidx.compose.foundation.lazy.items
18import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.filled.Close
19import androidx.compose.material.icons.filled.CloudOff
20import androidx.compose.material.icons.filled.CloudOff
20import androidx.compose.material.icons.filled.ErrorOutline
21import androidx.compose.material.icons.filled.ErrorOutline
21import androidx.compose.material.icons.filled.QueryStats
22import androidx.compose.material.icons.filled.QueryStats
22import androidx.compose.material.icons.filled.Refresh
23import androidx.compose.material.icons.filled.Refresh
24+import androidx.compose.material.icons.filled.Search
25+import androidx.compose.material.icons.filled.SearchOff
23import androidx.compose.material3.Card
26import androidx.compose.material3.Card
24import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.CircularProgressIndicator
25import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.ExperimentalMaterial3Api
26import androidx.compose.material3.Icon
29import androidx.compose.material3.Icon
27import androidx.compose.material3.IconButton
30import androidx.compose.material3.IconButton
28import androidx.compose.material3.MaterialTheme
31import androidx.compose.material3.MaterialTheme
32+import androidx.compose.material3.OutlinedTextField
29import androidx.compose.material3.Scaffold
33import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarHost
35+import androidx.compose.material3.SnackbarHostState
30import androidx.compose.material3.Text
36import androidx.compose.material3.Text
31import androidx.compose.material3.pulltorefresh.PullToRefreshBox
37import androidx.compose.material3.pulltorefresh.PullToRefreshBox
32import androidx.compose.runtime.Composable
38import androidx.compose.runtime.Composable
33import androidx.compose.runtime.getValue
39import 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
34import androidx.compose.ui.Alignment
44import androidx.compose.ui.Alignment
35import androidx.compose.ui.Modifier
45import androidx.compose.ui.Modifier
36import androidx.compose.ui.draw.clip
46import androidx.compose.ui.draw.clip
@@ -38,6 +48,7 @@ import androidx.compose.ui.res.pluralStringResource
38import androidx.compose.ui.res.stringResource
48import androidx.compose.ui.res.stringResource
39import androidx.compose.ui.semantics.Role
49import androidx.compose.ui.semantics.Role
40import androidx.compose.ui.semantics.contentDescription
50import androidx.compose.ui.semantics.contentDescription
51+import androidx.compose.ui.semantics.heading
41import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
42import androidx.compose.ui.text.style.TextOverflow
53import androidx.compose.ui.text.style.TextOverflow
43import androidx.compose.ui.unit.dp
54import androidx.compose.ui.unit.dp
@@ -45,8 +56,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
45import androidx.lifecycle.viewmodel.compose.viewModel
56import androidx.lifecycle.viewmodel.compose.viewModel
46import soy.iko.opencode.R
57import soy.iko.opencode.R
47import soy.iko.opencode.data.model.Tokens
58import soy.iko.opencode.data.model.Tokens
59+import soy.iko.opencode.data.network.NetworkConfig
48import soy.iko.opencode.di.AppContainer
60import soy.iko.opencode.di.AppContainer
49import soy.iko.opencode.ui.components.AppTopBar
61import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.ConnectionBannerFor
50import soy.iko.opencode.ui.components.EmptyState
63import soy.iko.opencode.ui.components.EmptyState
51import soy.iko.opencode.ui.components.SectionHeader
64import soy.iko.opencode.ui.components.SectionHeader
52import soy.iko.opencode.ui.vmFactory
65import soy.iko.opencode.ui.vmFactory
@@ -59,6 +72,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
59 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
72 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
60 val state by vm.state.collectAsStateWithLifecycle()
73 val state by vm.state.collectAsStateWithLifecycle()
61 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
74 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
75+ val snackbar = remember { SnackbarHostState() }
62
76
63 Scaffold(
77 Scaffold(
64 topBar = {
78 topBar = {
@@ -72,6 +86,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
72 },
86 },
73 )
87 )
74 },
88 },
89+ snackbarHost = { SnackbarHost(snackbar) },
75 ) { padding ->
90 ) { padding ->
76 PullToRefreshBox(
91 PullToRefreshBox(
77 isRefreshing = refreshing,
92 isRefreshing = refreshing,
@@ -79,6 +94,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (Str
79 modifier = Modifier.fillMaxSize().padding(padding),
94 modifier = Modifier.fillMaxSize().padding(padding),
80 ) {
95 ) {
81 Box(modifier = Modifier.fillMaxSize()) {
96 Box(modifier = Modifier.fillMaxSize()) {
97+ ConnectionBannerFor(container)
82 val loadingLabel = stringResource(R.string.usage_loading)
98 val loadingLabel = stringResource(R.string.usage_loading)
83 when (val s = state) {
99 when (val s = state) {
84 is UsageViewModel.State.Loading -> Centered {
100 is UsageViewModel.State.Loading -> Centered {
@@ -125,6 +141,15 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
125
141
126@Composable
142@Composable
127private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
143private 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+ }
128 LazyColumn(
153 LazyColumn(
129 modifier = Modifier.fillMaxSize(),
154 modifier = Modifier.fillMaxSize(),
130 contentPadding = PaddingValues(16.dp),
155 contentPadding = PaddingValues(16.dp),
@@ -133,7 +158,7 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
133 item { TotalsCard(report) }
158 item { TotalsCard(report) }
134 item { TokenBreakdownCard(report.totalTokens) }
159 item { TokenBreakdownCard(report.totalTokens) }
135 if (report.byModel.isNotEmpty()) {
160 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() }) }
137 // Key on provider+model: the same model id served by two providers yields two
162 // Key on provider+model: the same model id served by two providers yields two
138 // rows, so keying on the bare model would duplicate keys and crash the list.
163 // rows, so keying on the bare model would duplicate keys and crash the list.
139 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
164 items(report.byModel, key = { it.provider + "/" + it.model }) { m ->
@@ -147,8 +172,38 @@ private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
147 }
172 }
148 }
173 }
149 if (report.bySession.isNotEmpty()) {
174 if (report.bySession.isNotEmpty()) {
150− item { SectionHeader(stringResource(R.string.usage_all_sessions)) }
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+ }
151− items(report.bySession, key = { it.sessionId }) { s ->
206+ items(filteredSessions, key = { it.sessionId }) { s ->
152 UsageRow(
207 UsageRow(
153 title = s.title,
208 title = s.title,
154 cost = s.cost,
209 cost = s.cost,
app/src/main/res/values-es/strings.xml+23
@@ -46,11 +46,14 @@
46
46
47 <!-- Server list -->
47 <!-- Server list -->
48 <string name="servers_title">Servidores de opencode</string>
48 <string name="servers_title">Servidores de opencode</string>
49+ <string name="sessions_title">Sesiones</string>
49 <string name="add_server">Añadir servidor</string>
50 <string name="add_server">Añadir servidor</string>
50 <string name="edit_server">Editar servidor</string>
51 <string name="edit_server">Editar servidor</string>
51 <string name="connected">Conectado</string>
52 <string name="connected">Conectado</string>
52 <string name="no_servers_yet">Aún no hay servidores</string>
53 <string name="no_servers_yet">Aún no hay servidores</string>
53 <string name="no_servers_hint">Añade un endpoint de opencode serve para conectar.</string>
54 <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>
54 <string name="remove_server_title">¿Quitar servidor?</string>
57 <string name="remove_server_title">¿Quitar servidor?</string>
55 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
58 <string name="remove_server_text">Se quitará «%1$s» de tus servidores guardados.</string>
56 <string name="remove_server_active_text">«%1$s» es tu servidor actual. Al quitarlo también te desconectarás.</string>
59 <string name="remove_server_active_text">«%1$s» es tu servidor actual. Al quitarlo también te desconectarás.</string>
@@ -228,6 +231,8 @@
228 <string name="diagnostics_desc">Los informes de fallos se guardan localmente en este dispositivo.</string>
231 <string name="diagnostics_desc">Los informes de fallos se guardan localmente en este dispositivo.</string>
229 <string name="crash_reports">Informes de fallos</string>
232 <string name="crash_reports">Informes de fallos</string>
230 <string name="no_crash_reports">No hay fallos registrados.</string>
233 <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>
231 <string name="clear_all">Borrar todo</string>
236 <string name="clear_all">Borrar todo</string>
232 <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados?</string>
237 <string name="clear_all_confirm">¿Eliminar todos los informes de fallos guardados?</string>
233 <string name="reports_cleared">Informes de fallos eliminados</string>
238 <string name="reports_cleared">Informes de fallos eliminados</string>
@@ -363,6 +368,7 @@
363 <string name="app_lock_prompt_subtitle">Autentícate para acceder a tus servidores.</string>
368 <string name="app_lock_prompt_subtitle">Autentícate para acceder a tus servidores.</string>
364 <string name="app_locked">opencode está bloqueado</string>
369 <string name="app_locked">opencode está bloqueado</string>
365 <string name="app_unlock">Desbloquear</string>
370 <string name="app_unlock">Desbloquear</string>
371+ <string name="app_locked_hint">Autentícate para continuar</string>
366
372
367 <!-- Nuevas funciones -->
373 <!-- Nuevas funciones -->
368 <string name="edit_message">Editar</string>
374 <string name="edit_message">Editar</string>
@@ -409,6 +415,7 @@
409 <string name="reset">Restablecer</string>
415 <string name="reset">Restablecer</string>
410 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
416 <string name="text_size_sample">El veloz murciélago hindú comía feliz cardillo.</string>
411 <string name="text_scale_value">%1$d%%</string>
417 <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>
412
419
413 <!-- Message actions -->
420 <!-- Message actions -->
414 <string name="message_actions">Acciones del mensaje</string>
421 <string name="message_actions">Acciones del mensaje</string>
@@ -438,6 +445,8 @@
438 <string name="usage_by_model">Por modelo</string>
445 <string name="usage_by_model">Por modelo</string>
439 <string name="usage_this_session">Esta sesión</string>
446 <string name="usage_this_session">Esta sesión</string>
440 <string name="usage_all_sessions">Todas las sesiones</string>
447 <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>
441 <string name="usage_empty">Aún no hay uso registrado</string>
450 <string name="usage_empty">Aún no hay uso registrado</string>
442 <plurals name="usage_messages_count">
451 <plurals name="usage_messages_count">
443 <item quantity="one">%1$d mensaje del asistente</item>
452 <item quantity="one">%1$d mensaje del asistente</item>
@@ -476,6 +485,20 @@
476 <string name="mcp_empty">No hay servidores MCP configurados</string>
485 <string name="mcp_empty">No hay servidores MCP configurados</string>
477 <string name="mcp_failed">No se pudieron cargar los servidores MCP</string>
486 <string name="mcp_failed">No se pudieron cargar los servidores MCP</string>
478 <string name="mcp_connected">Conectado</string>
487 <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>
479 <string name="mcp_failed_state">Falló</string>
502 <string name="mcp_failed_state">Falló</string>
480 <string name="mcp_disabled">Desactivado</string>
503 <string name="mcp_disabled">Desactivado</string>
481 <string name="mcp_tools_count">%1$d herramientas</string>
504 <string name="mcp_tools_count">%1$d herramientas</string>
app/src/main/res/values/strings.xml+23
@@ -47,11 +47,14 @@
47
47
48 <!-- Server list -->
48 <!-- Server list -->
49 <string name="servers_title">opencode servers</string>
49 <string name="servers_title">opencode servers</string>
50+ <string name="sessions_title">Sessions</string>
50 <string name="add_server">Add server</string>
51 <string name="add_server">Add server</string>
51 <string name="edit_server">Edit server</string>
52 <string name="edit_server">Edit server</string>
52 <string name="connected">Connected</string>
53 <string name="connected">Connected</string>
53 <string name="no_servers_yet">No servers yet</string>
54 <string name="no_servers_yet">No servers yet</string>
54 <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
55 <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>
55 <string name="remove_server_title">Remove server?</string>
58 <string name="remove_server_title">Remove server?</string>
56 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
59 <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
57 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
60 <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
@@ -237,6 +240,8 @@
237 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
240 <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
238 <string name="crash_reports">Crash reports</string>
241 <string name="crash_reports">Crash reports</string>
239 <string name="no_crash_reports">No crashes recorded.</string>
242 <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>
240 <string name="clear_all">Clear all</string>
245 <string name="clear_all">Clear all</string>
241 <string name="clear_all_confirm">Delete all stored crash reports?</string>
246 <string name="clear_all_confirm">Delete all stored crash reports?</string>
242 <string name="reports_cleared">Crash reports cleared</string>
247 <string name="reports_cleared">Crash reports cleared</string>
@@ -408,6 +413,7 @@
408 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
413 <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
409 <string name="app_locked">opencode is locked</string>
414 <string name="app_locked">opencode is locked</string>
410 <string name="app_unlock">Unlock</string>
415 <string name="app_unlock">Unlock</string>
416+ <string name="app_locked_hint">Authenticate to continue</string>
411
417
412 <!-- Agent task plan (todowrite tool) -->
418 <!-- Agent task plan (todowrite tool) -->
413 <string name="plan">Plan</string>
419 <string name="plan">Plan</string>
@@ -431,6 +437,7 @@
431 <string name="reset">Reset</string>
437 <string name="reset">Reset</string>
432 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
438 <string name="text_size_sample">The quick brown fox jumps over the lazy dog.</string>
433 <string name="text_scale_value">%1$d%%</string>
439 <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>
434
441
435 <!-- Message actions -->
442 <!-- Message actions -->
436 <string name="message_actions">Message actions</string>
443 <string name="message_actions">Message actions</string>
@@ -460,6 +467,8 @@
460 <string name="usage_by_model">By model</string>
467 <string name="usage_by_model">By model</string>
461 <string name="usage_this_session">This session</string>
468 <string name="usage_this_session">This session</string>
462 <string name="usage_all_sessions">All sessions</string>
469 <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>
463 <string name="usage_empty">No usage recorded yet</string>
472 <string name="usage_empty">No usage recorded yet</string>
464 <plurals name="usage_messages_count">
473 <plurals name="usage_messages_count">
465 <item quantity="one">%1$d assistant message</item>
474 <item quantity="one">%1$d assistant message</item>
@@ -498,6 +507,20 @@
498 <string name="mcp_empty">No MCP servers configured</string>
507 <string name="mcp_empty">No MCP servers configured</string>
499 <string name="mcp_failed">Couldn\'t load MCP servers</string>
508 <string name="mcp_failed">Couldn\'t load MCP servers</string>
500 <string name="mcp_connected">Connected</string>
509 <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>
501 <string name="mcp_failed_state">Failed</string>
524 <string name="mcp_failed_state">Failed</string>
502 <string name="mcp_disabled">Disabled</string>
525 <string name="mcp_disabled">Disabled</string>
503 <string name="mcp_tools_count">%1$d tools</string>
526 <string name="mcp_tools_count">%1$d tools</string>