git.iko.soy/opencode-android
on master help sign in
Polish app UX with draft retention, confirmations, smart scroll, copy support, session previews, and richer settings
commite9b2cc33ada2806af2d3de6e5dce096cb517c67e authoriko <mail@iko.soy> dateMon 29 Jun 2026, 09:30 (3 months ago) parent0605447
21 files changed+886−174
README.md+11−1 app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+11−1 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+3 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−16 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+34−5 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+4−4 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+72−19 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−6 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+40−23 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+21−1 app/src/main/java/soy/iko/opencode/ui/components/StubScreen.kt−41 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+22 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+43−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+61−7 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+83−12 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+138−26 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+39−5 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+66−4 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12−1 app/src/test/java/soy/iko/opencode/UxTest.kt+91
README.md+11−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M6 complete:
9+Milestones M0–M7 complete:
1010
1111- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
1212 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -22,6 +22,16 @@ Milestones M0–M6 complete:
2222 the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
2323 command template with its configured agent; **multi-server quick-switch** — the session list title
2424 is a dropdown that lists all saved servers and switches instantly without navigating back.
25+- **M7:** **UX polish pass.** Draft retention (failed sends restore your text with a Retry action);
26+ destructive-action confirmations (delete session / remove server); the app bar now shows the real
27+ session title; smart auto-scroll that won't fight a reader who scrolled up, with a jump-to-latest
28+ FAB; long-press to copy assistant markdown, plus copy buttons on tool output and diffs; expandable
29+ tool output (Show more / Show less) instead of a hard truncation; **session list** with relative
30+ timestamps, last-message previews, and a search filter; tappable **file-browser breadcrumbs**;
31+ a "connected" marker on the active server; permission dialog with clear action hierarchy;
32+ dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
33+ polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34+ manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
2535
2636## Architecture
2737
app/src/main/java/soy/iko/opencode/MainActivity.kt+3−1
@@ -19,12 +19,14 @@ class MainActivity : ComponentActivity() {
1919 setContent {
2020 val themeMode by container.settingsStore.themeMode
2121 .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
22+ val dynamicColor by container.settingsStore.dynamicColor
23+ .collectAsStateWithLifecycle(initialValue = true)
2224 val dark = when (themeMode) {
2325 ThemeMode.SYSTEM -> isSystemInDarkTheme()
2426 ThemeMode.LIGHT -> false
2527 ThemeMode.DARK -> true
2628 }
27− OpencodeTheme(darkTheme = dark) {
29+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
2830 OpencodeAppUi(container = container)
2931 }
3032 }
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+11−1
@@ -1,6 +1,7 @@
11package soy.iko.opencode.data.repo
22
33import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
45import androidx.datastore.preferences.core.edit
56import androidx.datastore.preferences.core.stringPreferencesKey
67import androidx.datastore.preferences.preferencesDataStore
@@ -11,18 +12,27 @@ enum class ThemeMode { SYSTEM, LIGHT, DARK }
1112
1213private val Context.settingsDataStore by preferencesDataStore(name = "settings")
1314
14−/** Small app-preferences store (theme mode for now; default agent etc. can join later). */
15+/** App-preferences store: theme mode, dynamic color (Material You). */
1516class SettingsStore(context: Context) {
1617
1718 private val appContext = context.applicationContext
1819 private val themeKey = stringPreferencesKey("theme_mode")
20+ private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
1921
2022 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
2123 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
2224 .getOrDefault(ThemeMode.SYSTEM)
2325 }
2426
27+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
28+ prefs[dynamicColorKey] ?: true
29+ }
30+
2531 suspend fun setThemeMode(mode: ThemeMode) {
2632 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
2733 }
34+
35+ suspend fun setDynamicColor(enabled: Boolean) {
36+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
37+ }
2838}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+3
@@ -91,6 +91,9 @@ fun OpencodeApp(container: AppContainer) {
9191 SettingsScreen(
9292 container = container,
9393 onBack = { navController.popBackStack() },
94+ onManageServers = {
95+ navController.popBackStack(Routes.SERVERS, inclusive = false)
96+ },
9497 )
9598 }
9699 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+91−16
@@ -1,11 +1,14 @@
11package soy.iko.opencode.ui.chat
22
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
35import androidx.compose.foundation.clickable
46import androidx.compose.foundation.layout.Arrangement
57import androidx.compose.foundation.layout.Box
68import androidx.compose.foundation.layout.Column
79import androidx.compose.foundation.layout.PaddingValues
810import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
912import androidx.compose.foundation.layout.fillMaxSize
1013import androidx.compose.foundation.layout.fillMaxWidth
1114import androidx.compose.foundation.layout.padding
@@ -16,12 +19,16 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1619import androidx.compose.material.icons.Icons
1720import androidx.compose.material.icons.automirrored.filled.ArrowBack
1821import androidx.compose.material.icons.automirrored.filled.Send
22+import androidx.compose.material.icons.filled.KeyboardArrowDown
1923import androidx.compose.material.icons.filled.SmartToy
2024import androidx.compose.material.icons.filled.Stop
2125import androidx.compose.material.icons.filled.Terminal
2226import androidx.compose.material.icons.filled.Tune
27+import androidx.compose.material3.AlertDialog
2328import androidx.compose.material3.CircularProgressIndicator
2429import androidx.compose.material3.ExperimentalMaterial3Api
30+import androidx.compose.material3.ExtendedFloatingActionButton
31+import androidx.compose.material3.FloatingActionButton
2532import androidx.compose.material3.Icon
2633import androidx.compose.material3.IconButton
2734import androidx.compose.material3.MaterialTheme
@@ -31,18 +38,24 @@ import androidx.compose.material3.SnackbarHost
3138import androidx.compose.material3.SnackbarHostState
3239import androidx.compose.material3.Surface
3340import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
3442import androidx.compose.material3.TopAppBar
3543import androidx.compose.runtime.Composable
3644import androidx.compose.runtime.LaunchedEffect
45+import androidx.compose.runtime.derivedStateOf
3746import androidx.compose.runtime.getValue
3847import androidx.compose.runtime.mutableStateOf
3948import androidx.compose.runtime.remember
49+import androidx.compose.runtime.rememberCoroutineScope
4050import androidx.compose.runtime.setValue
4151import androidx.compose.ui.Alignment
4252import androidx.compose.ui.Modifier
53+import androidx.compose.ui.platform.LocalHapticFeedback
4354import androidx.compose.ui.unit.dp
4455import androidx.lifecycle.compose.collectAsStateWithLifecycle
4556import androidx.lifecycle.viewmodel.compose.viewModel
57+import kotlinx.coroutines.launch
58+import soy.iko.opencode.data.network.EventStreamClient
4659import soy.iko.opencode.di.AppContainer
4760import soy.iko.opencode.ui.vmFactory
4861
@@ -64,6 +77,10 @@ fun ChatScreen(
6477 val agents by vm.agents.collectAsStateWithLifecycle()
6578 val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
6679 val commands by vm.commands.collectAsStateWithLifecycle()
80+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
81+ val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
82+ val haptics = LocalHapticFeedback.current
83+ val scope = rememberCoroutineScope()
6784
6885 val listState = rememberLazyListState()
6986 val snackbar = remember { SnackbarHostState() }
@@ -71,24 +88,49 @@ fun ChatScreen(
7188 var showModelPicker by remember { mutableStateOf(false) }
7289 var showAgentPicker by remember { mutableStateOf(false) }
7390 var showCommandPicker by remember { mutableStateOf(false) }
91+ var showExitConfirm by remember { mutableStateOf(false) }
7492
75− // Keep the newest content in view as parts stream in.
93+ // Track whether the list is pinned to the bottom so streaming auto-scroll
94+ // doesn't fight a user who scrolled up to read earlier output.
95+ val isPinnedToBottom by remember {
96+ derivedStateOf {
97+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
98+ lastVisible >= messages.lastIndex || messages.isEmpty()
99+ }
100+ }
101+
102+ // Keep the newest content in view as parts stream in — only if already at the bottom.
76103 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
77− if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
104+ if (messages.isNotEmpty() && isPinnedToBottom) {
105+ listState.animateScrollToItem(messages.lastIndex)
106+ }
78107 }
79108 LaunchedEffect(error) {
80− error?.let {
81− snackbar.showSnackbar(it)
82− vm.clearError()
109+ val msg = error ?: return@LaunchedEffect
110+ val result = if (failedDraft != null) {
111+ snackbar.showSnackbar(message = msg, actionLabel = "Retry")
112+ } else {
113+ snackbar.showSnackbar(msg)
83114 }
115+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
116+ vm.clearError()
84117 }
85118
119+ fun doSend() {
120+ if (vm.send(input)) {
121+ input = ""
122+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
123+ }
124+ }
125+
126+ BackHandler(enabled = running) { showExitConfirm = true }
127+
86128 Scaffold(
87129 topBar = {
88130 TopAppBar(
89131 title = {
90132 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
91− Text("Session")
133+ Text(sessionTitle ?: "Session")
92134 Text(
93135 buildString {
94136 append(selectedModel?.modelLabel ?: "default model")
@@ -100,7 +142,9 @@ fun ChatScreen(
100142 }
101143 },
102144 navigationIcon = {
103− IconButton(onClick = onBack) {
145+ IconButton(onClick = {
146+ if (running) showExitConfirm = true else onBack()
147+ }) {
104148 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
105149 }
106150 },
@@ -124,10 +168,7 @@ fun ChatScreen(
124168 onValueChange = { input = it },
125169 running = running,
126170 enabled = vm.connected,
127− onSend = {
128− vm.send(input)
129− input = ""
130− },
171+ onSend = ::doSend,
131172 onAbort = { vm.abort() },
132173 )
133174 },
@@ -151,7 +192,7 @@ fun ChatScreen(
151192 verticalArrangement = Arrangement.spacedBy(16.dp),
152193 ) {
153194 items(messages, key = { it.info.id }) { message ->
154− MessageBubble(message)
195+ MessageBubble(message, isRunning = running)
155196 }
156197 if (running) {
157198 item(key = "__typing") {
@@ -162,10 +203,44 @@ fun ChatScreen(
162203 }
163204 }
164205 }
206+ // Jump-to-latest affordance when the user has scrolled away during a stream.
207+ AnimatedVisibility(
208+ visible = !isPinnedToBottom && messages.isNotEmpty(),
209+ modifier = Modifier.align(Alignment.BottomEnd),
210+ ) {
211+ ExtendedFloatingActionButton(
212+ onClick = {
213+ if (messages.isNotEmpty()) {
214+ scope.launch { listState.animateScrollToItem(messages.lastIndex) }
215+ }
216+ },
217+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
218+ text = { Text("Latest") },
219+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
220+ )
221+ }
165222 }
166223 }
167224 }
168225
226+ if (showExitConfirm) {
227+ AlertDialog(
228+ onDismissRequest = { showExitConfirm = false },
229+ title = { Text("Stop and exit?") },
230+ text = { Text("The agent is still working. Exit anyway?") },
231+ confirmButton = {
232+ TextButton(onClick = {
233+ showExitConfirm = false
234+ vm.abort()
235+ onBack()
236+ }) { Text("Stop & exit") }
237+ },
238+ dismissButton = {
239+ TextButton(onClick = { showExitConfirm = false }) { Text("Stay") }
240+ },
241+ )
242+ }
243+
169244 if (showModelPicker) {
170245 ModelPickerSheet(
171246 options = models,
@@ -202,13 +277,13 @@ fun ChatScreen(
202277
203278@Composable
204279private fun ConnectionBanner(
205− state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
280+ state: EventStreamClient.ConnectionState,
206281 modifier: Modifier = Modifier,
207282) {
208283 val text = when (state) {
209− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211− soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
284+ EventStreamClient.ConnectionState.Connecting -> "Connecting…"
285+ EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
286+ EventStreamClient.ConnectionState.Connected -> null
212287 }
213288 if (text != null) {
214289 Surface(
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+34−5
@@ -10,6 +10,7 @@ import soy.iko.opencode.data.model.Permission
1010import soy.iko.opencode.data.model.PermissionReplied
1111import soy.iko.opencode.data.model.PermissionResponse
1212import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.TextPart
1314import soy.iko.opencode.data.model.defaultOption
1415import soy.iko.opencode.data.model.toOptions
1516import soy.iko.opencode.data.network.EventStreamClient
@@ -68,6 +69,13 @@ class ChatViewModel(
6869 private val _commands = MutableStateFlow<List<Command>>(emptyList())
6970 val commands: StateFlow<List<Command>> = _commands.asStateFlow()
7071
72+ private val _sessionTitle = MutableStateFlow<String?>(null)
73+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
74+
75+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
76+ private val _failedDraft = MutableStateFlow<String?>(null)
77+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
78+
7179 fun clearError() { _error.value = null }
7280
7381 fun selectModel(option: ModelOption) { _selectedModel.value = option }
@@ -111,26 +119,47 @@ class ChatViewModel(
111119 viewModelScope.launch {
112120 runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
113121 }
122+ // Resolve the human-readable session title for the app bar (non-fatal).
123+ viewModelScope.launch {
124+ runCatching { conn.repository.listSessions() }
125+ .getOrNull()
126+ ?.firstOrNull { it.id == sessionId }
127+ ?.let { _sessionTitle.value = it.displayTitle }
128+ }
114129 }
115130 }
116131
117− fun send(text: String) {
118− val conn = connection ?: return
132+ /** Sends [text]; returns true on success so the caller can clear the draft only then. */
133+ fun send(text: String): Boolean {
134+ val conn = connection ?: return false
119135 val trimmed = text.trim()
120− if (trimmed.isEmpty() || _running.value) return
136+ if (trimmed.isEmpty() || _running.value) return false
121137 _running.value = true
122138 _error.value = null
139+ _failedDraft.value = null
123140 viewModelScope.launch {
124− runCatching {
141+ val ok = runCatching {
125142 conn.repository.sendPrompt(
126143 sessionId,
127144 trimmed,
128145 model = _selectedModel.value?.ref,
129146 agent = _selectedAgent.value,
130147 )
131− }.onFailure { _error.value = it.message ?: "Failed to send" }
148+ }.isSuccess
132149 _running.value = false
150+ if (!ok) {
151+ _failedDraft.value = trimmed
152+ _error.value = "Failed to send"
153+ }
133154 }
155+ return true
156+ }
157+
158+ /** Re-send the last draft whose send failed, if any. */
159+ fun retryFailed() {
160+ val draft = _failedDraft.value ?: return
161+ _failedDraft.value = null
162+ send(draft)
134163 }
135164
136165 /** Invoke a slash-command by name via the server's /command endpoint. */
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+4−4
@@ -21,10 +21,10 @@ import soy.iko.opencode.data.model.UserMessage
2121
2222/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
2323@Composable
24−fun MessageBubble(message: MessageWithParts, modifier: Modifier = Modifier) {
24+fun MessageBubble(message: MessageWithParts, isRunning: Boolean = false, modifier: Modifier = Modifier) {
2525 when (message.info) {
2626 is UserMessage -> UserBubble(message, modifier)
27− else -> AssistantBlock(message, modifier)
27+ else -> AssistantBlock(message, isRunning, modifier)
2828 }
2929}
3030
@@ -45,7 +45,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
4545}
4646
4747@Composable
48−private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
48+private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifier: Modifier) {
4949 Column(
5050 modifier = modifier.fillMaxWidth(),
5151 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -59,7 +59,7 @@ private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
5959 )
6060 }
6161 for (part in message.parts) {
62− PartView(part, modifier = Modifier.fillMaxWidth())
62+ PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth())
6363 }
6464 if (info is AssistantMessage) {
6565 val cost = info.cost
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+72−19
@@ -12,12 +12,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
1212import androidx.compose.material.icons.Icons
1313import androidx.compose.material.icons.filled.Bolt
1414import androidx.compose.material.icons.filled.CheckCircle
15+import androidx.compose.material.icons.filled.ContentCopy
1516import androidx.compose.material.icons.filled.Error
17+import androidx.compose.material.icons.filled.ExpandLess
18+import androidx.compose.material.icons.filled.ExpandMore
1619import androidx.compose.material.icons.filled.Psychology
1720import androidx.compose.material3.CircularProgressIndicator
1821import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
1923import androidx.compose.material3.MaterialTheme
2024import androidx.compose.material3.Text
25+import androidx.compose.material3.TextButton
2126import androidx.compose.runtime.Composable
2227import androidx.compose.runtime.getValue
2328import androidx.compose.runtime.mutableStateOf
@@ -26,6 +31,7 @@ import androidx.compose.runtime.setValue
2631import androidx.compose.ui.Alignment
2732import androidx.compose.ui.Modifier
2833import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.platform.LocalContext
2935import androidx.compose.ui.text.font.FontFamily
3036import androidx.compose.ui.unit.dp
3137import soy.iko.opencode.data.model.FilePart
@@ -44,19 +50,22 @@ import soy.iko.opencode.data.model.ToolUnknown
4450import soy.iko.opencode.data.model.UnknownPart
4551import soy.iko.opencode.ui.components.DiffView
4652import soy.iko.opencode.ui.components.MarkdownText
53+import soy.iko.opencode.ui.components.copyToClipboard
4754import soy.iko.opencode.ui.components.looksLikeDiff
4855
56+private const val COLLAPSED_LIMIT = 600
57+
4958/**
5059 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
5160 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
5261 */
5362@Composable
54−fun PartView(part: Part, modifier: Modifier = Modifier) {
63+fun PartView(part: Part, isRunning: Boolean = false, modifier: Modifier = Modifier) {
5564 when (part) {
5665 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
5766 MarkdownText(part.text, modifier = modifier)
5867 }
59− is ReasoningPart -> ReasoningBlock(part.text, modifier)
68+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
6069 is ToolPart -> ToolCallView(part, modifier)
6170 is FilePart -> FileChip(part, modifier)
6271 is StepStartPart -> {} // boundary marker — nothing to draw
@@ -66,38 +75,57 @@ fun PartView(part: Part, modifier: Modifier = Modifier) {
6675}
6776
6877@Composable
69−private fun ReasoningBlock(text: String, modifier: Modifier) {
78+private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
7079 if (text.isBlank()) return
80+ val context = LocalContext.current
7181 var expanded by remember { mutableStateOf(false) }
7282 Column(modifier = modifier.fillMaxWidth()) {
7383 Row(
7484 modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
7585 verticalAlignment = Alignment.CenterVertically,
7686 ) {
77− Icon(
78− Icons.Filled.Psychology,
79− contentDescription = null,
80− modifier = Modifier.size(16.dp),
81− tint = MaterialTheme.colorScheme.onSurfaceVariant,
82− )
87+ if (streaming) {
88+ CircularProgressIndicator(
89+ Modifier.size(14.dp),
90+ strokeWidth = 2.dp,
91+ color = MaterialTheme.colorScheme.onSurfaceVariant,
92+ )
93+ } else {
94+ Icon(
95+ Icons.Filled.Psychology,
96+ contentDescription = null,
97+ modifier = Modifier.size(16.dp),
98+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
100+ }
83101 Text(
84− if (expanded) " Thinking" else " Thinking…",
102+ " ${if (streaming) "Thinking…" else "Thoughts"}",
85103 style = MaterialTheme.typography.labelMedium,
86104 color = MaterialTheme.colorScheme.onSurfaceVariant,
87105 )
88106 }
89107 AnimatedVisibility(visible = expanded) {
90− Text(
91− text,
92− style = MaterialTheme.typography.bodySmall,
93− color = MaterialTheme.colorScheme.onSurfaceVariant,
94− )
108+ Column {
109+ Text(
110+ text,
111+ style = MaterialTheme.typography.bodySmall,
112+ color = MaterialTheme.colorScheme.onSurfaceVariant,
113+ )
114+ TextButton(
115+ onClick = { copyToClipboard(context, "reasoning", text) },
116+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
117+ ) {
118+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
119+ Text(" Copy", style = MaterialTheme.typography.labelSmall)
120+ }
121+ }
95122 }
96123 }
97124}
98125
99126@Composable
100127private fun ToolCallView(part: ToolPart, modifier: Modifier) {
128+ val context = LocalContext.current
101129 Column(
102130 modifier = modifier
103131 .fillMaxWidth()
@@ -119,17 +147,42 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
119147 else -> null
120148 }
121149 if (detail != null) {
122− val truncated = detail.take(5000)
123− if (looksLikeDiff(truncated)) {
124− DiffView(truncated)
150+ val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
151+ var expanded by remember(detail) { mutableStateOf(false) }
152+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail.take(5000) else collapsed
153+ if (looksLikeDiff(display)) {
154+ DiffView(display)
125155 } else {
126156 Text(
127− truncated.take(2000),
157+ display,
128158 style = MaterialTheme.typography.bodySmall,
129159 fontFamily = FontFamily.Monospace,
130160 modifier = Modifier.padding(top = 6.dp),
131161 )
132162 }
163+ if (detail.length > COLLAPSED_LIMIT) {
164+ TextButton(
165+ onClick = { expanded = !expanded },
166+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
167+ ) {
168+ Icon(
169+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
170+ contentDescription = null,
171+ modifier = Modifier.size(16.dp),
172+ )
173+ Text(
174+ " ${if (expanded) "Show less" else "Show more"}",
175+ style = MaterialTheme.typography.labelSmall,
176+ )
177+ }
178+ }
179+ TextButton(
180+ onClick = { copyToClipboard(context, "output", detail) },
181+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
182+ ) {
183+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
184+ Text(" Copy", style = MaterialTheme.typography.labelSmall)
185+ }
133186 }
134187 }
135188}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+41−6
@@ -1,14 +1,24 @@
11package soy.iko.opencode.ui.chat
22
3+import androidx.compose.foundation.layout.Arrangement
34import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
46import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Shield
510import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.Icon
613import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.OutlinedButton
715import androidx.compose.material3.Text
816import androidx.compose.material3.TextButton
917import androidx.compose.runtime.Composable
1018import androidx.compose.ui.Modifier
19+import androidx.compose.ui.platform.LocalHapticFeedback
1120import androidx.compose.ui.text.font.FontFamily
21+import androidx.compose.ui.unit.dp
1222import soy.iko.opencode.data.model.Permission
1323import soy.iko.opencode.data.model.PermissionResponse
1424
@@ -22,32 +32,57 @@ fun PermissionDialog(
2232 permission: Permission,
2333 onRespond: (PermissionResponse) -> Unit,
2434) {
35+ val haptics = LocalHapticFeedback.current
2536 AlertDialog(
2637 onDismissRequest = { /* require an explicit choice */ },
38+ icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
2739 title = { Text(permission.title ?: "Permission requested") },
2840 text = {
29− Column {
41+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
3042 permission.type?.let {
3143 Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
3244 }
3345 permission.patternText?.let {
3446 Text(
3547 it,
36− style = MaterialTheme.typography.bodySmall,
48+ style = MaterialTheme.typography.bodyMedium,
3749 fontFamily = FontFamily.Monospace,
3850 color = MaterialTheme.colorScheme.onSurfaceVariant,
3951 )
4052 }
53+ Text(
54+ "Allow this tool to run?",
55+ style = MaterialTheme.typography.bodySmall,
56+ color = MaterialTheme.colorScheme.onSurfaceVariant,
57+ )
4158 }
4259 },
4360 confirmButton = {
44− Column(modifier = Modifier.fillMaxWidth()) {
45− TextButton(onClick = { onRespond(PermissionResponse.ONCE) }) { Text("Allow once") }
46− TextButton(onClick = { onRespond(PermissionResponse.ALWAYS) }) { Text("Always allow") }
61+ Column(
62+ modifier = Modifier.fillMaxWidth(),
63+ verticalArrangement = Arrangement.spacedBy(4.dp),
64+ ) {
65+ Button(
66+ onClick = {
67+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
68+ onRespond(PermissionResponse.ONCE)
69+ },
70+ modifier = Modifier.fillMaxWidth(),
71+ ) { Text("Allow once") }
72+ OutlinedButton(
73+ onClick = {
74+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
75+ onRespond(PermissionResponse.ALWAYS)
76+ },
77+ modifier = Modifier.fillMaxWidth(),
78+ ) { Text("Always allow") }
4779 }
4880 },
4981 dismissButton = {
50− TextButton(onClick = { onRespond(PermissionResponse.REJECT) }) {
82+ TextButton(onClick = {
83+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
84+ onRespond(PermissionResponse.REJECT)
85+ }) {
5186 Text("Reject", color = MaterialTheme.colorScheme.error)
5287 }
5388 },
app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+40−23
@@ -2,15 +2,19 @@ package soy.iko.opencode.ui.components
22
33import androidx.compose.foundation.background
44import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
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.width
10−import androidx.compose.foundation.layout.widthIn
1111import androidx.compose.foundation.rememberScrollState
1212import androidx.compose.foundation.shape.RoundedCornerShape
1313import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.ContentCopy
16+import androidx.compose.material3.Icon
17+import androidx.compose.material3.IconButton
1418import androidx.compose.material3.MaterialTheme
1519import androidx.compose.material3.Text
1620import androidx.compose.runtime.Composable
@@ -19,6 +23,7 @@ import androidx.compose.ui.Alignment
1923import androidx.compose.ui.Modifier
2024import androidx.compose.ui.draw.clip
2125import androidx.compose.ui.graphics.Color
26+import androidx.compose.ui.platform.LocalContext
2227import androidx.compose.ui.text.font.FontFamily
2328import androidx.compose.ui.unit.dp
2429
@@ -74,6 +79,7 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
7479 val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
7580 val addText = MaterialTheme.colorScheme.primary
7681 val removeText = MaterialTheme.colorScheme.error
82+ val context = LocalContext.current
7783
7884 Column(
7985 modifier = modifier
@@ -81,29 +87,40 @@ fun DiffView(diff: String, modifier: Modifier = Modifier) {
8187 .padding(vertical = 4.dp)
8288 .clip(RoundedCornerShape(8.dp))
8389 .background(MaterialTheme.colorScheme.surfaceVariant)
84− .verticalScroll(rememberScrollState())
85− .horizontalScroll(rememberScrollState())
86− .padding(10.dp),
90+ .padding(top = 2.dp, end = 4.dp),
8791 ) {
88− for (line in lines) {
89− when (line) {
90− is DiffLine.Hunk -> Text(
91− line.text,
92− style = MaterialTheme.typography.bodySmall,
93− fontFamily = FontFamily.Monospace,
94− color = MaterialTheme.colorScheme.tertiary,
95− modifier = Modifier.padding(vertical = 2.dp),
96− )
97− is DiffLine.FileHeader -> Text(
98− line.text,
99− style = MaterialTheme.typography.bodySmall,
100− fontFamily = FontFamily.Monospace,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.padding(vertical = 1.dp),
103− )
104− is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
105− is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
106− is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
92+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
93+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
94+ Icon(Icons.Filled.ContentCopy, contentDescription = "Copy", tint = MaterialTheme.colorScheme.onSurfaceVariant)
95+ }
96+ }
97+ Column(
98+ modifier = Modifier
99+ .horizontalScroll(rememberScrollState())
100+ .verticalScroll(rememberScrollState())
101+ .padding(horizontal = 10.dp)
102+ .padding(bottom = 10.dp),
103+ ) {
104+ for (line in lines) {
105+ when (line) {
106+ is DiffLine.Hunk -> Text(
107+ line.text,
108+ style = MaterialTheme.typography.bodySmall,
109+ fontFamily = FontFamily.Monospace,
110+ color = MaterialTheme.colorScheme.tertiary,
111+ modifier = Modifier.padding(vertical = 2.dp),
112+ )
113+ is DiffLine.FileHeader -> Text(
114+ line.text,
115+ style = MaterialTheme.typography.bodySmall,
116+ fontFamily = FontFamily.Monospace,
117+ color = MaterialTheme.colorScheme.onSurfaceVariant,
118+ modifier = Modifier.padding(vertical = 1.dp),
119+ )
120+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
121+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
122+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
123+ }
107124 }
108125 }
109126 }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+21−1
@@ -1,23 +1,43 @@
11package soy.iko.opencode.ui.components
22
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.combinedClickable
39import androidx.compose.material3.MaterialTheme
410import androidx.compose.runtime.Composable
511import androidx.compose.ui.Modifier
12+import androidx.compose.ui.platform.LocalContext
613import androidx.compose.ui.text.TextStyle
714import com.mikepenz.markdown.m3.Markdown
815
916/**
1017 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
1118 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
19+ * Long-press copies the raw markdown to the system clipboard.
1220 */
21+@OptIn(ExperimentalFoundationApi::class)
1322@Composable
1423fun MarkdownText(
1524 markdown: String,
1625 modifier: Modifier = Modifier,
1726 style: TextStyle = MaterialTheme.typography.bodyLarge,
1827) {
28+ val context = LocalContext.current
1929 Markdown(
2030 content = markdown,
21− modifier = modifier,
31+ modifier = modifier.combinedClickable(
32+ onClick = {},
33+ onLongClick = { copyToClipboard(context, markdown) },
34+ ),
2235 )
2336}
37+
38+/** Copy [text] to the system clipboard and show a confirmation toast. */
39+internal fun copyToClipboard(context: Context, label: String, text: String = label) {
40+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
41+ clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
42+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
43+}
app/src/main/java/soy/iko/opencode/ui/components/StubScreen.ktdeleted−41
@@ -1,41 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Box
4−import androidx.compose.foundation.layout.padding
5−import androidx.compose.material.icons.Icons
6−import androidx.compose.material.icons.automirrored.filled.ArrowBack
7−import androidx.compose.material3.ExperimentalMaterial3Api
8−import androidx.compose.material3.Icon
9−import androidx.compose.material3.IconButton
10−import androidx.compose.material3.Scaffold
11−import androidx.compose.material3.Text
12−import androidx.compose.material3.TopAppBar
13−import androidx.compose.runtime.Composable
14−import androidx.compose.ui.Alignment
15−import androidx.compose.ui.Modifier
16−import androidx.compose.ui.unit.dp
17−
18−/** Shared placeholder for not-yet-implemented destinations. */
19−@OptIn(ExperimentalMaterial3Api::class)
20−@Composable
21−fun StubScreen(title: String, message: String, onBack: () -> Unit) {
22− Scaffold(
23− topBar = {
24− TopAppBar(
25− title = { Text(title) },
26− navigationIcon = {
27− IconButton(onClick = onBack) {
28− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
29− }
30− },
31− )
32− },
33− ) { padding ->
34− Box(
35− modifier = Modifier.padding(padding).padding(24.dp),
36− contentAlignment = Alignment.Center,
37− ) {
38− Text(message)
39− }
40− }
41−}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+22
@@ -0,0 +1,22 @@
1+package soy.iko.opencode.ui.components
2+
3+import java.util.concurrent.TimeUnit
4+
5+/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
6+fun relativeTime(epochMillis: Long?): String {
7+ if (epochMillis == null || epochMillis <= 0) return ""
8+ val diff = System.currentTimeMillis() - epochMillis
9+ if (diff < 0) return "now"
10+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
11+ if (minutes < 1) return "now"
12+ if (minutes < 60) return "${minutes}m"
13+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
14+ if (hours < 24) return "${hours}h"
15+ val days = TimeUnit.MILLISECONDS.toDays(diff)
16+ if (days < 7) return "${days}d"
17+ val weeks = days / 7
18+ if (weeks < 5) return "${weeks}w"
19+ val months = days / 30
20+ if (months < 12) return "${months}mo"
21+ return "${days / 365}y"
22+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+43−1
@@ -1,6 +1,7 @@
11package soy.iko.opencode.ui.file
22
33import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.horizontalScroll
45import androidx.compose.foundation.layout.Box
56import androidx.compose.foundation.layout.Column
67import androidx.compose.foundation.layout.Row
@@ -10,10 +11,13 @@ import androidx.compose.foundation.layout.padding
1011import androidx.compose.foundation.layout.size
1112import androidx.compose.foundation.lazy.LazyColumn
1213import androidx.compose.foundation.lazy.items
14+import androidx.compose.foundation.rememberScrollState
1315import androidx.compose.material.icons.Icons
1416import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.ChevronRight
1518import androidx.compose.material.icons.filled.Description
1619import androidx.compose.material.icons.filled.Folder
20+import androidx.compose.material.icons.filled.Home
1721import androidx.compose.material.icons.filled.Search
1822import androidx.compose.material3.CircularProgressIndicator
1923import androidx.compose.material3.ExperimentalMaterial3Api
@@ -49,7 +53,7 @@ fun FileBrowserScreen(
4953 Scaffold(
5054 topBar = {
5155 TopAppBar(
52− title = { Text(if (state.path.isBlank()) "Files" else state.path, maxLines = 1, overflow = TextOverflow.Ellipsis) },
56+ title = { Text("Files", maxLines = 1, overflow = TextOverflow.Ellipsis) },
5357 navigationIcon = {
5458 IconButton(onClick = onBack) {
5559 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -59,6 +63,12 @@ fun FileBrowserScreen(
5963 },
6064 ) { padding ->
6165 Column(modifier = Modifier.fillMaxSize().padding(padding)) {
66+ // Tappable breadcrumb trail so deep paths are navigable.
67+ Breadcrumbs(
68+ path = state.path,
69+ onNavigate = vm::open,
70+ modifier = Modifier.fillMaxWidth(),
71+ )
6272 OutlinedTextField(
6373 value = state.query,
6474 onValueChange = vm::setQuery,
@@ -89,6 +99,38 @@ fun FileBrowserScreen(
8999 }
90100}
91101
102+@Composable
103+private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
104+ val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
105+ Row(
106+ modifier = modifier
107+ .horizontalScroll(rememberScrollState())
108+ .padding(horizontal = 16.dp, vertical = 8.dp),
109+ verticalAlignment = Alignment.CenterVertically,
110+ ) {
111+ IconButton(onClick = { onNavigate("") }, modifier = Modifier.size(20.dp)) {
112+ Icon(Icons.Filled.Home, contentDescription = "Root", modifier = Modifier.size(18.dp))
113+ }
114+ var acc = ""
115+ segments.forEachIndexed { index, segment ->
116+ acc = if (acc.isEmpty()) segment else "$acc/$segment"
117+ Icon(
118+ Icons.Filled.ChevronRight,
119+ contentDescription = null,
120+ modifier = Modifier.size(16.dp),
121+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
122+ )
123+ val target = acc
124+ Text(
125+ segment,
126+ style = MaterialTheme.typography.bodyMedium,
127+ color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
128+ modifier = Modifier.clickable { onNavigate(target) }.padding(vertical = 2.dp),
129+ )
130+ }
131+ }
132+}
133+
92134@Composable
93135private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
94136 if (results.isEmpty()) {
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+61−7
@@ -1,13 +1,21 @@
11package soy.iko.opencode.ui.file
22
3+import android.content.Intent
34import androidx.compose.foundation.horizontalScroll
45import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
58import androidx.compose.foundation.layout.fillMaxSize
69import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.width
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.items
713import androidx.compose.foundation.rememberScrollState
814import androidx.compose.foundation.verticalScroll
915import androidx.compose.material.icons.Icons
1016import androidx.compose.material.icons.automirrored.filled.ArrowBack
17+import androidx.compose.material.icons.filled.ContentCopy
18+import androidx.compose.material.icons.filled.Share
1119import androidx.compose.material3.CircularProgressIndicator
1220import androidx.compose.material3.ExperimentalMaterial3Api
1321import androidx.compose.material3.Icon
@@ -18,8 +26,10 @@ import androidx.compose.material3.Text
1826import androidx.compose.material3.TopAppBar
1927import androidx.compose.runtime.Composable
2028import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.remember
2130import androidx.compose.ui.Alignment
2231import androidx.compose.ui.Modifier
32+import androidx.compose.ui.platform.LocalContext
2333import androidx.compose.ui.text.font.FontFamily
2434import androidx.compose.ui.text.style.TextOverflow
2535import androidx.compose.ui.unit.dp
@@ -27,6 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
2737import androidx.lifecycle.viewmodel.compose.viewModel
2838import soy.iko.opencode.di.AppContainer
2939import soy.iko.opencode.ui.components.DiffView
40+import soy.iko.opencode.ui.components.copyToClipboard
3041import soy.iko.opencode.ui.vmFactory
3142
3243@OptIn(ExperimentalMaterial3Api::class)
@@ -38,16 +49,36 @@ fun FileViewScreen(
3849) {
3950 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
4051 val state by vm.state.collectAsStateWithLifecycle()
52+ val context = LocalContext.current
53+ val filename = remember(path) { path.substringAfterLast('/') }
4154
4255 Scaffold(
4356 topBar = {
4457 TopAppBar(
45− title = { Text(path.substringAfterLast('/'), maxLines = 1, overflow = TextOverflow.Ellipsis) },
58+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
4659 navigationIcon = {
4760 IconButton(onClick = onBack) {
4861 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
4962 }
5063 },
64+ actions = {
65+ val content = state.content?.content.orEmpty()
66+ if (content.isNotEmpty()) {
67+ IconButton(onClick = { copyToClipboard(context, filename, content) }) {
68+ Icon(Icons.Filled.ContentCopy, contentDescription = "Copy")
69+ }
70+ IconButton(onClick = {
71+ val send = Intent(Intent.ACTION_SEND).apply {
72+ type = "text/plain"
73+ putExtra(Intent.EXTRA_SUBJECT, filename)
74+ putExtra(Intent.EXTRA_TEXT, content)
75+ }
76+ context.startActivity(Intent.createChooser(send, "Share $filename"))
77+ }) {
78+ Icon(Icons.Filled.Share, contentDescription = "Share")
79+ }
80+ }
81+ },
5182 )
5283 },
5384 ) { padding ->
@@ -75,16 +106,39 @@ fun FileViewScreen(
75106 .padding(padding),
76107 )
77108 } else {
78− Text(
79− text = content?.content.orEmpty(),
109+ val text = content?.content.orEmpty()
110+ val lines = remember(text) { text.split("\n") }
111+ val gutterWidth = remember(lines.size) {
112+ (lines.size.toString().length.coerceAtLeast(3) * 10).dp
113+ }
114+ Row(
80115 modifier = Modifier
81116 .fillMaxSize()
82117 .verticalScroll(scrollState)
83118 .horizontalScroll(rememberScrollState())
84− .padding(12.dp),
85− style = MaterialTheme.typography.bodySmall,
86− fontFamily = FontFamily.Monospace,
87− )
119+ .padding(8.dp),
120+ ) {
121+ // Line-number gutter
122+ Column(modifier = Modifier.width(gutterWidth)) {
123+ lines.forEachIndexed { i, _ ->
124+ Text(
125+ "${i + 1}",
126+ style = MaterialTheme.typography.bodySmall,
127+ fontFamily = FontFamily.Monospace,
128+ color = MaterialTheme.colorScheme.onSurfaceVariant,
129+ )
130+ }
131+ }
132+ Column(modifier = Modifier.padding(start = 8.dp)) {
133+ lines.forEach { line ->
134+ Text(
135+ line,
136+ style = MaterialTheme.typography.bodySmall,
137+ fontFamily = FontFamily.Monospace,
138+ )
139+ }
140+ }
141+ }
88142 }
89143 }
90144 }
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+83−12
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
55import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
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
@@ -13,30 +14,38 @@ import androidx.compose.foundation.lazy.LazyColumn
1314import androidx.compose.foundation.lazy.items
1415import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.filled.Add
17+import androidx.compose.material.icons.filled.CheckCircle
1618import androidx.compose.material.icons.filled.Delete
19+import androidx.compose.material.icons.filled.Dns
1720import androidx.compose.material.icons.filled.Edit
21+import androidx.compose.material3.AlertDialog
22+import androidx.compose.material3.Card
1823import androidx.compose.material3.CircularProgressIndicator
1924import androidx.compose.material3.ExperimentalMaterial3Api
2025import androidx.compose.material3.FloatingActionButton
2126import androidx.compose.material3.Icon
2227import androidx.compose.material3.IconButton
2328import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.Card
2529import androidx.compose.material3.Scaffold
2630import androidx.compose.material3.SnackbarHost
2731import androidx.compose.material3.SnackbarHostState
2832import androidx.compose.material3.Text
33+import androidx.compose.material3.TextButton
2934import androidx.compose.material3.TopAppBar
3035import androidx.compose.runtime.Composable
3136import androidx.compose.runtime.LaunchedEffect
3237import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
3339import androidx.compose.runtime.remember
40+import androidx.compose.runtime.setValue
3441import androidx.compose.ui.Alignment
3542import androidx.compose.ui.Modifier
43+import androidx.compose.ui.platform.LocalHapticFeedback
3644import androidx.compose.ui.text.style.TextOverflow
3745import androidx.compose.ui.unit.dp
3846import androidx.lifecycle.compose.collectAsStateWithLifecycle
3947import androidx.lifecycle.viewmodel.compose.viewModel
48+import soy.iko.opencode.data.model.ServerProfile
4049import soy.iko.opencode.di.AppContainer
4150import soy.iko.opencode.ui.vmFactory
4251
@@ -52,7 +61,11 @@ fun ServerListScreen(
5261 val profiles by vm.profiles.collectAsStateWithLifecycle()
5362 val connectingId by vm.connectingId.collectAsStateWithLifecycle()
5463 val error by vm.error.collectAsStateWithLifecycle()
64+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
65+ val haptics = LocalHapticFeedback.current
5566 val snackbar = remember { SnackbarHostState() }
67+ var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
68+ val connectedId = activeConnection?.profile?.id
5669
5770 LaunchedEffect(error) {
5871 error?.let {
@@ -71,15 +84,10 @@ fun ServerListScreen(
7184 },
7285 ) { padding ->
7386 if (profiles.isEmpty()) {
74− Box(
75− modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
76− contentAlignment = Alignment.Center,
77− ) {
78− Text(
79− "No servers yet.\nTap + to add an opencode serve endpoint.",
80− style = MaterialTheme.typography.bodyLarge,
81− )
82− }
87+ EmptyServers(
88+ onAdd = onAddProfile,
89+ modifier = Modifier.fillMaxSize().padding(padding),
90+ )
8391 } else {
8492 LazyColumn(
8593 modifier = Modifier.fillMaxSize().padding(padding),
@@ -87,6 +95,7 @@ fun ServerListScreen(
8795 verticalArrangement = Arrangement.spacedBy(12.dp),
8896 ) {
8997 items(profiles, key = { it.id }) { profile ->
98+ val isActive = profile.id == connectedId
9099 Card(
91100 modifier = Modifier
92101 .fillMaxWidth()
@@ -112,14 +121,28 @@ fun ServerListScreen(
112121 maxLines = 1,
113122 overflow = TextOverflow.Ellipsis,
114123 )
124+ if (isActive) {
125+ Text(
126+ "Connected",
127+ style = MaterialTheme.typography.labelSmall,
128+ color = MaterialTheme.colorScheme.primary,
129+ )
130+ }
115131 }
116− if (connectingId == profile.id) {
132+ if (isActive) {
133+ Icon(
134+ Icons.Filled.CheckCircle,
135+ contentDescription = "Connected",
136+ tint = MaterialTheme.colorScheme.primary,
137+ modifier = Modifier.size(22.dp),
138+ )
139+ } else if (connectingId == profile.id) {
117140 CircularProgressIndicator(modifier = Modifier.size(24.dp))
118141 } else {
119142 IconButton(onClick = { onEditProfile(profile.id) }) {
120143 Icon(Icons.Filled.Edit, contentDescription = "Edit")
121144 }
122− IconButton(onClick = { vm.delete(profile) }) {
145+ IconButton(onClick = { pendingDelete = profile }) {
123146 Icon(Icons.Filled.Delete, contentDescription = "Delete")
124147 }
125148 }
@@ -129,4 +152,52 @@ fun ServerListScreen(
129152 }
130153 }
131154 }
155+
156+ pendingDelete?.let { profile ->
157+ AlertDialog(
158+ onDismissRequest = { pendingDelete = null },
159+ title = { Text("Remove server?") },
160+ text = { Text("“${profile.displayLabel}” will be removed from your saved servers.") },
161+ confirmButton = {
162+ TextButton(onClick = {
163+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
164+ pendingDelete = null
165+ vm.delete(profile)
166+ }) { Text("Remove", color = MaterialTheme.colorScheme.error) }
167+ },
168+ dismissButton = {
169+ TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
170+ },
171+ )
172+ }
173+}
174+
175+@Composable
176+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
177+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
178+ Column(
179+ modifier = Modifier.padding(32.dp),
180+ horizontalAlignment = Alignment.CenterHorizontally,
181+ ) {
182+ Icon(
183+ Icons.Filled.Dns,
184+ contentDescription = null,
185+ modifier = Modifier.size(56.dp),
186+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
187+ )
188+ Spacer(Modifier.size(16.dp))
189+ Text("No servers yet", style = MaterialTheme.typography.titleMedium)
190+ Spacer(Modifier.size(8.dp))
191+ Text(
192+ "Add an opencode serve endpoint to connect.",
193+ style = MaterialTheme.typography.bodyMedium,
194+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195+ )
196+ Spacer(Modifier.size(20.dp))
197+ TextButton(onClick = onAdd) {
198+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
199+ Text(" Add server")
200+ }
201+ }
202+ }
132203}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+138−26
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
66import androidx.compose.foundation.layout.Column
77import androidx.compose.foundation.layout.PaddingValues
88import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
910import androidx.compose.foundation.layout.fillMaxSize
1011import androidx.compose.foundation.layout.fillMaxWidth
1112import androidx.compose.foundation.layout.padding
@@ -16,10 +17,14 @@ import androidx.compose.material.icons.Icons
1617import androidx.compose.material.icons.automirrored.filled.Logout
1718import androidx.compose.material.icons.filled.Add
1819import androidx.compose.material.icons.filled.ArrowDropDown
20+import androidx.compose.material.icons.filled.ChatBubbleOutline
1921import androidx.compose.material.icons.filled.Delete
2022import androidx.compose.material.icons.filled.Folder
2123import androidx.compose.material.icons.filled.Refresh
24+import androidx.compose.material.icons.filled.Search
2225import androidx.compose.material.icons.filled.Settings
26+import androidx.compose.material3.AlertDialog
27+import androidx.compose.material3.Button
2328import androidx.compose.material3.Card
2429import androidx.compose.material3.CircularProgressIndicator
2530import androidx.compose.material3.DropdownMenu
@@ -29,8 +34,10 @@ import androidx.compose.material3.ExtendedFloatingActionButton
2934import androidx.compose.material3.Icon
3035import androidx.compose.material3.IconButton
3136import androidx.compose.material3.MaterialTheme
37+import androidx.compose.material3.OutlinedTextField
3238import androidx.compose.material3.Scaffold
3339import androidx.compose.material3.Text
40+import androidx.compose.material3.TextButton
3441import androidx.compose.material3.TopAppBar
3542import androidx.compose.runtime.Composable
3643import androidx.compose.runtime.getValue
@@ -39,11 +46,14 @@ import androidx.compose.runtime.remember
3946import androidx.compose.runtime.setValue
4047import androidx.compose.ui.Alignment
4148import androidx.compose.ui.Modifier
49+import androidx.compose.ui.platform.LocalHapticFeedback
4250import androidx.compose.ui.text.style.TextOverflow
4351import androidx.compose.ui.unit.dp
4452import androidx.lifecycle.compose.collectAsStateWithLifecycle
4553import androidx.lifecycle.viewmodel.compose.viewModel
54+import soy.iko.opencode.data.model.Session
4655import soy.iko.opencode.di.AppContainer
56+import soy.iko.opencode.ui.components.relativeTime
4757import soy.iko.opencode.ui.vmFactory
4858
4959@OptIn(ExperimentalMaterial3Api::class)
@@ -60,7 +70,9 @@ fun SessionListScreen(
6070 val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
6171 val profiles by vm.profiles.collectAsStateWithLifecycle()
6272 val switchingId by vm.switchingId.collectAsStateWithLifecycle()
73+ val haptics = LocalHapticFeedback.current
6374 var showServerMenu by remember { mutableStateOf(false) }
75+ var pendingDelete by remember { mutableStateOf<Session?>(null) }
6476
6577 Scaffold(
6678 topBar = {
@@ -145,35 +157,39 @@ fun SessionListScreen(
145157 modifier = Modifier.align(Alignment.Center).padding(24.dp),
146158 color = MaterialTheme.colorScheme.error,
147159 )
148− state.sessions.isEmpty() -> Text(
149− "No sessions yet.\nTap “New session” to start.",
150− modifier = Modifier.align(Alignment.Center).padding(24.dp),
160+ state.sessions.isEmpty() -> EmptySessions(
161+ onCreate = { vm.createSession(onCreated = onOpenSession) },
162+ modifier = Modifier.align(Alignment.Center),
151163 )
152− else -> LazyColumn(
153− modifier = Modifier.fillMaxSize(),
154− contentPadding = PaddingValues(16.dp),
155− verticalArrangement = Arrangement.spacedBy(12.dp),
156− ) {
157− items(state.sessions, key = { it.id }) { session ->
158− Card(
159− modifier = Modifier
160− .fillMaxWidth()
161− .clickable { onOpenSession(session.id) },
164+ else -> Column(modifier = Modifier.fillMaxSize()) {
165+ OutlinedTextField(
166+ value = state.query,
167+ onValueChange = vm::setQuery,
168+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
169+ placeholder = { Text("Search sessions…") },
170+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
171+ singleLine = true,
172+ )
173+ val sessions = state.filtered
174+ if (sessions.isEmpty()) {
175+ Text(
176+ "No sessions match “${state.query}”.",
177+ modifier = Modifier.padding(24.dp),
178+ color = MaterialTheme.colorScheme.onSurfaceVariant,
179+ )
180+ } else {
181+ LazyColumn(
182+ modifier = Modifier.fillMaxSize(),
183+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
184+ verticalArrangement = Arrangement.spacedBy(12.dp),
162185 ) {
163− Row(
164− modifier = Modifier.fillMaxWidth().padding(16.dp),
165− verticalAlignment = Alignment.CenterVertically,
166− ) {
167− Text(
168− session.displayTitle,
169− style = MaterialTheme.typography.titleMedium,
170− maxLines = 2,
171− overflow = TextOverflow.Ellipsis,
172− modifier = Modifier.weight(1f),
186+ items(sessions, key = { it.id }) { session ->
187+ SessionCard(
188+ session = session,
189+ preview = state.previews[session.id],
190+ onClick = { onOpenSession(session.id) },
191+ onDelete = { pendingDelete = session },
173192 )
174− IconButton(onClick = { vm.deleteSession(session) }) {
175− Icon(Icons.Filled.Delete, contentDescription = "Delete")
176− }
177193 }
178194 }
179195 }
@@ -181,4 +197,100 @@ fun SessionListScreen(
181197 }
182198 }
183199 }
200+
201+ pendingDelete?.let { session ->
202+ AlertDialog(
203+ onDismissRequest = { pendingDelete = null },
204+ title = { Text("Delete session?") },
205+ text = { Text("“${session.displayTitle}” will be permanently deleted. This cannot be undone.") },
206+ confirmButton = {
207+ TextButton(onClick = {
208+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
209+ pendingDelete = null
210+ vm.deleteSession(session)
211+ }) { Text("Delete", color = MaterialTheme.colorScheme.error) }
212+ },
213+ dismissButton = {
214+ TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
215+ },
216+ )
217+ }
218+}
219+
220+@Composable
221+private fun SessionCard(
222+ session: Session,
223+ preview: String?,
224+ onClick: () -> Unit,
225+ onDelete: () -> Unit,
226+) {
227+ Card(
228+ modifier = Modifier
229+ .fillMaxWidth()
230+ .clickable { onClick() },
231+ ) {
232+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
233+ Row(
234+ modifier = Modifier.fillMaxWidth(),
235+ verticalAlignment = Alignment.CenterVertically,
236+ ) {
237+ Text(
238+ session.displayTitle,
239+ style = MaterialTheme.typography.titleMedium,
240+ maxLines = 1,
241+ overflow = TextOverflow.Ellipsis,
242+ modifier = Modifier.weight(1f),
243+ )
244+ val time = relativeTime(session.time?.updated ?: session.time?.created)
245+ if (time.isNotEmpty()) {
246+ Text(
247+ time,
248+ style = MaterialTheme.typography.labelSmall,
249+ color = MaterialTheme.colorScheme.onSurfaceVariant,
250+ )
251+ }
252+ IconButton(onClick = onDelete) {
253+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
254+ }
255+ }
256+ if (!preview.isNullOrBlank()) {
257+ Spacer(Modifier.size(4.dp))
258+ Text(
259+ preview,
260+ style = MaterialTheme.typography.bodyMedium,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ maxLines = 2,
263+ overflow = TextOverflow.Ellipsis,
264+ )
265+ }
266+ }
267+ }
268+}
269+
270+@Composable
271+private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
272+ Column(
273+ modifier = modifier.padding(32.dp),
274+ horizontalAlignment = Alignment.CenterHorizontally,
275+ ) {
276+ Icon(
277+ Icons.Filled.ChatBubbleOutline,
278+ contentDescription = null,
279+ modifier = Modifier.size(56.dp),
280+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
281+ )
282+ Spacer(Modifier.size(16.dp))
283+ Text("No sessions yet", style = MaterialTheme.typography.titleMedium)
284+ Spacer(Modifier.size(8.dp))
285+ Text(
286+ "Start a conversation with opencode.",
287+ style = MaterialTheme.typography.bodyMedium,
288+ color = MaterialTheme.colorScheme.onSurfaceVariant,
289+ )
290+ Spacer(Modifier.size(20.dp))
291+ Button(onClick = onCreate) {
292+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
293+ Text(" New session")
294+ }
295+ }
184296}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+39−5
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.TextPart
78import soy.iko.opencode.di.AppContainer
89import kotlinx.coroutines.flow.MutableStateFlow
910import kotlinx.coroutines.flow.SharingStarted
@@ -14,9 +15,22 @@ import kotlinx.coroutines.launch
1415
1516data class SessionListState(
1617 val sessions: List<Session> = emptyList(),
18+ val query: String = "",
19+ val previews: Map<String, String> = emptyMap(),
1720 val loading: Boolean = true,
1821 val error: String? = null,
19−)
22+) {
23+ /** Sessions filtered by the current search query (title or preview text). */
24+ val filtered: List<Session>
25+ get() {
26+ val q = query.trim()
27+ if (q.isEmpty()) return sessions
28+ return sessions.filter { session ->
29+ session.displayTitle.contains(q, ignoreCase = true) ||
30+ (previews[session.id]?.contains(q, ignoreCase = true) == true)
31+ }
32+ }
33+}
2034
2135class SessionListViewModel(private val container: AppContainer) : ViewModel() {
2236
@@ -43,6 +57,10 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
4357
4458 init { refresh() }
4559
60+ fun setQuery(query: String) {
61+ _state.value = _state.value.copy(query = query)
62+ }
63+
4664 fun refresh() {
4765 val conn = container.activeConnection.value
4866 if (conn == null) {
@@ -54,15 +72,31 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5472 viewModelScope.launch {
5573 runCatching { conn.repository.listSessions() }
5674 .onSuccess { list ->
57− _state.value = SessionListState(
58− sessions = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 },
59− loading = false,
60− )
75+ val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
76+ _state.value = _state.value.copy(sessions = sorted, loading = false)
77+ loadPreviews(sorted)
6178 }
6279 .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
6380 }
6481 }
6582
83+ /** Fetch the last text part of each session for list previews (best-effort, bounded). */
84+ private fun loadPreviews(sessions: List<Session>) {
85+ val conn = container.activeConnection.value ?: return
86+ val api = conn.api
87+ val toLoad = sessions.take(50)
88+ toLoad.forEach { session ->
89+ viewModelScope.launch {
90+ val preview = runCatching {
91+ api.listMessages(session.id).lastOrNull()?.let { msg ->
92+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
93+ }
94+ }.getOrNull()?.takeIf { p -> p.isNotBlank() } ?: return@launch
95+ _state.value = _state.value.copy(previews = _state.value.previews + (session.id to preview))
96+ }
97+ }
98+ }
99+
66100 fun createSession(onCreated: (String) -> Unit) {
67101 val conn = container.activeConnection.value ?: return
68102 viewModelScope.launch {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+66−4
@@ -1,12 +1,18 @@
11package soy.iko.opencode.ui.settings
22
3+import androidx.compose.foundation.clickable
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.Spacer
58import androidx.compose.foundation.layout.fillMaxWidth
69import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
711import androidx.compose.foundation.selection.selectable
812import androidx.compose.material.icons.Icons
913import androidx.compose.material.icons.automirrored.filled.ArrowBack
14+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
15+import androidx.compose.material.icons.filled.Dns
1016import androidx.compose.material3.ExperimentalMaterial3Api
1117import androidx.compose.material3.HorizontalDivider
1218import androidx.compose.material3.Icon
@@ -14,13 +20,17 @@ import androidx.compose.material3.IconButton
1420import androidx.compose.material3.MaterialTheme
1521import androidx.compose.material3.RadioButton
1622import androidx.compose.material3.Scaffold
23+import androidx.compose.material3.Switch
1724import androidx.compose.material3.Text
25+import androidx.compose.material3.TextButton
1826import androidx.compose.material3.TopAppBar
1927import androidx.compose.runtime.Composable
2028import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.remember
2130import androidx.compose.runtime.rememberCoroutineScope
2231import androidx.compose.ui.Alignment
2332import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalContext
2434import androidx.compose.ui.unit.dp
2535import androidx.lifecycle.compose.collectAsStateWithLifecycle
2636import kotlinx.coroutines.launch
@@ -29,10 +39,16 @@ import soy.iko.opencode.di.AppContainer
2939
3040@OptIn(ExperimentalMaterial3Api::class)
3141@Composable
32−fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
42+fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit) {
3343 val scope = rememberCoroutineScope()
3444 val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
45+ val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
3546 val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
47+ val context = LocalContext.current
48+ val versionName = remember {
49+ runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
50+ .getOrDefault("0.1.0")
51+ }
3652
3753 Scaffold(
3854 topBar = {
@@ -49,13 +65,32 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
4965 Column(modifier = Modifier.padding(padding).padding(16.dp)) {
5066 Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
5167 ThemeMode.entries.forEach { mode ->
52− Row(
68+ ThemeRow(
5369 mode = mode,
5470 selected = themeMode == mode,
5571 onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
5672 )
5773 }
5874
75+ Spacer(Modifier.size(8.dp))
76+ Row(
77+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
78+ verticalAlignment = Alignment.CenterVertically,
79+ ) {
80+ Column(modifier = Modifier.weight(1f)) {
81+ Text("Dynamic color", style = MaterialTheme.typography.bodyLarge)
82+ Text(
83+ "Use Material You wallpaper colors (Android 12+)",
84+ style = MaterialTheme.typography.bodySmall,
85+ color = MaterialTheme.colorScheme.onSurfaceVariant,
86+ )
87+ }
88+ Switch(
89+ checked = dynamicColor,
90+ onCheckedChange = { scope.launch { container.settingsStore.setDynamicColor(it) } },
91+ )
92+ }
93+
5994 HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
6095
6196 Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
@@ -73,13 +108,40 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
73108 color = MaterialTheme.colorScheme.onSurfaceVariant,
74109 )
75110 }
111+ Row(
112+ modifier = Modifier
113+ .fillMaxWidth()
114+ .padding(top = 8.dp)
115+ .clickable { onManageServers() }
116+ .padding(vertical = 8.dp),
117+ verticalAlignment = Alignment.CenterVertically,
118+ ) {
119+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
120+ Text(" Manage servers", modifier = Modifier.weight(1f))
121+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
122+ }
123+
124+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
125+
126+ Text("About", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
127+ Text(
128+ "opencode-android $versionName",
129+ style = MaterialTheme.typography.bodyMedium,
130+ modifier = Modifier.padding(top = 8.dp),
131+ )
132+ Text(
133+ "A native Android client for the opencode serve HTTP API.",
134+ style = MaterialTheme.typography.bodySmall,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(top = 4.dp),
137+ )
76138 }
77139 }
78140}
79141
80142@Composable
81−private fun Row(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
82− androidx.compose.foundation.layout.Row(
143+private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
144+ Row(
83145 modifier = Modifier
84146 .fillMaxWidth()
85147 .selectable(selected = selected, onClick = onSelect)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+12−1
@@ -1,14 +1,18 @@
11package soy.iko.opencode.ui.theme
22
33import android.app.Activity
4+import android.os.Build
45import androidx.compose.foundation.isSystemInDarkTheme
56import androidx.compose.material3.MaterialTheme
67import androidx.compose.material3.darkColorScheme
8+import androidx.compose.material3.dynamicDarkColorScheme
9+import androidx.compose.material3.dynamicLightColorScheme
710import androidx.compose.material3.lightColorScheme
811import androidx.compose.runtime.Composable
912import androidx.compose.runtime.SideEffect
1013import androidx.compose.ui.graphics.Color
1114import androidx.compose.ui.graphics.toArgb
15+import androidx.compose.ui.platform.LocalContext
1216import androidx.compose.ui.platform.LocalView
1317import androidx.core.view.WindowCompat
1418
@@ -29,9 +33,16 @@ private val LightColors = lightColorScheme(
2933@Composable
3034fun OpencodeTheme(
3135 darkTheme: Boolean = isSystemInDarkTheme(),
36+ dynamicColor: Boolean = false,
3237 content: @Composable () -> Unit,
3338) {
34− val colorScheme = if (darkTheme) DarkColors else LightColors
39+ val context = LocalContext.current
40+ val colorScheme = when {
41+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
42+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
43+ darkTheme -> DarkColors
44+ else -> LightColors
45+ }
3546 val view = LocalView.current
3647 if (!view.isInEditMode) {
3748 SideEffect {
app/src/test/java/soy/iko/opencode/UxTest.ktnew file+91
@@ -0,0 +1,91 @@
1+package soy.iko.opencode
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertTrue
5+import org.junit.Test
6+import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.data.model.TimeInfo
8+import soy.iko.opencode.ui.components.relativeTime
9+import soy.iko.opencode.ui.session.SessionListState
10+import java.util.concurrent.TimeUnit
11+
12+class UxTest {
13+
14+ // --- relativeTime ---
15+
16+ @Test
17+ fun relativeTimeHandlesNullAndZero() {
18+ assertEquals("", relativeTime(null))
19+ assertEquals("", relativeTime(0))
20+ assertEquals("", relativeTime(-1))
21+ }
22+
23+ @Test
24+ fun relativeTimeFormatsMinutes() {
25+ val now = System.currentTimeMillis()
26+ assertEquals("now", relativeTime(now))
27+ assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
28+ }
29+
30+ @Test
31+ fun relativeTimeFormatsHoursAndDays() {
32+ val now = System.currentTimeMillis()
33+ assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
34+ assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
35+ assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
36+ }
37+
38+ // --- Session search/filter ---
39+
40+ private fun session(id: String, title: String) =
41+ Session(id = id, title = title)
42+
43+ @Test
44+ fun emptyQueryReturnsAllSessions() {
45+ val state = SessionListState(
46+ sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
47+ )
48+ assertEquals(2, state.filtered.size)
49+ }
50+
51+ @Test
52+ fun queryFiltersByTitle() {
53+ val state = SessionListState(
54+ sessions = listOf(session("1", "Refactor tests"), session("2", "Add docs")),
55+ query = "refactor",
56+ )
57+ val result = state.filtered
58+ assertEquals(1, result.size)
59+ assertEquals("1", result.first().id)
60+ }
61+
62+ @Test
63+ fun queryFiltersByPreviewText() {
64+ val state = SessionListState(
65+ sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
66+ previews = mapOf("2" to "discuss the database migration"),
67+ query = "database",
68+ )
69+ val result = state.filtered
70+ assertEquals(1, result.size)
71+ assertEquals("2", result.first().id)
72+ }
73+
74+ @Test
75+ fun queryIsCaseInsensitive() {
76+ val state = SessionListState(
77+ sessions = listOf(session("1", "Build Pipeline")),
78+ query = "build",
79+ )
80+ assertEquals(1, state.filtered.size)
81+ }
82+
83+ @Test
84+ fun noMatchReturnsEmpty() {
85+ val state = SessionListState(
86+ sessions = listOf(session("1", "Alpha")),
87+ query = "zzz",
88+ )
89+ assertTrue(state.filtered.isEmpty())
90+ }
91+}