git.iko.soy/opencode-android
on master help sign in
Fix 35 UI/UX issues: permissions, file viewer, chat semantics, touch targets, search caching, accessibility
commit066cded171d8dbd9c22077d45d477520928677ad authoriko <mail@iko.soy> dateFri 3 Jul 2026, 09:27 (3 months ago) parent59c889d
32 files changed+891−220
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+17−2 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−1 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+8−2 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+73−22 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+8−2 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+9−4 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+8−2 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+25−9 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−10 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+20−1 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+10−5 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+4−5 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+37−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+87−22 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+162−64 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+17−8 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+12−2 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+30−7 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+22−3 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+8−2 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+9 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−8 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+39−11 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+11−1 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−10 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+22−7 app/src/main/res/values-es/strings.xml+40−3 app/src/main/res/values/strings.xml+40−3
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+4
@@ -200,6 +200,10 @@ object NetworkConfig {
200200 const val twoPaneWidthThresholdDp = 840
201201 /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
202202 const val userBubbleWidthFraction = 0.85f
203+ /** Minimum number of options in a picker sheet (model/agent/command) before its search
204+ * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
205+ * eye, so popping the keyboard for it is just friction. */
206+ const val pickerSearchAutofocusThreshold = 8
203207 /** Extra bottom content padding (dp) for the chat message list, reserving room for the
204208 * jump-to-latest FAB so it never floats over the last message. */
205209 const val chatListFabInsetDp = 80
app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+17−2
@@ -7,12 +7,14 @@ import androidx.biometric.BiometricManager
77import androidx.biometric.BiometricPrompt
88import androidx.compose.foundation.layout.Arrangement
99import androidx.compose.foundation.layout.Column
10+import androidx.compose.foundation.layout.Spacer
1011import androidx.compose.foundation.layout.fillMaxSize
1112import androidx.compose.foundation.layout.padding
1213import androidx.compose.foundation.layout.size
1314import androidx.compose.material.icons.Icons
1415import androidx.compose.material.icons.filled.Lock
1516import androidx.compose.material3.Button
17+import androidx.compose.material3.CircularProgressIndicator
1618import androidx.compose.material3.Icon
1719import androidx.compose.material3.MaterialTheme
1820import androidx.compose.material3.Surface
@@ -28,6 +30,9 @@ import androidx.compose.ui.Alignment
2830import androidx.compose.ui.Modifier
2931import androidx.compose.ui.platform.LocalContext
3032import androidx.compose.ui.res.stringResource
33+import androidx.compose.ui.semantics.LiveRegionMode
34+import androidx.compose.ui.semantics.liveRegion
35+import androidx.compose.ui.semantics.semantics
3136import androidx.compose.ui.unit.dp
3237import androidx.core.content.ContextCompat
3338import androidx.fragment.app.FragmentActivity
@@ -206,20 +211,30 @@ fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
206211 ) {
207212 Icon(
208213 Icons.Filled.Lock,
209− contentDescription = null,
214+ contentDescription = stringResource(R.string.app_locked),
210215 modifier = Modifier.size(48.dp),
211216 tint = MaterialTheme.colorScheme.onSurfaceVariant,
212217 )
213218 Text(
214219 stringResource(R.string.app_locked),
215220 style = MaterialTheme.typography.titleMedium,
216− modifier = Modifier.padding(top = 16.dp),
221+ modifier = Modifier
222+ .padding(top = 16.dp)
223+ .semantics { liveRegion = LiveRegionMode.Polite },
217224 )
218225 Button(
219226 onClick = { authenticate() },
220227 enabled = !authInFlight,
221228 modifier = Modifier.padding(top = 24.dp),
222229 ) {
230+ if (authInFlight) {
231+ CircularProgressIndicator(
232+ modifier = Modifier.size(18.dp),
233+ strokeWidth = 2.dp,
234+ color = MaterialTheme.colorScheme.primary,
235+ )
236+ Spacer(Modifier.size(8.dp))
237+ }
223238 Text(stringResource(R.string.app_unlock))
224239 }
225240 }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+5−1
@@ -332,7 +332,11 @@ fun OpencodeApp(container: AppContainer) {
332332 }
333333
334334 composable(Routes.USAGE) {
335− UsageScreen(container = container, onBack = { navController.popBackStack() })
335+ UsageScreen(
336+ container = container,
337+ onBack = { navController.popBackStack() },
338+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
339+ )
336340 }
337341
338342 composable(Routes.MCP) {
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+8−2
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction
4949import androidx.compose.ui.unit.dp
5050import androidx.compose.foundation.layout.Box
5151import soy.iko.opencode.data.model.Agent
52+import soy.iko.opencode.data.network.NetworkConfig
5253import soy.iko.opencode.R
5354
5455/**
@@ -122,9 +123,14 @@ fun AgentPickerSheet(
122123 var query by rememberSaveable { mutableStateOf("") }
123124 val keyboardController = LocalSoftwareKeyboardController.current
124125 val haptics = LocalHapticFeedback.current
125− // Auto-focus the search field (and raise the keyboard) when the sheet opens.
126+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
127+ // it for a short catalog that's faster to scan by eye than to filter.
126128 val searchFocus = remember { FocusRequester() }
127− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
129+ LaunchedEffect(Unit) {
130+ if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
131+ runCatching { searchFocus.requestFocus() }
132+ }
133+ }
128134 OutlinedTextField(
129135 value = query,
130136 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+73−22
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.filled.PhotoCamera
4444import androidx.compose.material.icons.filled.PhotoLibrary
4545import androidx.compose.material.icons.filled.Refresh
4646import androidx.compose.material.icons.filled.Restore
47+import androidx.compose.material.icons.filled.Schedule
4748import androidx.compose.material.icons.filled.Stop
4849import androidx.compose.material3.AlertDialog
4950import androidx.compose.material3.Badge
@@ -154,6 +155,7 @@ fun ChatScreen(
154155 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
155156 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
156157 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
158+ val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
157159 val agents by vm.agents.collectAsStateWithLifecycle()
158160 val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
159161 val agentsError by vm.agentsError.collectAsStateWithLifecycle()
@@ -226,6 +228,10 @@ fun ChatScreen(
226228 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
227229 var showShellDialog by rememberSaveable { mutableStateOf(false) }
228230 var showPalette by rememberSaveable { mutableStateOf(false) }
231+ // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
232+ // a confirmation dialog (from both the overflow menu and the command palette).
233+ var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
234+ var showInitConfirm by rememberSaveable { mutableStateOf(false) }
229235
230236 // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
231237 // action list doesn't need a Composable context. Actions map to the same handlers the
@@ -244,7 +250,7 @@ fun ChatScreen(
244250 add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
245251 add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
246252 add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
247− if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
253+ if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
248254 if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
249255 add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
250256 }
@@ -371,7 +377,7 @@ fun ChatScreen(
371377 // A freshly-created share link is copied to the clipboard so the user can paste it right away.
372378 LaunchedEffect(Unit) {
373379 vm.shareLinkEvents.collect { url ->
374− copyToClipboard(shareContext, "share", url)
380+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
375381 snackbar.showSnackbar(linkCopiedMsg)
376382 }
377383 }
@@ -436,9 +442,17 @@ fun ChatScreen(
436442 // the overflow menu despite the title implying otherwise.
437443 Box {
438444 Row(
445+ // mergeDescendants so TalkBack reads the title/subtitle Texts as one
446+ // node; the click label rides on `clickable` (onClickLabel) instead of
447+ // an explicit contentDescription, which would have SUPPRESSED the child
448+ // Texts and announced only "Change model and agent".
439449 modifier = Modifier
440− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showTitleMenu = true }
441− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
450+ .clickable(
451+ enabled = models.isNotEmpty(),
452+ role = Role.Button,
453+ onClickLabel = changeLabel,
454+ ) { showTitleMenu = true }
455+ .semantics(mergeDescendants = true) {},
442456 verticalAlignment = Alignment.CenterVertically,
443457 ) {
444458 Column(modifier = Modifier.weight(1f, fill = false)) {
@@ -532,8 +546,6 @@ fun ChatScreen(
532546 val loadingLabel = stringResource(R.string.loading)
533547 val shareLabel = stringResource(R.string.share_conversation)
534548 val commandsLabel = stringResource(R.string.commands)
535− val agentLabel = stringResource(R.string.choose_agent)
536− val modelLabel = stringResource(R.string.choose_model)
537549 val renameLabel = stringResource(R.string.rename_session_chat)
538550 val deleteLabel = stringResource(R.string.delete_session_chat)
539551 val createShareLabel = stringResource(R.string.create_share_link)
@@ -574,16 +586,6 @@ fun ChatScreen(
574586 enabled = !commandsLoading || commands.isNotEmpty(),
575587 onClick = { showOverflowMenu = false; showCommandPicker = true },
576588 )
577− DropdownMenuItem(
578− text = { Text(agentLabel) },
579− enabled = !agentsLoading || agents.isNotEmpty(),
580− onClick = { showOverflowMenu = false; showAgentPicker = true },
581− )
582− DropdownMenuItem(
583− text = { Text(modelLabel) },
584− enabled = !modelsLoading || models.isNotEmpty(),
585− onClick = { showOverflowMenu = false; showModelPicker = true },
586− )
587589 androidx.compose.material3.HorizontalDivider()
588590 // Session sharing: create a public link, copy an existing one, or revoke it.
589591 if (shareUrl == null) {
@@ -598,7 +600,7 @@ fun ChatScreen(
598600 onClick = {
599601 showOverflowMenu = false
600602 shareUrl?.let {
601− copyToClipboard(shareContext, "share", it)
603+ copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
602604 scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
603605 }
604606 },
@@ -611,12 +613,12 @@ fun ChatScreen(
611613 DropdownMenuItem(
612614 text = { Text(summarizeLabel) },
613615 enabled = hasMessages && !running,
614− onClick = { showOverflowMenu = false; vm.summarize() },
616+ onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
615617 )
616618 DropdownMenuItem(
617619 text = { Text(initLabel) },
618620 enabled = !running,
619− onClick = { showOverflowMenu = false; vm.initProject() },
621+ onClick = { showOverflowMenu = false; showInitConfirm = true },
620622 )
621623 DropdownMenuItem(
622624 text = { Text(shellLabel) },
@@ -936,6 +938,10 @@ fun ChatScreen(
936938 key = { it.key },
937939 contentType = { it.contentType },
938940 ) { item ->
941+ // Default placement animation so inserted/moved rows glide in. Kept to the
942+ // default (no heavy fade spec) so a streaming message growing in place
943+ // doesn't animate on every token.
944+ Box(Modifier.animateItem()) {
939945 when (item) {
940946 is MessageListItem.Separator -> DateSeparator(item.label)
941947 is MessageListItem.Message -> {
@@ -978,6 +984,7 @@ fun ChatScreen(
978984 )
979985 }
980986 }
987+ }
981988 }
982989 if (running) {
983990 item(key = "__typing") {
@@ -1021,7 +1028,9 @@ fun ChatScreen(
10211028 contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
10221029 }
10231030 },
1024− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
1031+ // contentDescription null: the visible "Latest" text already labels
1032+ // the FAB, so a description here makes TalkBack announce "Latest" twice.
1033+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
10251034 text = { Text(stringResource(R.string.latest)) },
10261035 )
10271036 }
@@ -1069,6 +1078,40 @@ fun ChatScreen(
10691078 )
10701079 }
10711080
1081+ if (showSummarizeConfirm) {
1082+ AlertDialog(
1083+ onDismissRequest = { showSummarizeConfirm = false },
1084+ title = { Text(stringResource(R.string.summarize_confirm_title)) },
1085+ text = { Text(stringResource(R.string.summarize_confirm_text)) },
1086+ confirmButton = {
1087+ TextButton(onClick = {
1088+ showSummarizeConfirm = false
1089+ vm.summarize()
1090+ }) { Text(stringResource(R.string.action_continue)) }
1091+ },
1092+ dismissButton = {
1093+ TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1094+ },
1095+ )
1096+ }
1097+
1098+ if (showInitConfirm) {
1099+ AlertDialog(
1100+ onDismissRequest = { showInitConfirm = false },
1101+ title = { Text(stringResource(R.string.init_confirm_title)) },
1102+ text = { Text(stringResource(R.string.init_confirm_text)) },
1103+ confirmButton = {
1104+ TextButton(onClick = {
1105+ showInitConfirm = false
1106+ vm.initProject()
1107+ }) { Text(stringResource(R.string.action_continue)) }
1108+ },
1109+ dismissButton = {
1110+ TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1111+ },
1112+ )
1113+ }
1114+
10721115 if (showRenameDialog) {
10731116 RenameSessionChatDialog(
10741117 initialTitle = sessionTitle ?: "",
@@ -1146,6 +1189,8 @@ fun ChatScreen(
11461189 pendingPermission?.let { permission ->
11471190 PermissionDialog(
11481191 permission = permission,
1192+ position = permissionProgress.position,
1193+ total = permissionProgress.total,
11491194 onRespond = { response -> vm.respondPermission(permission, response) },
11501195 )
11511196 }
@@ -1272,7 +1317,11 @@ private fun ChatInputBar(
12721317 false
12731318 }
12741319 },
1275− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1320+ // While a run is active a typed message only queues (sends after the run), so
1321+ // the placeholder says so instead of the plain "Message…".
1322+ placeholder = {
1323+ Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
1324+ },
12761325 enabled = enabled,
12771326 maxLines = 6,
12781327 // Show a "N / max" countdown once the draft crosses a high fraction of
@@ -1373,8 +1422,10 @@ private fun ComposerTrailingButton(
13731422 onClick = onQueue,
13741423 modifier = Modifier.testTag("queue_button"),
13751424 ) {
1425+ // A clock/schedule icon (not Send) so it reads as "queue for later" and
1426+ // isn't confused with the send action sitting right beside the Stop button.
13761427 Icon(
1377− Icons.AutoMirrored.Filled.Send,
1428+ Icons.Filled.Schedule,
13781429 contentDescription = stringResource(R.string.queue_followup),
13791430 )
13801431 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+36
@@ -69,6 +69,14 @@ import kotlinx.coroutines.withContext
6969 */
7070data class ChatError(val message: String, val retryable: Boolean = false)
7171
72+/**
73+ * Progress of the current permission within a burst of requests. [total] is the max number
74+ * simultaneously pending since the queue was last empty; [position] advances as the user clears
75+ * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
76+ */
77+@androidx.compose.runtime.Immutable
78+data class PermissionProgress(val position: Int, val total: Int)
79+
7280/**
7381 * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
7482 * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
@@ -298,6 +306,14 @@ class ChatViewModel(
298306 private val _pendingPermission = MutableStateFlow<Permission?>(null)
299307 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
300308
309+ /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
310+ private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
311+ val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
312+
313+ /** Max number of permissions simultaneously pending since the queue was last empty. Reset
314+ * to 0 when the queue drains, so a fresh burst starts its count over. */
315+ private var maxPendingSeen = 0
316+
301317 // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
302318 // recent; when one is answered/replied — or a response fails — we fall back to the next so a
303319 // request that arrived while another was in flight isn't silently dropped (its tool run would
@@ -308,16 +324,36 @@ class ChatViewModel(
308324 private fun enqueuePermission(permission: Permission) {
309325 pendingPermissions[permission.id] = permission
310326 _pendingPermission.value = pendingPermissions.values.lastOrNull()
327+ updatePermissionProgress()
311328 }
312329
313330 private fun resolvePermission(id: String) {
314331 pendingPermissions.remove(id)
315332 _pendingPermission.value = pendingPermissions.values.lastOrNull()
333+ updatePermissionProgress()
316334 }
317335
318336 private fun clearPermissions() {
319337 pendingPermissions.clear()
320338 _pendingPermission.value = null
339+ updatePermissionProgress()
340+ }
341+
342+ /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
343+ * resets only when it drains, so position = total - remaining + 1 advances as requests are
344+ * answered. */
345+ private fun updatePermissionProgress() {
346+ val remaining = pendingPermissions.size
347+ if (remaining == 0) {
348+ maxPendingSeen = 0
349+ _permissionProgress.value = PermissionProgress(0, 0)
350+ return
351+ }
352+ if (remaining > maxPendingSeen) maxPendingSeen = remaining
353+ _permissionProgress.value = PermissionProgress(
354+ position = maxPendingSeen - remaining + 1,
355+ total = maxPendingSeen,
356+ )
321357 }
322358
323359 private val _agents = MutableStateFlow<List<Agent>>(emptyList())
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+8−2
@@ -46,6 +46,7 @@ import androidx.compose.ui.text.style.TextOverflow
4646import androidx.compose.ui.unit.dp
4747import androidx.compose.foundation.layout.Box
4848import soy.iko.opencode.data.model.Command
49+import soy.iko.opencode.data.network.NetworkConfig
4950import soy.iko.opencode.R
5051
5152/** Bottom sheet that lists slash-commands; selecting one invokes it. */
@@ -102,9 +103,14 @@ fun CommandPickerSheet(
102103 var query by rememberSaveable { mutableStateOf("") }
103104 val keyboardController = LocalSoftwareKeyboardController.current
104105 val haptics = LocalHapticFeedback.current
105− // Auto-focus the search field (and raise the keyboard) when the sheet opens.
106+ // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
107+ // it for a short list that's faster to scan by eye than to filter.
106108 val searchFocus = remember { FocusRequester() }
107− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
109+ LaunchedEffect(Unit) {
110+ if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
111+ runCatching { searchFocus.requestFocus() }
112+ }
113+ }
108114 OutlinedTextField(
109115 value = query,
110116 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+9−4
@@ -2,11 +2,13 @@ package soy.iko.opencode.ui.chat
22
33import androidx.compose.foundation.background
44import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.BoxWithConstraints
56import androidx.compose.foundation.layout.Column
67import androidx.compose.foundation.layout.Row
78import androidx.compose.foundation.layout.fillMaxWidth
89import androidx.compose.foundation.layout.padding
910import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.layout.widthIn
1012import androidx.compose.material.icons.Icons
1113import androidx.compose.material.icons.automirrored.filled.HelpOutline
1214import androidx.compose.material.icons.automirrored.filled.VolumeUp
@@ -222,10 +224,13 @@ private fun UserBubble(
222224 .joinToString("\n\n") { it.text }
223225 .takeIf { it.isNotBlank() }
224226 }
225− Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
227+ // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
228+ // snug instead of a wide empty box while long prompts still cap at the readable fraction.
229+ BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
226230 Column(
227231 modifier = Modifier
228− .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
232+ .align(Alignment.TopEnd)
233+ .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
229234 .clip(MaterialTheme.shapes.medium)
230235 .background(MaterialTheme.colorScheme.primaryContainer)
231236 .padding(12.dp),
@@ -271,7 +276,7 @@ private fun UserBubble(
271276 IconButton(
272277 onClick = {
273278 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
274− copyToClipboard(context, "message", textToCopy)
279+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
275280 },
276281 ) {
277282 Icon(
@@ -424,7 +429,7 @@ private fun AssistantActions(
424429 if (textToCopy != null) {
425430 IconButton(onClick = {
426431 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
427− copyToClipboard(context, "message", textToCopy)
432+ copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
428433 }) {
429434 Icon(
430435 Icons.Filled.ContentCopy,
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+8−2
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction
4949import androidx.compose.ui.unit.dp
5050import androidx.compose.foundation.layout.Box
5151import soy.iko.opencode.data.model.ModelOption
52+import soy.iko.opencode.data.network.NetworkConfig
5253import soy.iko.opencode.R
5354
5455/**
@@ -124,9 +125,14 @@ fun ModelPickerSheet(
124125 val keyboardController = LocalSoftwareKeyboardController.current
125126 val haptics = LocalHapticFeedback.current
126127 // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
127− // user can start typing to filter a long catalog without an extra tap.
128+ // user can start typing to filter a long catalog without an extra tap. Skip it for a
129+ // short catalog that's faster to scan by eye than to filter.
128130 val searchFocus = remember { FocusRequester() }
129− LaunchedEffect(Unit) { runCatching { searchFocus.requestFocus() } }
131+ LaunchedEffect(Unit) {
132+ if (options.size > NetworkConfig.pickerSearchAutofocusThreshold) {
133+ runCatching { searchFocus.requestFocus() }
134+ }
135+ }
130136 OutlinedTextField(
131137 value = query,
132138 onValueChange = { query = it },
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+25−9
@@ -30,6 +30,7 @@ import androidx.compose.material3.MaterialTheme
3030import androidx.compose.material3.Text
3131import androidx.compose.material3.TextButton
3232import androidx.compose.runtime.Composable
33+import androidx.compose.runtime.LaunchedEffect
3334import androidx.compose.runtime.getValue
3435import androidx.compose.runtime.mutableStateOf
3536import androidx.compose.runtime.remember
@@ -46,6 +47,7 @@ import androidx.compose.ui.semantics.contentDescription
4647import androidx.compose.ui.semantics.semantics
4748import androidx.compose.ui.semantics.stateDescription
4849import androidx.compose.ui.text.font.FontFamily
50+import androidx.compose.ui.res.pluralStringResource
4951import androidx.compose.ui.res.stringResource
5052import androidx.compose.ui.unit.dp
5153import kotlinx.serialization.json.Json
@@ -164,6 +166,15 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
164166 // visible; once complete it collapses. A non-null override records an explicit user
165167 // toggle and wins over the streaming default, so we don't fight a user who collapsed it.
166168 var userOverride by rememberSaveable(saveableKey) { mutableStateOf<Boolean?>(null) }
169+ // Sticky expand: once a block has streamed this session, pin it open after streaming ends
170+ // (until the user explicitly collapses it) so it isn't yanked away mid-read the instant the
171+ // stream finishes. A historical block that never streamed this session (streaming is false
172+ // from the first composition) never sets streamedThisSession, so it stays collapsed.
173+ var streamedThisSession by remember(saveableKey) { mutableStateOf(false) }
174+ LaunchedEffect(streaming) {
175+ if (streaming) streamedThisSession = true
176+ else if (streamedThisSession && userOverride == null) userOverride = true
177+ }
167178 val expanded = userOverride ?: streaming
168179 val expandedState = stringResource(R.string.state_expanded)
169180 val collapsedState = stringResource(R.string.state_collapsed)
@@ -217,7 +228,7 @@ private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modi
217228 TextButton(
218229 onClick = {
219230 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
220− copyToClipboard(context, "reasoning", text)
231+ copyToClipboard(context, context.getString(R.string.clip_label_reasoning), text)
221232 },
222233 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
223234 modifier = Modifier.semantics(mergeDescendants = true) {},
@@ -296,14 +307,19 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
296307 else -> null
297308 }
298309 if (detail != null) {
299− val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
310+ // looksLikeDiff scans the whole string; memoize on `detail` so a recomposition that
311+ // doesn't change the content (e.g. the expand/collapse flip) doesn't re-scan.
312+ val isDiff = remember(detail) { looksLikeDiff(detail) }
313+ val collapsed = remember(detail, isDiff) {
314+ val head = detail.take(COLLAPSED_LIMIT)
315+ // Truncating a diff mid-line makes DiffView render a malformed final line, so
316+ // trim back to the last complete line when the content is a diff.
317+ if (isDiff) head.substringBeforeLast('\n') else head
318+ }
300319 var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
301320 val expandedState = stringResource(R.string.state_expanded)
302321 val collapsedState = stringResource(R.string.state_collapsed)
303322 val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
304− // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
305− // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
306− val isDiff = remember(display) { looksLikeDiff(display) }
307323 // How many lines are hidden while collapsed. detail.lines() splits the whole
308324 // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
309325 // every recomposition, including each streaming update of a running tool.
@@ -326,7 +342,7 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
326342 collapsedState = collapsedState,
327343 onCopy = {
328344 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
329− copyToClipboard(context, "output", detail)
345+ copyToClipboard(context, context.getString(R.string.clip_label_output), detail)
330346 },
331347 diffSaveKey = part.id,
332348 )
@@ -411,7 +427,7 @@ private fun CollapsibleDetail(
411427 Text(
412428 when {
413429 expanded -> stringResource(R.string.show_less)
414− moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
430+ moreLines > 0 -> pluralStringResource(R.plurals.show_more_lines, moreLines, moreLines)
415431 else -> stringResource(R.string.show_more)
416432 },
417433 style = MaterialTheme.typography.labelSmall,
@@ -495,14 +511,14 @@ private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -
495511 opener?.invoke(toOpen)
496512 } else if (!path.isNullOrBlank()) {
497513 haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
498− copyToClipboard(context, "path", path)
514+ copyToClipboard(context, context.getString(R.string.clip_label_path), path)
499515 showCopyToast(context, context.getString(R.string.path_copied))
500516 }
501517 },
502518 onLongClick = {
503519 if (!path.isNullOrBlank()) {
504520 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
505− copyToClipboard(context, "path", path)
521+ copyToClipboard(context, context.getString(R.string.clip_label_path), path)
506522 showCopyToast(context, context.getString(R.string.path_copied))
507523 }
508524 },
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−10
@@ -4,7 +4,11 @@ import androidx.compose.foundation.layout.Arrangement
44import androidx.compose.foundation.layout.Column
55import androidx.compose.foundation.layout.Row
66import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
78import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.rememberScrollState
10+import androidx.compose.foundation.text.selection.SelectionContainer
11+import androidx.compose.foundation.verticalScroll
812import androidx.compose.material.icons.Icons
913import androidx.compose.material.icons.filled.Shield
1014import androidx.compose.material3.AlertDialog
@@ -37,6 +41,8 @@ import soy.iko.opencode.R
3741fun PermissionDialog(
3842 permission: Permission,
3943 onRespond: (PermissionResponse) -> Unit,
44+ position: Int = 0,
45+ total: Int = 0,
4046) {
4147 val haptics = LocalHapticFeedback.current
4248 // Guard against double-respond: back press + button tap, or rapid double-tap,
@@ -66,20 +72,45 @@ fun PermissionDialog(
6672 onDismissRequest = { respond(PermissionResponse.REJECT) },
6773 properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
6874 icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
69− title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
70− text = {
71− Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
72− permission.type?.let {
73− Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
74− }
75− permission.patternText?.let {
75+ title = {
76+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
77+ // A backlog indicator ("N of M") when more than one request is queued, so the
78+ // user knows the newest dialog isn't the only pending decision.
79+ if (total > 1) {
7680 Text(
77− it,
78− style = MaterialTheme.typography.bodyMedium,
79− fontFamily = FontFamily.Monospace,
81+ stringResource(R.string.permission_progress, position, total),
82+ style = MaterialTheme.typography.labelMedium,
8083 color = MaterialTheme.colorScheme.onSurfaceVariant,
8184 )
8285 }
86+ Text(permission.title ?: stringResource(R.string.permission_title))
87+ }
88+ },
89+ text = {
90+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
91+ // The command / pattern can be long or multi-line; make it selectable and cap
92+ // its height with an inner scroll so it can't push the Allow/Reject buttons off
93+ // screen. Keeps the monospace styling.
94+ SelectionContainer {
95+ Column(
96+ modifier = Modifier
97+ .heightIn(max = 200.dp)
98+ .verticalScroll(rememberScrollState()),
99+ verticalArrangement = Arrangement.spacedBy(8.dp),
100+ ) {
101+ permission.type?.let {
102+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
103+ }
104+ permission.patternText?.let {
105+ Text(
106+ it,
107+ style = MaterialTheme.typography.bodyMedium,
108+ fontFamily = FontFamily.Monospace,
109+ color = MaterialTheme.colorScheme.onSurfaceVariant,
110+ )
111+ }
112+ }
113+ }
83114 Text(
84115 stringResource(R.string.permission_text),
85116 style = MaterialTheme.typography.bodySmall,
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt+20−1
@@ -7,6 +7,13 @@ import androidx.compose.foundation.layout.heightIn
77import androidx.compose.foundation.layout.padding
88import androidx.compose.foundation.lazy.LazyColumn
99import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardActions
11+import androidx.compose.foundation.text.KeyboardOptions
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Close
14+import androidx.compose.material.icons.filled.Search
15+import androidx.compose.material3.Icon
16+import androidx.compose.material3.IconButton
1017import androidx.compose.material3.MaterialTheme
1118import androidx.compose.material3.OutlinedTextField
1219import androidx.compose.material3.Surface
@@ -25,6 +32,8 @@ import androidx.compose.ui.input.key.key
2532import androidx.compose.ui.input.key.onPreviewKeyEvent
2633import androidx.compose.ui.input.key.type
2734import androidx.compose.ui.res.stringResource
35+import androidx.compose.ui.semantics.Role
36+import androidx.compose.ui.text.input.ImeAction
2837import androidx.compose.ui.unit.dp
2938import androidx.compose.ui.window.Dialog
3039import soy.iko.opencode.R
@@ -63,6 +72,16 @@ fun CommandPalette(
6372 onValueChange = { query = it },
6473 placeholder = { Text(stringResource(R.string.palette_hint)) },
6574 singleLine = true,
75+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
76+ trailingIcon = {
77+ if (query.isNotEmpty()) {
78+ IconButton(onClick = { query = "" }) {
79+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
80+ }
81+ }
82+ },
83+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
84+ keyboardActions = KeyboardActions(onGo = { filtered.firstOrNull()?.let { run(it) } }),
6685 modifier = Modifier
6786 .fillMaxWidth()
6887 .focusRequester(focus)
@@ -91,7 +110,7 @@ fun CommandPalette(
91110 style = MaterialTheme.typography.bodyLarge,
92111 modifier = Modifier
93112 .fillMaxWidth()
94− .clickable { run(action) }
113+ .clickable(role = Role.Button) { run(action) }
95114 .padding(horizontal = 12.dp, vertical = 14.dp),
96115 )
97116 }
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+4−1
@@ -79,7 +79,10 @@ fun ConnectionBanner(
7979 Surface(
8080 modifier = modifier
8181 .fillMaxWidth()
82− .semantics {
82+ // mergeDescendants so the spinner + label read as one TalkBack stop instead of
83+ // several; the Retry TextButton is its own merged node, so it stays separately
84+ // actionable.
85+ .semantics(mergeDescendants = true) {
8386 // Announce connection state changes to TalkBack users so they're
8487 // aware the stream dropped/reconnecting without visual cues.
8588 liveRegion = LiveRegionMode.Polite
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+10−5
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.Color
3131import androidx.compose.ui.platform.LocalContext
3232import androidx.compose.ui.text.font.FontFamily
3333import androidx.compose.ui.unit.dp
34+import androidx.compose.ui.res.pluralStringResource
3435import androidx.compose.ui.res.stringResource
3536import soy.iko.opencode.R
3637
@@ -150,7 +151,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
150151 modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
151152 horizontalArrangement = Arrangement.End,
152153 ) {
153− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
154+ IconButton(onClick = { copyToClipboard(context, context.getString(R.string.clip_label_diff), diff) }) {
154155 Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
155156 }
156157 }
@@ -181,7 +182,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
181182 line.text,
182183 style = MaterialTheme.typography.bodySmall,
183184 fontFamily = FontFamily.Monospace,
184− color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
185+ color = MaterialTheme.colorScheme.onSurfaceVariant,
185186 modifier = Modifier.padding(vertical = 1.dp),
186187 )
187188 is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
@@ -191,12 +192,16 @@ fun DiffView(diff: String, modifier: Modifier = Modifier, saveKey: String? = nul
191192 }
192193 }
193194 }
194− if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
195+ if (lines.size > COLLAPSED_DIFF_LINES) {
196+ val hidden = lines.size - COLLAPSED_DIFF_LINES
195197 TextButton(
196− onClick = { expanded = true },
198+ onClick = { expanded = !expanded },
197199 contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
198200 ) {
199− Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
201+ Text(
202+ if (expanded) stringResource(R.string.show_less)
203+ else pluralStringResource(R.plurals.show_more_lines, hidden, hidden),
204+ )
200205 }
201206 }
202207 Spacer(Modifier.height(10.dp))
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+4−5
@@ -210,23 +210,22 @@ private fun CodeWithCopy(model: MarkdownComponentModel) {
210210 modifier = Modifier.weight(1f),
211211 )
212212 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
213− IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
213+ IconButton(onClick = { wrap = !wrap }) {
214214 Icon(
215215 Icons.AutoMirrored.Filled.WrapText,
216216 contentDescription = wrapLabel,
217− modifier = Modifier.size(16.dp),
217+ modifier = Modifier.size(18.dp),
218218 tint = if (wrap) MaterialTheme.colorScheme.primary
219219 else MaterialTheme.colorScheme.onSurfaceVariant,
220220 )
221221 }
222222 IconButton(
223− onClick = { copyToClipboard(context, "code", code) },
224− modifier = Modifier.size(36.dp),
223+ onClick = { copyToClipboard(context, context.getString(R.string.clip_label_code), code) },
225224 ) {
226225 Icon(
227226 Icons.Filled.ContentCopy,
228227 contentDescription = context.getString(R.string.copy),
229− modifier = Modifier.size(14.dp),
228+ modifier = Modifier.size(18.dp),
230229 tint = MaterialTheme.colorScheme.onSurfaceVariant,
231230 )
232231 }
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+37−2
@@ -1,20 +1,28 @@
11package soy.iko.opencode.ui.components
22
33import android.util.Base64
4+import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
57import androidx.compose.foundation.layout.fillMaxWidth
68import androidx.compose.foundation.layout.heightIn
9+import androidx.compose.foundation.layout.padding
710import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.Text
812import androidx.compose.runtime.Composable
913import androidx.compose.runtime.Immutable
14+import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.mutableIntStateOf
1016import androidx.compose.runtime.produceState
1117import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
1219import androidx.compose.ui.Alignment
1320import androidx.compose.ui.Modifier
1421import androidx.compose.ui.draw.clip
1522import androidx.compose.ui.layout.ContentScale
1623import androidx.compose.ui.platform.LocalContext
1724import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.semantics.Role
1826import androidx.compose.ui.semantics.contentDescription
1927import androidx.compose.ui.semantics.semantics
2028import androidx.compose.ui.unit.dp
@@ -164,7 +172,10 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
164172 ImageStatusBox { BrokenImageIcon() }
165173 return
166174 }
167− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
175+ // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
176+ // the error state instead of being a permanent broken-image icon.
177+ var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
178+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
168179 ImageRequest.Builder(context)
169180 .data(model)
170181 .apply {
@@ -194,10 +205,34 @@ fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modi
194205 )
195206 }
196207 },
197− error = { ImageStatusBox { BrokenImageIcon() } },
208+ error = { ImageRetry(onRetry = { retryKey++ }) },
198209 )
199210}
200211
212+/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
213+ * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
214+@Composable
215+private fun ImageRetry(onRetry: () -> Unit) {
216+ val label = stringResource(R.string.tap_to_retry)
217+ Box(
218+ modifier = Modifier
219+ .fillMaxWidth()
220+ .heightIn(min = 120.dp)
221+ .clickable(role = Role.Button, onClick = onRetry),
222+ contentAlignment = Alignment.Center,
223+ ) {
224+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
225+ BrokenImageIcon()
226+ Text(
227+ label,
228+ style = MaterialTheme.typography.labelSmall,
229+ color = MaterialTheme.colorScheme.onSurfaceVariant,
230+ modifier = Modifier.padding(top = 4.dp),
231+ )
232+ }
233+ }
234+}
235+
201236/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
202237 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
203238private val ImageResolving = Any()
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+87−22
@@ -32,6 +32,7 @@ import androidx.compose.material3.FilterChip
3232import androidx.compose.material3.HorizontalDivider
3333import androidx.compose.material3.Icon
3434import androidx.compose.material3.IconButton
35+import androidx.compose.material3.LinearProgressIndicator
3536import androidx.compose.material3.MaterialTheme
3637import androidx.compose.material3.OutlinedTextField
3738import androidx.compose.material3.Scaffold
@@ -45,6 +46,8 @@ import androidx.compose.runtime.getValue
4546import androidx.compose.runtime.remember
4647import androidx.compose.ui.Alignment
4748import androidx.compose.ui.Modifier
49+import androidx.compose.ui.focus.FocusRequester
50+import androidx.compose.ui.focus.focusRequester
4851import androidx.compose.ui.graphics.Color
4952import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5053import androidx.compose.ui.platform.testTag
@@ -60,6 +63,7 @@ import androidx.compose.ui.text.input.ImeAction
6063import androidx.compose.ui.text.style.TextOverflow
6164import androidx.compose.ui.text.withStyle
6265import androidx.compose.ui.unit.dp
66+import androidx.compose.ui.res.pluralStringResource
6367import androidx.compose.ui.res.stringResource
6468import androidx.lifecycle.compose.collectAsStateWithLifecycle
6569import androidx.lifecycle.viewmodel.compose.viewModel
@@ -70,6 +74,7 @@ import soy.iko.opencode.data.model.symbolKindLabel
7074import soy.iko.opencode.di.AppContainer
7175import soy.iko.opencode.R
7276import soy.iko.opencode.ui.vmFactory
77+import soy.iko.opencode.util.runCatchingCancellable
7378
7479@OptIn(ExperimentalMaterial3Api::class)
7580@Composable
@@ -110,10 +115,18 @@ fun FileBrowserScreen(
110115 modifier = Modifier.fillMaxWidth(),
111116 )
112117 val keyboardController = LocalSoftwareKeyboardController.current
118+ // Search is the primary reason to open this screen, so focus the field on first
119+ // composition. Focus only (no forced keyboard) so it doesn't fight the breadcrumb/listing.
120+ val searchFocus = remember { FocusRequester() }
121+ LaunchedEffect(Unit) { runCatchingCancellable { searchFocus.requestFocus() } }
113122 OutlinedTextField(
114123 value = state.query,
115124 onValueChange = vm::setQuery,
116− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
125+ modifier = Modifier
126+ .fillMaxWidth()
127+ .padding(start = 12.dp, end = 12.dp, top = 12.dp)
128+ .focusRequester(searchFocus)
129+ .testTag("file_search"),
117130 label = {
118131 Text(
119132 stringResource(
@@ -154,13 +167,14 @@ fun FileBrowserScreen(
154167 modifier = Modifier.fillMaxSize(),
155168 ) {
156169 Box(modifier = Modifier.fillMaxSize()) {
170+ val loadingLabel = stringResource(R.string.loading)
157171 when {
158− state.loading || state.searching -> {
159− val loadingLabel = stringResource(R.string.loading)
160− CircularProgressIndicator(
161− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
162− )
163− }
172+ // Full-screen spinner only for the initial directory load. An in-flight
173+ // search keeps the previous results visible (a slim top bar below shows the
174+ // progress) instead of blanking the list behind a spinner on every keystroke.
175+ state.loading -> CircularProgressIndicator(
176+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
177+ )
164178 state.error != null -> Column(
165179 modifier = Modifier.align(Alignment.Center).padding(24.dp),
166180 horizontalAlignment = Alignment.CenterHorizontally,
@@ -180,9 +194,15 @@ fun FileBrowserScreen(
180194 }
181195 }
182196 state.mode == SearchMode.TEXT ->
183− TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
197+ TextResults(
198+ state.textResults,
199+ searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_contents_hint),
200+ ) { path, line -> onOpenFile(path, line) }
184201 state.mode == SearchMode.SYMBOL ->
185− SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
202+ SymbolResults(
203+ state.symbolResults,
204+ searchEmptyMessage(state.searching, state.query.isBlank(), R.string.search_symbols_hint),
205+ ) { path, line -> onOpenFile(path, line) }
186206 state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187207 else -> DirectoryListing(
188208 state = state,
@@ -191,6 +211,16 @@ fun FileBrowserScreen(
191211 onOpenFile = { onOpenFile(it, null) },
192212 )
193213 }
214+ // Slim progress bar for an in-flight search; the results list stays visible
215+ // underneath so incremental typing doesn't flash an empty screen each keystroke.
216+ if (state.searching) {
217+ LinearProgressIndicator(
218+ modifier = Modifier
219+ .align(Alignment.TopCenter)
220+ .fillMaxWidth()
221+ .semantics { contentDescription = loadingLabel },
222+ )
223+ }
194224 }
195225 }
196226 }
@@ -200,9 +230,13 @@ fun FileBrowserScreen(
200230@Composable
201231private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202232 val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
233+ val scrollState = rememberScrollState()
234+ // Scroll to the deepest segment when the path changes so the current directory stays in
235+ // view instead of hiding off the right edge behind the earlier segments on a deep path.
236+ LaunchedEffect(path) { scrollState.animateScrollTo(scrollState.maxValue) }
203237 Row(
204238 modifier = modifier
205− .horizontalScroll(rememberScrollState())
239+ .horizontalScroll(scrollState)
206240 .padding(horizontal = 16.dp, vertical = 8.dp),
207241 verticalAlignment = Alignment.CenterVertically,
208242 ) {
@@ -268,18 +302,39 @@ private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit
268302 FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
269303}
270304
305+/** Empty-state message for a content/symbol search: a "type to search" hint before anything
306+ * is typed, nothing while a search is in flight (the top progress bar covers that, so the
307+ * first keystroke doesn't flash a false "No matches"), and "No matches" only once a real
308+ * query genuinely returned nothing. */
309+@Composable
310+private fun searchEmptyMessage(searching: Boolean, queryBlank: Boolean, hint: Int): String? = when {
311+ searching -> null
312+ queryBlank -> stringResource(hint)
313+ else -> stringResource(R.string.no_matches)
314+}
315+
271316/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
272317@Composable
273−private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
318+private fun TextResults(results: List<FindMatch>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
274319 if (results.isEmpty()) {
275− EmptyFileState(
276− icon = Icons.Filled.Search,
277− message = stringResource(R.string.no_matches),
278− modifier = Modifier.fillMaxWidth().padding(24.dp),
279− )
320+ if (emptyMessage != null) {
321+ EmptyFileState(
322+ icon = Icons.Filled.Search,
323+ message = emptyMessage,
324+ modifier = Modifier.fillMaxWidth().padding(24.dp),
325+ )
326+ }
280327 return
281328 }
282329 LazyColumn(modifier = Modifier.fillMaxSize()) {
330+ item(key = "__count") {
331+ Text(
332+ pluralStringResource(R.plurals.file_search_results, results.size, results.size),
333+ style = MaterialTheme.typography.labelSmall,
334+ color = MaterialTheme.colorScheme.onSurfaceVariant,
335+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
336+ )
337+ }
283338 itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
284339 val name = match.filePath.substringAfterLast('/')
285340 val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
@@ -331,16 +386,26 @@ private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit
331386
332387/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
333388@Composable
334−private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
389+private fun SymbolResults(results: List<SymbolResult>, emptyMessage: String?, onOpen: (String, Int?) -> Unit) {
335390 if (results.isEmpty()) {
336− EmptyFileState(
337− icon = Icons.Filled.Search,
338− message = stringResource(R.string.no_matches),
339− modifier = Modifier.fillMaxWidth().padding(24.dp),
340− )
391+ if (emptyMessage != null) {
392+ EmptyFileState(
393+ icon = Icons.Filled.Search,
394+ message = emptyMessage,
395+ modifier = Modifier.fillMaxWidth().padding(24.dp),
396+ )
397+ }
341398 return
342399 }
343400 LazyColumn(modifier = Modifier.fillMaxSize()) {
401+ item(key = "__count") {
402+ Text(
403+ pluralStringResource(R.plurals.file_search_results, results.size, results.size),
404+ style = MaterialTheme.typography.labelSmall,
405+ color = MaterialTheme.colorScheme.onSurfaceVariant,
406+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
407+ )
408+ }
344409 itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
345410 val fileName = symbol.filePath.substringAfterLast('/')
346411 Column(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+162−64
@@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut
99import androidx.compose.animation.togetherWith
1010import androidx.compose.foundation.background
1111import androidx.compose.foundation.horizontalScroll
12+import androidx.compose.foundation.interaction.DragInteraction
1213import androidx.compose.foundation.layout.Box
1314import androidx.compose.foundation.layout.BoxScope
1415import androidx.compose.foundation.layout.Column
@@ -35,10 +36,13 @@ import androidx.compose.material.icons.filled.Close
3536import androidx.compose.material.icons.filled.ContentCopy
3637import androidx.compose.material.icons.filled.KeyboardArrowDown
3738import androidx.compose.material.icons.filled.KeyboardArrowUp
39+import androidx.compose.material.icons.filled.MoreVert
3840import androidx.compose.material.icons.filled.Refresh
3941import androidx.compose.material.icons.filled.Search
4042import androidx.compose.material.icons.filled.Share
4143import androidx.compose.material3.CircularProgressIndicator
44+import androidx.compose.material3.DropdownMenu
45+import androidx.compose.material3.DropdownMenuItem
4246import androidx.compose.material3.ExperimentalMaterial3Api
4347import androidx.compose.material3.FilterChip
4448import androidx.compose.material3.Icon
@@ -51,6 +55,7 @@ import androidx.compose.material3.TextButton
5155import androidx.compose.material3.TopAppBar
5256import androidx.compose.runtime.Composable
5357import androidx.compose.runtime.LaunchedEffect
58+import androidx.compose.runtime.MutableState
5459import androidx.compose.runtime.getValue
5560import androidx.compose.runtime.mutableIntStateOf
5661import androidx.compose.runtime.mutableStateOf
@@ -85,8 +90,14 @@ import soy.iko.opencode.ui.vmFactory
8590import soy.iko.opencode.util.runCatchingCancellable
8691import kotlinx.coroutines.Dispatchers
8792import kotlinx.coroutines.delay
93+import kotlinx.coroutines.flow.collect
8894import kotlinx.coroutines.withContext
8995
96+/** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
97+ * split once (not twice) into this many lines at most, so it can't hold two full line
98+ * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
99+private const val MAX_RENDERED_LINES = 5000
100+
90101@OptIn(ExperimentalMaterial3Api::class)
91102@Composable
92103fun FileViewScreen(
@@ -121,7 +132,23 @@ fun FileViewScreen(
121132 var wrap by rememberSaveable(path) { mutableStateOf(codeWrap) }
122133 val listState = rememberLazyListState()
123134 val rawText = state.content?.content.orEmpty()
124− val lines = remember(rawText) { rawText.split("\n") }
135+ // Split the raw text exactly once here (the line viewer and find-in-file both consume this
136+ // list) and cap it so a huge file doesn't get split twice or overwhelm the LazyColumn.
137+ val allLines = remember(rawText) { rawText.split("\n") }
138+ val truncated = allLines.size > MAX_RENDERED_LINES
139+ val lines = remember(allLines) {
140+ if (allLines.size > MAX_RENDERED_LINES) allLines.subList(0, MAX_RENDERED_LINES) else allLines
141+ }
142+ // The line the viewer was opened at (from a search hit), highlighted until the user scrolls
143+ // or starts an in-file find so the landed line stands out. Null once cleared.
144+ val highlightLine = rememberJumpToLineHighlight(
145+ path = path,
146+ initialLine = initialLine,
147+ content = state.content,
148+ isBinary = state.content?.isBinary == true,
149+ lineCount = lines.size,
150+ listState = listState,
151+ )
125152 // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
126153 // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
127154 var debouncedFind by remember { mutableStateOf("") }
@@ -145,19 +172,6 @@ fun FileViewScreen(
145172 LaunchedEffect(matchPos, matchIndices) {
146173 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
147174 }
148− // Opened at a specific line (from a search hit): scroll there once the raw content loads.
149− // One-shot so a later find-in-file navigation isn't overridden.
150− var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
151− LaunchedEffect(state.content, initialLine) {
152− val line = initialLine ?: return@LaunchedEffect
153− if (didInitialLineScroll) return@LaunchedEffect
154− val content = state.content ?: return@LaunchedEffect
155− if (content.isBinary) return@LaunchedEffect
156− val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
157− runCatchingCancellable { listState.scrollToItem(target) }
158− didInitialLineScroll = true
159− }
160−
161175 Scaffold(
162176 topBar = {
163177 FileViewTopBar(
@@ -165,6 +179,7 @@ fun FileViewScreen(
165179 loading = state.loading,
166180 content = rawText,
167181 wrap = wrap,
182+ diffShown = showToggle && showDiff,
168183 onBack = onBack,
169184 onReload = { vm.reload() },
170185 // Find searches the raw line list, which isn't on screen under a diff. Rather
@@ -222,13 +237,16 @@ fun FileViewScreen(
222237 },
223238 findActive = findActive,
224239 findQuery = findQuery,
225− onQueryChange = { findQuery = it; matchPos = 0 },
240+ onQueryChange = { findQuery = it; matchPos = 0; if (it.isNotEmpty()) highlightLine.value = null },
226241 matchIndices = matchIndices,
227242 matchPos = matchPos,
228243 onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
229244 onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
230245 onCloseFind = { findActive = false; findQuery = "" },
231246 wrap = wrap,
247+ lines = lines,
248+ truncated = truncated,
249+ highlightLineIndex = highlightLine.value,
232250 listState = listState,
233251 onRetry = { vm.reload() },
234252 )
@@ -237,6 +255,37 @@ fun FileViewScreen(
237255 }
238256}
239257
258+// Owns the "jump to a line from a search hit" behaviour: scroll to the target once the raw
259+// content loads (one-shot), and clear the highlight the moment the user drags the list by hand.
260+// Extracted so FileViewScreen stays under the cyclomatic-complexity threshold.
261+@Composable
262+private fun rememberJumpToLineHighlight(
263+ path: String,
264+ initialLine: Int?,
265+ content: Any?,
266+ isBinary: Boolean,
267+ lineCount: Int,
268+ listState: LazyListState,
269+): MutableState<Int?> {
270+ val highlight = rememberSaveable(path) { mutableStateOf<Int?>(null) }
271+ var didInitialScroll by rememberSaveable(path) { mutableStateOf(false) }
272+ LaunchedEffect(content, initialLine) {
273+ val line = initialLine ?: return@LaunchedEffect
274+ if (didInitialScroll || content == null || isBinary) return@LaunchedEffect
275+ val target = (line - 1).coerceIn(0, (lineCount - 1).coerceAtLeast(0))
276+ runCatchingCancellable { listState.scrollToItem(target) }
277+ highlight.value = target
278+ didInitialScroll = true
279+ }
280+ LaunchedEffect(listState) {
281+ if (initialLine == null) return@LaunchedEffect
282+ listState.interactionSource.interactions.collect { interaction ->
283+ if (interaction is DragInteraction.Start) highlight.value = null
284+ }
285+ }
286+ return highlight
287+}
288+
240289@OptIn(ExperimentalMaterial3Api::class)
241290@Composable
242291private fun FileViewTopBar(
@@ -244,6 +293,7 @@ private fun FileViewTopBar(
244293 loading: Boolean,
245294 content: String,
246295 wrap: Boolean,
296+ diffShown: Boolean,
247297 onBack: () -> Unit,
248298 onReload: () -> Unit,
249299 onToggleFind: () -> Unit,
@@ -263,9 +313,13 @@ private fun FileViewTopBar(
263313 // be editing it), so reload without requiring a back-out. Shows a spinner while
264314 // loading since the content now stays on screen during a reload (the spinner is
265315 // the only progress signal).
316+ val loadingLabel = stringResource(R.string.loading)
266317 IconButton(onClick = onReload, enabled = !loading) {
267318 if (loading) {
268− CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
319+ CircularProgressIndicator(
320+ Modifier.size(20.dp).semantics { contentDescription = loadingLabel },
321+ strokeWidth = 2.dp,
322+ )
269323 } else {
270324 Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
271325 }
@@ -278,19 +332,38 @@ private fun FileViewTopBar(
278332 IconButton(onClick = onToggleFind) {
279333 Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
280334 }
281− // Wrap long lines instead of horizontal-scrolling, useful for prose.
282− IconButton(onClick = onToggleWrap) {
283− Icon(
284− Icons.AutoMirrored.Filled.WrapText,
285− contentDescription = stringResource(R.string.wrap_lines),
286− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
287− )
288− }
289− IconButton(onClick = onCopy) {
290− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
335+ // Overflow the less-frequent actions (wrap / copy / share) so a long filename
336+ // title isn't crowded off screen by five inline icons on a phone.
337+ var menuExpanded by remember { mutableStateOf(false) }
338+ IconButton(onClick = { menuExpanded = true }) {
339+ Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.more_options))
291340 }
292− IconButton(onClick = onShare) {
293− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
341+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
342+ // Wrap long lines instead of horizontal-scrolling; useful for prose. Disabled
343+ // in diff mode because DiffView ignores wrap, so an active-looking toggle there
344+ // would be a silent no-op.
345+ DropdownMenuItem(
346+ text = { Text(stringResource(R.string.wrap_lines)) },
347+ onClick = { onToggleWrap(); menuExpanded = false },
348+ enabled = !diffShown,
349+ leadingIcon = {
350+ Icon(
351+ Icons.AutoMirrored.Filled.WrapText,
352+ contentDescription = null,
353+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
354+ )
355+ },
356+ )
357+ DropdownMenuItem(
358+ text = { Text(stringResource(R.string.copy)) },
359+ onClick = { onCopy(); menuExpanded = false },
360+ leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
361+ )
362+ DropdownMenuItem(
363+ text = { Text(stringResource(R.string.share)) },
364+ onClick = { onShare(); menuExpanded = false },
365+ leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
366+ )
294367 }
295368 }
296369 },
@@ -313,6 +386,9 @@ private fun BoxScope.FileViewStateContent(
313386 onNext: () -> Unit,
314387 onCloseFind: () -> Unit,
315388 wrap: Boolean,
389+ lines: List<String>,
390+ truncated: Boolean,
391+ highlightLineIndex: Int?,
316392 listState: LazyListState,
317393 onRetry: () -> Unit,
318394) {
@@ -353,6 +429,9 @@ private fun BoxScope.FileViewStateContent(
353429 onNext = onNext,
354430 onCloseFind = onCloseFind,
355431 wrap = wrap,
432+ lines = lines,
433+ truncated = truncated,
434+ highlightLineIndex = highlightLineIndex,
356435 listState = listState,
357436 )
358437 }
@@ -374,6 +453,9 @@ private fun BoxScope.FileViewContentBody(
374453 onNext: () -> Unit,
375454 onCloseFind: () -> Unit,
376455 wrap: Boolean,
456+ lines: List<String>,
457+ truncated: Boolean,
458+ highlightLineIndex: Int?,
377459 listState: LazyListState,
378460) {
379461 // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
@@ -456,12 +538,14 @@ private fun BoxScope.FileViewContentBody(
456538 )
457539 } else {
458540 FileTextContent(
459− text = text,
541+ lines = lines,
460542 filename = filename,
461543 topInset = topInset,
544+ truncated = truncated,
462545 wrap = wrap,
463546 findQuery = findQuery,
464547 matchIndices = matchIndices,
548+ highlightLineIndex = highlightLineIndex,
465549 listState = listState,
466550 )
467551 }
@@ -522,15 +606,16 @@ private fun FindBar(
522606
523607@Composable
524608private fun FileTextContent(
525− text: String,
609+ lines: List<String>,
526610 filename: String,
527611 topInset: androidx.compose.ui.unit.Dp,
612+ truncated: Boolean,
528613 wrap: Boolean,
529614 findQuery: String,
530615 matchIndices: List<Int>,
616+ highlightLineIndex: Int?,
531617 listState: LazyListState,
532618) {
533− val lines = remember(text) { text.split("\n") }
534619 // Gutter width scales with the digit count and the font scale so accessibility
535620 // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
536621 // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
@@ -545,39 +630,52 @@ private fun FileTextContent(
545630 val syntax = remember(filename) { syntaxFor(filename) }
546631 val q = findQuery.trim()
547632 val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
548− LazyColumn(
549− state = listState,
550− modifier = Modifier
551− .fillMaxSize()
552− .padding(top = topInset)
553− .padding(8.dp),
554− ) {
555− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
556− val isMatch = q.isNotEmpty() && index in matchSet
557− Row(
558− modifier = Modifier
559− .fillMaxWidth()
560− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
561− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
562− ) {
563− Text(
564− "${index + 1}",
565− modifier = Modifier.width(gutterWidth),
566− style = MaterialTheme.typography.bodySmall,
567− fontFamily = FontFamily.Monospace,
568− color = MaterialTheme.colorScheme.onSurfaceVariant,
569− )
570− // Heuristic syntax highlighting per line. Falls back to plain text for
571− // unknown extensions, so non-code files render unchanged. Memoized so the
572− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
573− // file, or palette actually changes — not on every recomposition (e.g. every
574− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
575− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
576− Text(
577− highlighted,
578− modifier = Modifier.padding(start = 8.dp),
579− style = MaterialTheme.typography.bodySmall,
580− )
633+ Column(modifier = Modifier.fillMaxSize().padding(top = topInset)) {
634+ if (truncated) {
635+ Text(
636+ stringResource(R.string.file_truncated, MAX_RENDERED_LINES),
637+ style = MaterialTheme.typography.labelSmall,
638+ color = MaterialTheme.colorScheme.onSurfaceVariant,
639+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
640+ )
641+ }
642+ LazyColumn(
643+ state = listState,
644+ modifier = Modifier
645+ .weight(1f)
646+ .fillMaxWidth()
647+ .padding(8.dp),
648+ ) {
649+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
650+ val isMatch = q.isNotEmpty() && index in matchSet
651+ // The jump-to-line target keeps the same emphasis as a find match so the line
652+ // the viewer landed on is obvious until the user scrolls or starts a find.
653+ val isTarget = index == highlightLineIndex
654+ Row(
655+ modifier = Modifier
656+ .fillMaxWidth()
657+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
658+ .then(if (isMatch || isTarget) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
659+ ) {
660+ Text(
661+ "${index + 1}",
662+ modifier = Modifier.width(gutterWidth),
663+ style = MaterialTheme.typography.bodySmall,
664+ fontFamily = FontFamily.Monospace,
665+ color = MaterialTheme.colorScheme.onSurfaceVariant,
666+ )
667+ // Heuristic syntax highlighting per line. Falls back to plain text for
668+ // unknown extensions, so non-code files render unchanged. Memoized so the
669+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
670+ // file, or palette actually changes — not on every recomposition (e.g. every
671+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
672+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
673+ Text(
674+ highlighted,
675+ modifier = Modifier.padding(start = 8.dp),
676+ style = MaterialTheme.typography.bodySmall,
677+ )
678+ }
581679 }
582680 }
583681 }
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt+17−8
@@ -5,9 +5,11 @@ import androidx.compose.foundation.layout.Box
55import androidx.compose.foundation.layout.Column
66import androidx.compose.foundation.layout.PaddingValues
77import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
89import androidx.compose.foundation.layout.fillMaxSize
910import androidx.compose.foundation.layout.fillMaxWidth
1011import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
1113import androidx.compose.foundation.lazy.LazyColumn
1214import androidx.compose.foundation.lazy.items
1315import androidx.compose.material.icons.Icons
@@ -71,9 +73,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
7173 Box(modifier = Modifier.fillMaxSize()) {
7274 val loadingLabel = stringResource(R.string.loading)
7375 when (val s = state) {
74− is McpViewModel.State.Loading -> CircularProgressIndicator(
75− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
76− )
76+ is McpViewModel.State.Loading -> Column(
77+ modifier = Modifier.align(Alignment.Center),
78+ horizontalAlignment = Alignment.CenterHorizontally,
79+ ) {
80+ CircularProgressIndicator(
81+ Modifier.semantics { contentDescription = loadingLabel },
82+ )
83+ Spacer(Modifier.size(12.dp))
84+ Text(loadingLabel, color = MaterialTheme.colorScheme.onSurfaceVariant)
85+ }
7786 is McpViewModel.State.Disconnected -> EmptyState(
7887 icon = Icons.Filled.CloudOff,
7988 title = stringResource(R.string.not_connected),
@@ -130,15 +139,15 @@ private fun McpServerCard(server: McpServerInfo) {
130139 )
131140 }
132141 }
133− val stateLabel = when {
134− !server.enabled -> stringResource(R.string.mcp_disabled)
135− server.type != null -> server.type
136− else -> stringResource(R.string.mcp_connected)
142+ val stateLabel = if (server.enabled) {
143+ stringResource(R.string.mcp_enabled)
144+ } else {
145+ stringResource(R.string.mcp_disabled)
137146 }
138147 Text(
139148 stateLabel,
140149 style = MaterialTheme.typography.labelMedium,
141− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error,
150+ color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
142151 )
143152 }
144153 }
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt+12−2
@@ -35,7 +35,9 @@ import androidx.compose.ui.Alignment
3535import androidx.compose.ui.Modifier
3636import androidx.compose.ui.focus.focusRequester
3737import androidx.compose.ui.platform.LocalSoftwareKeyboardController
38+import androidx.compose.ui.res.pluralStringResource
3839import androidx.compose.ui.res.stringResource
40+import androidx.compose.ui.semantics.Role
3941import androidx.compose.ui.semantics.contentDescription
4042import androidx.compose.ui.semantics.semantics
4143import androidx.compose.ui.text.AnnotatedString
@@ -117,6 +119,14 @@ fun GlobalSearchScreen(
117119 title = stringResource(R.string.search_no_matches),
118120 modifier = Modifier.align(Alignment.Center),
119121 )
122+ // Typed something but below the min length (no search run yet): nudge to keep
123+ // typing rather than reverting to the untouched start state, which reads as if
124+ // the field were empty.
125+ state.query.isNotBlank() && !state.hasSearched -> EmptyState(
126+ icon = Icons.Filled.Search,
127+ title = stringResource(R.string.search_keep_typing),
128+ modifier = Modifier.align(Alignment.Center),
129+ )
120130 state.results.isEmpty() -> EmptyState(
121131 icon = Icons.Filled.Search,
122132 title = stringResource(R.string.search_all_start),
@@ -130,7 +140,7 @@ fun GlobalSearchScreen(
130140 ) {
131141 item(key = "__count") {
132142 Text(
133− stringResource(R.string.search_results_count, state.results.size),
143+ pluralStringResource(R.plurals.search_results_count, state.results.size, state.results.size),
134144 style = MaterialTheme.typography.labelMedium,
135145 color = MaterialTheme.colorScheme.onSurfaceVariant,
136146 modifier = Modifier.padding(top = 8.dp),
@@ -162,7 +172,7 @@ fun GlobalSearchScreen(
162172
163173@Composable
164174private fun SearchResultCard(hit: SearchHit, query: String, onClick: () -> Unit) {
165− Card(modifier = Modifier.fillMaxWidth().clickable { onClick() }) {
175+ Card(modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) { onClick() }) {
166176 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
167177 Text(
168178 hit.session.displayTitle,
app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt+30−7
@@ -1,14 +1,17 @@
11package soy.iko.opencode.ui.search
22
3+import android.util.Log
34import androidx.compose.runtime.Immutable
45import androidx.lifecycle.ViewModel
56import androidx.lifecycle.viewModelScope
67import soy.iko.opencode.R
8+import soy.iko.opencode.data.model.MessageWithParts
79import soy.iko.opencode.data.model.Session
810import soy.iko.opencode.data.model.TextPart
911import soy.iko.opencode.data.network.NetworkConfig
1012import soy.iko.opencode.di.AppContainer
1113import soy.iko.opencode.util.runCatchingCancellable
14+import soy.iko.opencode.util.safeExceptionSummary
1215import kotlinx.coroutines.Job
1316import kotlinx.coroutines.coroutineScope
1417import kotlinx.coroutines.delay
@@ -19,6 +22,7 @@ import kotlinx.coroutines.flow.update
1922import kotlinx.coroutines.launch
2023import kotlinx.coroutines.sync.Semaphore
2124import kotlinx.coroutines.sync.withPermit
25+import java.util.concurrent.ConcurrentHashMap
2226
2327/** One search result: the [session] that matched and a [snippet] of the matching text. */
2428@Immutable
@@ -51,6 +55,14 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
5155
5256 private var searchJob: Job? = null
5357
58+ // In-memory caches for the ViewModel's lifetime so a growing/re-typed query re-filters
59+ // locally instead of re-downloading every session's history on each debounced keystroke.
60+ // Only successful fetches are cached (a failed one isn't, so a later query retries it); an
61+ // explicit retry() clears both to force a fresh fetch. ConcurrentHashMap because the
62+ // per-session fetches run concurrently under the semaphore.
63+ @Volatile private var sessionsCache: List<Session>? = null
64+ private val messageCache = ConcurrentHashMap<String, List<MessageWithParts>>()
65+
5466 fun setQuery(query: String) {
5567 _state.update { it.copy(query = query) }
5668 searchJob?.cancel()
@@ -68,10 +80,13 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
6880 }
6981 }
7082
71− /** Re-run the current query immediately (no debounce), used by the error-state Retry. */
83+ /** Re-run the current query immediately (no debounce), used by the error-state Retry. Clears
84+ * the caches so the retry actually refetches (the prior attempt may have failed). */
7285 fun retry() {
7386 val trimmed = _state.value.query.trim()
7487 if (trimmed.length < NetworkConfig.minSearchQueryLength) return
88+ sessionsCache = null
89+ messageCache.clear()
7590 searchJob?.cancel()
7691 _state.update { it.copy(searching = true, error = null) }
7792 searchJob = viewModelScope.launch { runSearch(trimmed) }
@@ -84,10 +99,11 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
8499 return
85100 }
86101 _state.update { it.copy(searching = true, error = null) }
87− val sessions = runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
88− _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
89− return
90− }
102+ val sessions = sessionsCache
103+ ?: runCatchingCancellable { conn.repository.listSessions() }.getOrElse {
104+ _state.update { s -> s.copy(searching = false, error = container.friendlyError(it)) }
105+ return
106+ }.also { sessionsCache = it }
91107 val toSearch = sessions.take(NetworkConfig.maxSearchSessions)
92108 val truncated = sessions.size > toSearch.size
93109 val hits = java.util.Collections.synchronizedMap(HashMap<String, SearchHit>())
@@ -96,8 +112,15 @@ class GlobalSearchViewModel(private val container: AppContainer) : ViewModel() {
96112 toSearch.forEach { session ->
97113 launch {
98114 semaphore.withPermit {
99− val messages = runCatchingCancellable { conn.api.listMessages(session.id) }
100− .getOrDefault(emptyList())
115+ // Reuse cached history when present so re-typing a query never re-fetches;
116+ // only sessions not yet cached hit the network.
117+ val messages = messageCache[session.id]
118+ ?: runCatchingCancellable { conn.api.listMessages(session.id) }
119+ .onSuccess { messageCache[session.id] = it }
120+ .getOrElse {
121+ Log.w("GlobalSearch", "listMessages failed: " + safeExceptionSummary(it))
122+ emptyList()
123+ }
101124 val snippet = matchSnippet(messages, query)
102125 ?: session.displayTitle.takeIf { it.contains(query, ignoreCase = true) }
103126 if (snippet != null) hits[session.id] = SearchHit(session, snippet)
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+22−3
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
99import androidx.compose.foundation.layout.Row
1010import androidx.compose.foundation.layout.fillMaxSize
1111import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.heightIn
1213import androidx.compose.foundation.layout.imePadding
1314import androidx.compose.foundation.layout.padding
1415import androidx.compose.foundation.layout.size
@@ -39,6 +40,7 @@ import androidx.compose.material3.Text
3940import androidx.compose.material3.TextButton
4041import androidx.compose.material3.TopAppBar
4142import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
4244import androidx.compose.runtime.getValue
4345import androidx.compose.runtime.mutableStateOf
4446import androidx.compose.runtime.remember
@@ -46,8 +48,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
4648import androidx.compose.runtime.setValue
4749import androidx.compose.ui.Alignment
4850import androidx.compose.ui.Modifier
51+import androidx.compose.ui.focus.FocusRequester
52+import androidx.compose.ui.focus.focusRequester
4953import androidx.compose.ui.platform.LocalContext
54+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
5055import androidx.compose.ui.platform.testTag
56+import androidx.compose.ui.semantics.Role
5157import androidx.compose.ui.semantics.contentDescription
5258import androidx.compose.ui.semantics.semantics
5359import androidx.compose.ui.text.input.ImeAction
@@ -160,6 +166,16 @@ private fun ServerEditForm(
160166 // isValidUrl parses with OkHttp too; memoize on baseUrl so it runs once per edit instead
161167 // of re-parsing at each isError/supportingText call site on every keystroke's recomposition.
162168 val urlValid = remember(state.baseUrl) { isValidUrl(state.baseUrl) }
169+ // Focus the Base URL field (and raise the keyboard) on a fresh Add-server form so the
170+ // user can start typing the required field immediately.
171+ val baseUrlFocus = remember { FocusRequester() }
172+ val keyboard = LocalSoftwareKeyboardController.current
173+ LaunchedEffect(Unit) {
174+ if (state.isNew && state.baseUrl.isBlank()) {
175+ baseUrlFocus.requestFocus()
176+ keyboard?.show()
177+ }
178+ }
163179 Column(
164180 modifier = Modifier
165181 .padding(padding)
@@ -169,7 +185,8 @@ private fun ServerEditForm(
169185 .verticalScroll(rememberScrollState()),
170186 verticalArrangement = Arrangement.spacedBy(12.dp),
171187 ) {
172− // Base URL is the only required field, so it leads the form and takes focus.
188+ // Base URL is the only required field, so it leads the form and takes focus on a
189+ // fresh add form (via baseUrlFocus above).
173190 OutlinedTextField(
174191 value = state.baseUrl,
175192 onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
@@ -195,7 +212,7 @@ private fun ServerEditForm(
195212 }
196213 },
197214 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
198− modifier = Modifier.fillMaxWidth().testTag("server_url"),
215+ modifier = Modifier.fillMaxWidth().focusRequester(baseUrlFocus).testTag("server_url"),
199216 )
200217 // Auto-expand LAN discovery only on a fresh form (no URL yet) so tapping a found
201218 // server is the primary path; on edit the populated URL keeps it (and its multicast)
@@ -460,8 +477,10 @@ private fun DiscoverySection(onPick: (String) -> Unit, initiallyExpanded: Boolea
460477 Column(
461478 modifier = Modifier
462479 .fillMaxWidth()
463− .clickable { onPick(server.baseUrl) }
480+ .heightIn(min = 48.dp)
481+ .clickable(role = Role.Button) { onPick(server.baseUrl) }
464482 .padding(vertical = 8.dp),
483+ verticalArrangement = Arrangement.Center,
465484 ) {
466485 Text(server.name, style = MaterialTheme.typography.bodyMedium)
467486 Text(
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+8−2
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.Delete
2020import androidx.compose.material.icons.filled.Dns
2121import androidx.compose.material.icons.filled.MoreVert
2222import androidx.compose.material3.AlertDialog
23+import androidx.compose.material3.Button
2324import androidx.compose.material3.Card
2425import androidx.compose.material3.CircularProgressIndicator
2526import androidx.compose.material3.DropdownMenu
@@ -173,7 +174,12 @@ fun ServerListScreen(
173174 // so the list isn't double-padded at the top.
174175 Column(modifier = Modifier.fillMaxSize().padding(top = padding.calculateTopPadding())) {
175176 if (reconnecting && profiles.isNotEmpty()) {
176− LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
177+ val reconnectingLabel = stringResource(R.string.reconnecting)
178+ LinearProgressIndicator(
179+ modifier = Modifier
180+ .fillMaxWidth()
181+ .semantics { contentDescription = reconnectingLabel },
182+ )
177183 }
178184 if (profiles.isEmpty()) {
179185 EmptyServers(
@@ -460,7 +466,7 @@ private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
460466 color = MaterialTheme.colorScheme.onSurfaceVariant,
461467 )
462468 Spacer(Modifier.size(20.dp))
463− TextButton(onClick = onAdd) {
469+ Button(onClick = onAdd) {
464470 Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
465471 Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
466472 }
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt+9
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding
99import androidx.compose.foundation.layout.size
1010import androidx.compose.foundation.rememberScrollState
1111import androidx.compose.foundation.selection.selectable
12+import androidx.compose.foundation.text.KeyboardActions
1213import androidx.compose.foundation.text.KeyboardOptions
1314import androidx.compose.foundation.verticalScroll
1415import androidx.compose.material3.AlertDialog
@@ -28,6 +29,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
2829import androidx.compose.runtime.setValue
2930import androidx.compose.ui.Alignment
3031import androidx.compose.ui.Modifier
32+import androidx.compose.ui.focus.FocusRequester
33+import androidx.compose.ui.focus.focusRequester
3134import androidx.compose.ui.semantics.Role
3235import androidx.compose.ui.text.font.FontFamily
3336import androidx.compose.ui.text.input.ImeAction
@@ -166,16 +169,22 @@ fun NewSessionDialog(
166169 onSelect = { choice = DirChoice.Custom },
167170 )
168171 if (choice is DirChoice.Custom) {
172+ // Autofocus the path field the moment the Custom row is chosen so the
173+ // keyboard is ready without a second tap.
174+ val customFocus = remember { FocusRequester() }
175+ LaunchedEffect(Unit) { customFocus.requestFocus() }
169176 OutlinedTextField(
170177 value = customText,
171178 onValueChange = { customText = it },
172179 modifier = Modifier
173180 .fillMaxWidth()
181+ .focusRequester(customFocus)
174182 .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
175183 placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
176184 singleLine = true,
177185 enabled = !creating,
178186 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
187+ keyboardActions = KeyboardActions(onDone = { if (canCreate) onCreate(resolved) }),
179188 )
180189 }
181190
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+54−8
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Row
1111import androidx.compose.foundation.layout.Spacer
1212import androidx.compose.foundation.layout.fillMaxSize
1313import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.heightIn
1415import androidx.compose.foundation.layout.padding
1516import androidx.compose.foundation.layout.size
1617import androidx.compose.foundation.lazy.LazyColumn
@@ -71,13 +72,17 @@ import androidx.compose.ui.Alignment
7172import androidx.compose.ui.Modifier
7273import androidx.compose.ui.draw.alpha
7374import androidx.compose.ui.draw.clip
75+import androidx.compose.ui.focus.FocusRequester
76+import androidx.compose.ui.focus.focusRequester
7477import androidx.compose.ui.platform.LocalHapticFeedback
7578import androidx.compose.ui.platform.LocalSoftwareKeyboardController
7679import androidx.compose.ui.platform.testTag
7780import androidx.compose.ui.semantics.Role
7881import androidx.compose.ui.semantics.contentDescription
7982import androidx.compose.ui.semantics.semantics
83+import androidx.compose.ui.text.TextRange
8084import androidx.compose.ui.text.input.ImeAction
85+import androidx.compose.ui.text.input.TextFieldValue
8186import androidx.compose.ui.text.style.TextOverflow
8287import androidx.compose.ui.unit.dp
8388import androidx.compose.ui.res.stringResource
@@ -113,6 +118,9 @@ fun SessionListScreen(
113118 onOpenSettings: () -> Unit,
114119 onAddServer: () -> Unit,
115120 onOpenSearch: () -> Unit = {},
121+ // Incremented by a host (the two-pane empty-detail pane) to open the new-session
122+ // directory picker, so that pane shares this screen's dialog instead of bypassing it.
123+ externalNewSessionTrigger: Int = 0,
116124 selectedSessionId: String? = null,
117125) {
118126 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
@@ -145,6 +153,11 @@ fun SessionListScreen(
145153 val openNewSession = {
146154 if (!creating) { vm.loadDirectoryOptions(); showNewSessionDialog = true }
147155 }
156+ // A host bumps externalNewSessionTrigger to open this exact directory-picker dialog
157+ // (e.g. the two-pane detail pane's "New session" button), reusing one dialog path.
158+ LaunchedEffect(externalNewSessionTrigger) {
159+ if (externalNewSessionTrigger > 0) openNewSession()
160+ }
148161 var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
149162 var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
150163 var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
@@ -161,6 +174,13 @@ fun SessionListScreen(
161174 BackHandler(enabled = showServerMenu) { showServerMenu = false }
162175 BackHandler(enabled = showSortMenu) { showSortMenu = false }
163176
177+ // Dismiss the server switcher once an in-flight switch resolves (switchingId returns to
178+ // null), so its per-row spinner stays visible for the whole switch instead of the menu
179+ // closing the instant a profile is tapped.
180+ LaunchedEffect(switchingId) {
181+ if (switchingId == null && showServerMenu) showServerMenu = false
182+ }
183+
164184 LaunchedEffect(Unit) {
165185 vm.transientErrors.collect { msg ->
166186 snackbar.showSnackbar(msg)
@@ -222,8 +242,12 @@ fun SessionListScreen(
222242 onExpand = { showServerMenu = true },
223243 onDismiss = { showServerMenu = false },
224244 onSelect = { profile ->
225− showServerMenu = false
226− vm.switchServer(profile)
245+ // Keep the switcher open so its per-row spinner (driven by
246+ // switchingId) is visible during the switch; the LaunchedEffect
247+ // above dismisses it once the switch resolves. Tapping the
248+ // already-active server is a no-op, so just close the menu.
249+ if (profile.id == connectedId) showServerMenu = false
250+ else vm.switchServer(profile)
227251 },
228252 onAddServer = {
229253 showServerMenu = false
@@ -689,14 +713,31 @@ private fun RenameSessionDialog(
689713 onConfirm: () -> Unit,
690714 onDismiss: () -> Unit,
691715) {
716+ val focusRequester = remember { FocusRequester() }
717+ // Drive the field with a TextFieldValue seeded with the whole title selected so the
718+ // dialog opens with the text highlighted for immediate overtyping; edits sync back to
719+ // the hoisted title (capped) that gates the confirm button and the Done action.
720+ var fieldValue by remember {
721+ mutableStateOf(TextFieldValue(title, selection = TextRange(0, title.length)))
722+ }
723+ LaunchedEffect(Unit) { focusRequester.requestFocus() }
692724 AlertDialog(
693725 onDismissRequest = onDismiss,
694726 title = { Text(stringResource(R.string.rename_session)) },
695727 text = {
696728 OutlinedTextField(
697− value = title,
698− onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
699− modifier = Modifier.fillMaxWidth(),
729+ value = fieldValue,
730+ onValueChange = { v ->
731+ val capped = if (v.text.length > NetworkConfig.maxSessionTitleChars) {
732+ val t = v.text.take(NetworkConfig.maxSessionTitleChars)
733+ TextFieldValue(t, selection = TextRange(t.length))
734+ } else {
735+ v
736+ }
737+ fieldValue = capped
738+ onTitleChange(capped.text)
739+ },
740+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
700741 placeholder = { Text(stringResource(R.string.session_title_hint)) },
701742 label = { Text(stringResource(R.string.rename_session)) },
702743 singleLine = true,
@@ -742,7 +783,10 @@ private fun SessionCard(
742783 // Mute an archived row (unless it's the open one) so it reads as backgrounded
743784 // when "show archived" surfaces it alongside active sessions.
744785 .then(if (isArchived && !isSelected) Modifier.alpha(0.6f) else Modifier)
745− .clickable { onClick() },
786+ // Merge the row's text into one semantics node so TalkBack reads it as a single
787+ // item; the overflow menu button stays its own actionable node.
788+ .semantics(mergeDescendants = true) {}
789+ .clickable(role = Role.Button) { onClick() },
746790 colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
747791 border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
748792 ) {
@@ -778,7 +822,8 @@ private fun SessionCard(
778822 contentAlignment = Alignment.Center,
779823 ) {
780824 Text(
781− unreadCount.toString(),
825+ if (unreadCount > 99) stringResource(R.string.count_overflow)
826+ else unreadCount.toString(),
782827 style = MaterialTheme.typography.labelSmall,
783828 color = MaterialTheme.colorScheme.onPrimary,
784829 )
@@ -823,7 +868,7 @@ private fun SessionCard(
823868 verticalAlignment = Alignment.CenterVertically,
824869 modifier = Modifier
825870 .padding(top = 2.dp)
826− .semantics { contentDescription = dirDesc },
871+ .semantics(mergeDescendants = true) { contentDescription = dirDesc },
827872 ) {
828873 Icon(
829874 Icons.Filled.Folder,
@@ -1033,6 +1078,7 @@ private fun ServerSwitcherMenu(
10331078 Box {
10341079 Row(
10351080 modifier = Modifier
1081+ .heightIn(min = 48.dp)
10361082 .clickable(role = Role.Button) { onExpand() },
10371083 verticalAlignment = Alignment.CenterVertically,
10381084 ) {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+5−1
@@ -754,7 +754,11 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
754754 val previousProfile = container.activeConnection.value?.profile
755755 val savedUnread = container.unread.value
756756 _switchingId.value = profile.id
757− _state.update { it.copy(loading = true, error = null) }
757+ // Don't flip `loading` here: that would blank the currently-populated list behind a
758+ // full-screen spinner. The switch feedback is the per-row spinner in the server
759+ // switcher (driven by switchingId); the existing list stays visible until refresh()
760+ // replaces it once the new connection lands.
761+ _state.update { it.copy(error = null) }
758762 viewModelScope.launch {
759763 val result = runCatchingCancellable {
760764 val conn = container.connect(profile)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+39−11
@@ -53,6 +53,10 @@ fun TwoPaneSessionChat(
5353 modifier: Modifier = Modifier,
5454) {
5555 var selected by rememberSaveable { mutableStateOf<String?>(null) }
56+ // Bumped by the empty-detail "New session" button to open the left pane's new-session
57+ // directory picker (the same dialog the FAB uses) instead of creating a directory-less
58+ // session; the created session then opens into the detail pane via onOpenSession.
59+ var newSessionTrigger by remember { mutableStateOf(0) }
5660 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
5761 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
5862 val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
@@ -101,12 +105,24 @@ fun TwoPaneSessionChat(
101105 // the latch is set the moment any of those wins, it never re-selects after a user clears
102106 // the pane (BackHandler) or a server switch nulls `selected`.
103107 var autoSelected by remember { mutableStateOf(false) }
104− LaunchedEffect(sessionListState.loading, sessionListState.sessions, pendingOpenSession, selected) {
108+ LaunchedEffect(
109+ sessionListState.loading,
110+ sessionListState.sessions,
111+ sessionListState.archivedIds,
112+ sessionListState.showArchived,
113+ pendingOpenSession,
114+ selected,
115+ ) {
105116 if (autoSelected || sessionListState.loading) return@LaunchedEffect
106117 // A pending deep-link or an existing selection takes precedence; yield the pane to it.
107118 if (pendingOpenSession != null || selected != null) { autoSelected = true; return@LaunchedEffect }
108− // Pick by recency regardless of the list's current sort mode.
109− val id = mostRecentSessionId(sessionListState.sessions) ?: return@LaunchedEffect
119+ // Pick by recency regardless of the list's current sort mode, but only among
120+ // sessions visible under the current archive filter so we never open a hidden row.
121+ val id = mostRecentSessionId(
122+ sessionListState.sessions,
123+ sessionListState.archivedIds,
124+ sessionListState.showArchived,
125+ ) ?: return@LaunchedEffect
110126 selected = id
111127 autoSelected = true
112128 }
@@ -140,6 +156,7 @@ fun TwoPaneSessionChat(
140156 onOpenSettings = onOpenSettings,
141157 onAddServer = onAddServer,
142158 onOpenSearch = onOpenSearch,
159+ externalNewSessionTrigger = newSessionTrigger,
143160 selectedSessionId = selected,
144161 )
145162 }
@@ -151,11 +168,10 @@ fun TwoPaneSessionChat(
151168 Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
152169 val sessionId = selected
153170 if (sessionId == null) {
154− // Reuse the same VM create path the session-list FAB uses; open the new
155− // session straight into the detail pane.
156− EmptyDetail(onNewSession = {
157− sessionListVm.createSession(directory = null) { id -> selected = id }
158− })
171+ // Route through the same new-session directory picker the left-pane FAB
172+ // opens (rather than creating a directory-less session here); the created
173+ // session opens into the detail pane via the shared onOpenSession callback.
174+ EmptyDetail(onNewSession = { newSessionTrigger++ })
159175 } else {
160176 BackHandler { selected = null }
161177 // key() on the session id so switching conversations in two-pane mode
@@ -179,9 +195,21 @@ fun TwoPaneSessionChat(
179195 }
180196}
181197
182−/** Id of the most recently updated (or created) session, or null when the list is empty. */
183−private fun mostRecentSessionId(sessions: List<Session>): String? =
184− sessions.maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }?.id
198+/**
199+ * Id of the most recently updated (or created) session that is *visible* under the current
200+ * archive filter (archived sessions excluded unless [showArchived]), or null when none
201+ * qualify. Auto-selecting a hidden/archived session would open the detail pane on a row with
202+ * no corresponding highlighted entry in the list.
203+ */
204+private fun mostRecentSessionId(
205+ sessions: List<Session>,
206+ archivedIds: Set<String>,
207+ showArchived: Boolean,
208+): String? =
209+ sessions
210+ .filter { showArchived || it.id !in archivedIds }
211+ .maxByOrNull { it.time?.updated ?: it.time?.created ?: 0L }
212+ ?.id
185213
186214/** Whether a session with [id] is present in [sessions]. */
187215private fun containsSession(sessions: List<Session>, id: String): Boolean =
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+11−1
@@ -59,6 +59,7 @@ import kotlinx.coroutines.launch
5959import kotlinx.coroutines.withContext
6060import soy.iko.opencode.util.runCatchingCancellable
6161import soy.iko.opencode.ui.components.AppTopBar
62+import soy.iko.opencode.ui.components.copyToClipboard
6263import soy.iko.opencode.ui.components.showToast
6364import androidx.compose.ui.Alignment
6465import androidx.compose.ui.Modifier
@@ -66,6 +67,7 @@ import androidx.compose.ui.draw.clip
6667import androidx.compose.ui.platform.LocalContext
6768import androidx.compose.ui.platform.LocalHapticFeedback
6869import androidx.compose.ui.res.stringResource
70+import androidx.compose.ui.text.font.FontFamily
6971import androidx.compose.ui.semantics.Role
7072import androidx.compose.ui.semantics.contentDescription
7173import androidx.compose.ui.semantics.heading
@@ -311,6 +313,14 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
311313 },
312314 dismissButton = {
313315 Row {
316+ TextButton(
317+ // Disable Copy until the report has loaded so it can't copy an empty body.
318+ enabled = content != null,
319+ onClick = {
320+ copyToClipboard(context, context.getString(R.string.crash_report), content.orEmpty())
321+ },
322+ ) { Text(stringResource(R.string.copy)) }
323+ Spacer(Modifier.size(8.dp))
314324 TextButton(
315325 // Disable Share until the report has loaded — otherwise tapping it
316326 // while the async read is still in flight (content == null) shares an
@@ -351,7 +361,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
351361 ) {
352362 Text(
353363 content,
354− style = MaterialTheme.typography.bodySmall,
364+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
355365 modifier = Modifier.fillMaxWidth(),
356366 )
357367 }
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+24−10
@@ -207,6 +207,9 @@ fun SettingsScreen(
207207 ThemeRow(
208208 mode = mode,
209209 selected = s.themeMode == mode,
210+ // When Material You is active the static Tokyo Night swatches would be a
211+ // lie, so ThemeRow hides them and notes that system colors are in use.
212+ usingSystemColors = s.dynamicColor && dynamicColorAvailable,
210213 onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
211214 )
212215 }
@@ -646,7 +649,7 @@ private fun ChatTextSizeControl(scale: Float, onScaleChange: (Float) -> Unit) {
646649}
647650
648651@Composable
649−private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
652+private fun ThemeRow(mode: ThemeMode, selected: Boolean, usingSystemColors: Boolean, onSelect: () -> Unit) {
650653 // Preview swatches so the user can see what each mode looks like before selecting,
651654 // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
652655 // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
@@ -676,15 +679,26 @@ private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
676679 style = MaterialTheme.typography.bodyLarge,
677680 modifier = Modifier.weight(1f),
678681 )
679− // Three small color dots previewing the palette for this mode.
680− Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
681− swatches.forEach { color ->
682− Box(
683− modifier = Modifier
684− .size(14.dp)
685− .clip(CircleShape)
686− .background(color),
687− )
682+ if (usingSystemColors) {
683+ // Dynamic color (Material You) overrides the static palettes, so the dots would
684+ // misrepresent the actual colors — note that system colors are in use instead.
685+ Text(
686+ stringResource(R.string.theme_using_system_colors),
687+ style = MaterialTheme.typography.labelSmall,
688+ color = MaterialTheme.colorScheme.onSurfaceVariant,
689+ textAlign = TextAlign.End,
690+ )
691+ } else {
692+ // Three small color dots previewing the palette for this mode.
693+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
694+ swatches.forEach { color ->
695+ Box(
696+ modifier = Modifier
697+ .size(14.dp)
698+ .clip(CircleShape)
699+ .background(color),
700+ )
701+ }
688702 }
689703 }
690704 }
app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt+22−7
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.usage
22
33import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
45import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
@@ -33,7 +34,9 @@ import androidx.compose.runtime.getValue
3334import androidx.compose.ui.Alignment
3435import androidx.compose.ui.Modifier
3536import androidx.compose.ui.draw.clip
37+import androidx.compose.ui.res.pluralStringResource
3638import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.semantics.Role
3740import androidx.compose.ui.semantics.contentDescription
3841import androidx.compose.ui.semantics.semantics
3942import androidx.compose.ui.text.style.TextOverflow
@@ -52,7 +55,7 @@ import java.util.Locale
5255
5356@OptIn(ExperimentalMaterial3Api::class)
5457@Composable
55−fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
58+fun UsageScreen(container: AppContainer, onBack: () -> Unit, onOpenSession: (String) -> Unit = {}) {
5659 val vm: UsageViewModel = viewModel(factory = vmFactory { UsageViewModel(container) })
5760 val state by vm.state.collectAsStateWithLifecycle()
5861 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
@@ -105,7 +108,7 @@ fun UsageScreen(container: AppContainer, onBack: () -> Unit) {
105108 modifier = Modifier.align(Alignment.Center),
106109 )
107110 } else {
108− UsageContent(s.report)
111+ UsageContent(s.report, onOpenSession)
109112 }
110113 }
111114 }
@@ -121,7 +124,7 @@ private fun Centered(content: @Composable androidx.compose.foundation.layout.Col
121124}
122125
123126@Composable
124−private fun UsageContent(report: UsageReport) {
127+private fun UsageContent(report: UsageReport, onOpenSession: (String) -> Unit) {
125128 LazyColumn(
126129 modifier = Modifier.fillMaxSize(),
127130 contentPadding = PaddingValues(16.dp),
@@ -152,6 +155,7 @@ private fun UsageContent(report: UsageReport) {
152155 tokens = s.tokens,
153156 messages = s.messages,
154157 costFraction = fractionOf(s.cost, report.totalCost),
158+ onClick = { onOpenSession(s.sessionId) },
155159 )
156160 }
157161 }
@@ -199,7 +203,7 @@ private fun TotalsCard(report: UsageReport) {
199203 )
200204 Spacer(Modifier.size(8.dp))
201205 Text(
202− stringResource(R.string.usage_messages_count, report.messageCount),
206+ pluralStringResource(R.plurals.usage_messages_count, report.messageCount, report.messageCount),
203207 style = MaterialTheme.typography.bodySmall,
204208 color = MaterialTheme.colorScheme.onSurfaceVariant,
205209 )
@@ -238,13 +242,24 @@ private fun StatRow(label: String, value: String) {
238242}
239243
240244@Composable
241−private fun UsageRow(title: String, cost: Double, tokens: Tokens, messages: Int, costFraction: Float) {
242− Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
245+private fun UsageRow(
246+ title: String,
247+ cost: Double,
248+ tokens: Tokens,
249+ messages: Int,
250+ costFraction: Float,
251+ onClick: (() -> Unit)? = null,
252+) {
253+ val rowModifier = Modifier
254+ .fillMaxWidth()
255+ .let { if (onClick != null) it.clickable(role = Role.Button, onClick = onClick) else it }
256+ .padding(vertical = 6.dp)
257+ Column(modifier = rowModifier) {
243258 Row(verticalAlignment = Alignment.CenterVertically) {
244259 Column(modifier = Modifier.weight(1f)) {
245260 Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium)
246261 Text(
247− stringResource(R.string.usage_messages_count, messages) + " · " + formatInt(tokens.total),
262+ pluralStringResource(R.plurals.usage_messages_count, messages, messages) + " · " + formatInt(tokens.total),
248263 style = MaterialTheme.typography.labelSmall,
249264 color = MaterialTheme.colorScheme.onSurfaceVariant,
250265 )
app/src/main/res/values-es/strings.xml+40−3
@@ -148,7 +148,10 @@
148148 <string name="thoughts">Pensamientos</string>
149149 <string name="show_more">Mostrar más</string>
150150 <string name="show_less">Mostrar menos</string>
151− <string name="show_more_lines">Mostrar %1$d líneas más</string>
151+ <plurals name="show_more_lines">
152+ <item quantity="one">Mostrar %1$d línea más</item>
153+ <item quantity="other">Mostrar %1$d líneas más</item>
154+ </plurals>
152155 <string name="tool_completed">Completado</string>
153156 <string name="tool_error">Error</string>
154157 <string name="tool_running">En ejecución</string>
@@ -364,7 +367,10 @@
364367 <string name="search_truncated">Mostrando resultados solo de las sesiones más recientes.</string>
365368 <string name="search_all_start">Busca en tus conversaciones</string>
366369 <string name="search_all_start_hint">Escribe una palabra o frase para encontrarla en todas las sesiones.</string>
367− <string name="search_results_count">%1$d resultados</string>
370+ <plurals name="search_results_count">
371+ <item quantity="one">%1$d resultado</item>
372+ <item quantity="other">%1$d resultados</item>
373+ </plurals>
368374
369375 <!-- Plan de tareas del agente (herramienta todowrite) -->
370376 <string name="plan">Plan</string>
@@ -418,7 +424,10 @@
418424 <string name="usage_this_session">Esta sesión</string>
419425 <string name="usage_all_sessions">Todas las sesiones</string>
420426 <string name="usage_empty">Aún no hay uso registrado</string>
421− <string name="usage_messages_count">%1$d mensajes del asistente</string>
427+ <plurals name="usage_messages_count">
428+ <item quantity="one">%1$d mensaje del asistente</item>
429+ <item quantity="other">%1$d mensajes del asistente</item>
430+ </plurals>
422431 <string name="usage_loading">Calculando uso…</string>
423432 <string name="usage_failed">No se pudo cargar el uso</string>
424433
@@ -501,4 +510,32 @@
501510 <string name="import_confirm_replace">Reemplazar</string>
502511 <string name="export_passwords_title">¿Exportar contraseñas en texto plano?</string>
503512 <string name="export_passwords_warning">Esta copia de seguridad contendrá las contraseñas de tus servidores en texto plano. Cualquiera que pueda leer el archivo podrá verlas.</string>
513+
514+ <!-- UI/UX batch: permisos, confirmaciones, indicaciones de búsqueda, accesibilidad -->
515+ <string name="permission_progress">%1$d de %2$d</string>
516+ <string name="action_continue">Continuar</string>
517+ <string name="summarize_confirm_title">¿Resumir la conversación?</string>
518+ <string name="summarize_confirm_text">Los mensajes anteriores se compactan en un resumen para liberar contexto. Esto no se puede deshacer aquí.</string>
519+ <string name="init_confirm_title">¿Generar AGENTS.md?</string>
520+ <string name="init_confirm_text">El agente creará o sobrescribirá AGENTS.md en el directorio del proyecto. Un archivo existente puede ser reemplazado.</string>
521+ <string name="composer_hint_running">Mensaje — se enviará al terminar la ejecución actual</string>
522+ <string name="clip_label_code">Código</string>
523+ <string name="clip_label_diff">Diferencias</string>
524+ <string name="clip_label_reasoning">Razonamiento</string>
525+ <string name="clip_label_output">Salida</string>
526+ <string name="clip_label_path">Ruta</string>
527+ <string name="clip_label_message">Mensaje</string>
528+ <string name="clip_label_share">Enlace para compartir</string>
529+ <string name="search_contents_hint">Escribe para buscar en el contenido del archivo</string>
530+ <string name="search_symbols_hint">Escribe para buscar símbolos</string>
531+ <string name="search_keep_typing">Sigue escribiendo para buscar…</string>
532+ <string name="theme_using_system_colors">Usando los colores del sistema (Material You)</string>
533+ <string name="tap_to_retry">Toca para reintentar</string>
534+ <string name="count_overflow">99+</string>
535+ <string name="mcp_enabled">Activado</string>
536+ <string name="file_truncated">Archivo grande — mostrando las primeras %1$d líneas.</string>
537+ <plurals name="file_search_results">
538+ <item quantity="one">%1$d resultado</item>
539+ <item quantity="other">%1$d resultados</item>
540+ </plurals>
504541</resources>
app/src/main/res/values/strings.xml+40−3
@@ -148,7 +148,10 @@
148148 <string name="thoughts">Thoughts</string>
149149 <string name="show_more">Show more</string>
150150 <string name="show_less">Show less</string>
151− <string name="show_more_lines">Show %1$d more lines</string>
151+ <plurals name="show_more_lines">
152+ <item quantity="one">Show %1$d more line</item>
153+ <item quantity="other">Show %1$d more lines</item>
154+ </plurals>
152155 <string name="tool_completed">Completed</string>
153156 <string name="tool_error">Error</string>
154157 <string name="tool_running">Running</string>
@@ -345,7 +348,10 @@
345348 <string name="search_truncated">Showing results from the most recent sessions only.</string>
346349 <string name="search_all_start">Search your conversations</string>
347350 <string name="search_all_start_hint">Type a word or phrase to find it across every session.</string>
348− <string name="search_results_count">%1$d results</string>
351+ <plurals name="search_results_count">
352+ <item quantity="one">%1$d result</item>
353+ <item quantity="other">%1$d results</item>
354+ </plurals>
349355
350356 <!-- Sharing / summarize / init / shell -->
351357 <string name="create_share_link">Create share link</string>
@@ -420,7 +426,10 @@
420426 <string name="usage_this_session">This session</string>
421427 <string name="usage_all_sessions">All sessions</string>
422428 <string name="usage_empty">No usage recorded yet</string>
423− <string name="usage_messages_count">%1$d assistant messages</string>
429+ <plurals name="usage_messages_count">
430+ <item quantity="one">%1$d assistant message</item>
431+ <item quantity="other">%1$d assistant messages</item>
432+ </plurals>
424433 <string name="usage_loading">Calculating usage…</string>
425434 <string name="usage_failed">Couldn\'t load usage</string>
426435
@@ -503,4 +512,32 @@
503512 <string name="import_confirm_replace">Replace</string>
504513 <string name="export_passwords_title">Export plaintext passwords?</string>
505514 <string name="export_passwords_warning">This backup will contain your server passwords in plain text. Anyone who can read the file can read them.</string>
515+
516+ <!-- UI/UX batch: permissions, confirmations, search prompts, a11y -->
517+ <string name="permission_progress">%1$d of %2$d</string>
518+ <string name="action_continue">Continue</string>
519+ <string name="summarize_confirm_title">Summarize conversation?</string>
520+ <string name="summarize_confirm_text">Earlier messages are compacted into a summary to free up context. This can\'t be undone here.</string>
521+ <string name="init_confirm_title">Generate AGENTS.md?</string>
522+ <string name="init_confirm_text">The agent will create or overwrite AGENTS.md in the project directory. An existing file may be replaced.</string>
523+ <string name="composer_hint_running">Message — sends after the current run</string>
524+ <string name="clip_label_code">Code</string>
525+ <string name="clip_label_diff">Diff</string>
526+ <string name="clip_label_reasoning">Reasoning</string>
527+ <string name="clip_label_output">Output</string>
528+ <string name="clip_label_path">Path</string>
529+ <string name="clip_label_message">Message</string>
530+ <string name="clip_label_share">Share link</string>
531+ <string name="search_contents_hint">Type to search file contents</string>
532+ <string name="search_symbols_hint">Type to search symbols</string>
533+ <string name="search_keep_typing">Keep typing to search…</string>
534+ <string name="theme_using_system_colors">Using system colors (Material You)</string>
535+ <string name="tap_to_retry">Tap to retry</string>
536+ <string name="count_overflow">99+</string>
537+ <string name="mcp_enabled">Enabled</string>
538+ <string name="file_truncated">Large file — showing the first %1$d lines.</string>
539+ <plurals name="file_search_results">
540+ <item quantity="one">%1$d result</item>
541+ <item quantity="other">%1$d results</item>
542+ </plurals>
506543</resources>