git.iko.soy/opencode-android
on master help sign in
Add Enter-to-send, per-session draft persistence, message timestamps, VCS status badges in the file browser, connectivity-aware SSE reconnect, cleaner token/cost formatting, and bounded preview loading
commit4743e50b93ec2695a0adee90e87af61c235dc5c0 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 10:45 (3 months ago) parente37d1bb
11 files changed+214−22
README.md+10−1 app/src/main/AndroidManifest.xml+1 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−2 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+22 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+26−2 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+36−1 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+14−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+26−9
README.md+10−1
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M7 complete:
9+Milestones M0–M8 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.
@@ -33,6 +33,15 @@ Milestones M0–M7 complete:
3333 polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
3434 manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
3535 The session list also supports **renaming** a session (`PATCH /session/:id`).
36+- **M8:** **Enter-to-send** (hardware-keyboard Enter sends, Shift+Enter for newline);
37+ **per-session draft persistence** — typed text survives back-navigation and process
38+ death; **message timestamps** (relative time on every bubble); **VCS status in the file
39+ browser** — `GET /file/status` powers added/modified/deleted badges with `+N/−N` line
40+ counts on tracked files; **connectivity-aware SSE reconnect** — a `ConnectivityManager`
41+ callback triggers an immediate reconnect (skipping backoff) when the device regains
42+ network; cleaner **token/cost formatting** (`1,234 in · 5,678 out`, precision-aware cost);
43+ and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
44+ list never floods the server with parallel requests.
3645
3746## Architecture
3847
app/src/main/AndroidManifest.xml+1
@@ -2,6 +2,7 @@
22<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33
44 <uses-permission android:name="android.permission.INTERNET" />
5+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
56
67 <application
78 android:name=".OpencodeApp"
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+17−2
@@ -5,6 +5,7 @@ import io.ktor.client.HttpClient
55import io.ktor.client.plugins.sse.sse
66import kotlinx.coroutines.CancellationException
77import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.channels.Channel
89import kotlinx.coroutines.delay
910import kotlinx.coroutines.flow.Flow
1011import kotlinx.coroutines.flow.MutableStateFlow
@@ -15,6 +16,8 @@ import kotlinx.coroutines.flow.asStateFlow
1516import kotlinx.coroutines.flow.channelFlow
1617import kotlinx.coroutines.flow.shareIn
1718import kotlinx.coroutines.isActive
19+import kotlinx.coroutines.selects.onTimeout
20+import kotlinx.coroutines.selects.select
1821
1922/**
2023 * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
@@ -24,6 +27,7 @@ import kotlinx.coroutines.isActive
2427 * Ordering contract: callers that send a prompt must already be collecting [events]
2528 * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
2629 */
30+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
2731class EventStreamClient(
2832 private val client: HttpClient,
2933 scope: CoroutineScope,
@@ -40,6 +44,12 @@ class EventStreamClient(
4044 replay = 0,
4145 )
4246
47+ /** Conflated channel used to cut the reconnect backoff short when network returns. */
48+ private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
49+
50+ /** Request an immediate reconnect, skipping any in-progress backoff. */
51+ fun triggerReconnect() { reconnectSignal.trySend(Unit) }
52+
4353 private fun stream(): Flow<BusEvent> = channelFlow {
4454 var backoffMs = INITIAL_BACKOFF_MS
4555 while (isActive) {
@@ -66,8 +76,13 @@ class EventStreamClient(
6676 }
6777 _state.value = ConnectionState.Disconnected
6878 if (!isActive) break
69− delay(backoffMs)
70− backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
79+ // Wait for the backoff, but allow a reconnect signal to cut it short.
80+ var signaled = false
81+ select {
82+ reconnectSignal.onReceive { signaled = true }
83+ onTimeout(backoffMs) { /* normal backoff elapsed */ }
84+ }
85+ backoffMs = if (signaled) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
7186 }
7287 }
7388
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+22
@@ -0,0 +1,22 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+
5+/**
6+ * Persists the in-progress message draft for each session so it survives
7+ * back-navigation and process death. Backed by SharedPreferences because the
8+ * UI needs the initial value synchronously and drafts are low-stakes data.
9+ */
10+class DraftStore(context: Context) {
11+
12+ private val prefs = context.applicationContext
13+ .getSharedPreferences("drafts", Context.MODE_PRIVATE)
14+
15+ fun get(sessionId: String): String = prefs.getString(sessionId, "").orEmpty()
16+
17+ fun set(sessionId: String, text: String) {
18+ prefs.edit().apply {
19+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
20+ }.apply()
21+ }
22+}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+27
@@ -1,7 +1,11 @@
11package soy.iko.opencode.di
22
33import android.content.Context
4+import android.net.ConnectivityManager
5+import android.net.Network
6+import android.net.NetworkRequest
47import soy.iko.opencode.data.model.ServerProfile
8+import soy.iko.opencode.data.repo.DraftStore
59import soy.iko.opencode.data.repo.ProfileStore
610import soy.iko.opencode.data.repo.SettingsStore
711import kotlinx.coroutines.CoroutineScope
@@ -20,10 +24,33 @@ class AppContainer(context: Context) {
2024 val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
2125 val profileStore = ProfileStore(context)
2226 val settingsStore = SettingsStore(context)
27+ val draftStore = DraftStore(context)
28+
29+ private val appContext = context.applicationContext
2330
2431 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
2532 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
2633
34+ init {
35+ registerNetworkMonitor()
36+ }
37+
38+ /**
39+ * When the device regains connectivity, nudge the active SSE stream to reconnect
40+ * immediately instead of waiting out the exponential backoff.
41+ */
42+ private fun registerNetworkMonitor() {
43+ val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
44+ ?: return
45+ runCatching {
46+ cm.registerNetworkCallback(NetworkRequest.Builder().build(), object : ConnectivityManager.NetworkCallback() {
47+ override fun onAvailable(network: Network) {
48+ activeConnection.value?.events?.triggerReconnect()
49+ }
50+ })
51+ }
52+ }
53+
2754 /** Open (or re-open) a connection to [profile], replacing any current one. */
2855 suspend fun connect(profile: ServerProfile): OpencodeConnection {
2956 disconnect()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+24−6
@@ -50,6 +50,12 @@ import androidx.compose.runtime.rememberCoroutineScope
5050import androidx.compose.runtime.setValue
5151import androidx.compose.ui.Alignment
5252import androidx.compose.ui.Modifier
53+import androidx.compose.ui.input.key.Key
54+import androidx.compose.ui.input.key.KeyEventType
55+import androidx.compose.ui.input.key.isShiftPressed
56+import androidx.compose.ui.input.key.key
57+import androidx.compose.ui.input.key.onPreviewKeyEvent
58+import androidx.compose.ui.input.key.type
5359import androidx.compose.ui.platform.LocalHapticFeedback
5460import androidx.compose.ui.unit.dp
5561import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -79,12 +85,12 @@ fun ChatScreen(
7985 val commands by vm.commands.collectAsStateWithLifecycle()
8086 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
8187 val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
88+ val draft by vm.draft.collectAsStateWithLifecycle()
8289 val haptics = LocalHapticFeedback.current
8390 val scope = rememberCoroutineScope()
8491
8592 val listState = rememberLazyListState()
8693 val snackbar = remember { SnackbarHostState() }
87− var input by remember { mutableStateOf("") }
8894 var showModelPicker by remember { mutableStateOf(false) }
8995 var showAgentPicker by remember { mutableStateOf(false) }
9096 var showCommandPicker by remember { mutableStateOf(false) }
@@ -117,8 +123,7 @@ fun ChatScreen(
117123 }
118124
119125 fun doSend() {
120− if (vm.send(input)) {
121− input = ""
126+ if (vm.send(draft)) {
122127 haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
123128 }
124129 }
@@ -164,8 +169,8 @@ fun ChatScreen(
164169 snackbarHost = { SnackbarHost(snackbar) },
165170 bottomBar = {
166171 ChatInputBar(
167− value = input,
168− onValueChange = { input = it },
172+ value = draft,
173+ onValueChange = vm::updateDraft,
169174 running = running,
170175 enabled = vm.connected,
171176 onSend = ::doSend,
@@ -323,7 +328,20 @@ private fun ChatInputBar(
323328 OutlinedTextField(
324329 value = value,
325330 onValueChange = onValueChange,
326− modifier = Modifier.weight(1f),
331+ modifier = Modifier
332+ .weight(1f)
333+ .onPreviewKeyEvent { event ->
334+ if (event.type == KeyEventType.KeyDown &&
335+ event.key == Key.Enter &&
336+ !event.isShiftPressed &&
337+ enabled && value.isNotBlank()
338+ ) {
339+ onSend()
340+ true
341+ } else {
342+ false
343+ }
344+ },
327345 placeholder = { Text("Message opencode…") },
328346 enabled = enabled,
329347 maxLines = 6,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+11
@@ -76,12 +76,21 @@ class ChatViewModel(
7676 private val _failedDraft = MutableStateFlow<String?>(null)
7777 val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
7878
79+ /** Per-session draft, persisted so it survives navigation/process death. */
80+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
81+ val draft: StateFlow<String> = _draft.asStateFlow()
82+
7983 fun clearError() { _error.value = null }
8084
8185 fun selectModel(option: ModelOption) { _selectedModel.value = option }
8286
8387 fun selectAgent(name: String?) { _selectedAgent.value = name }
8488
89+ fun updateDraft(text: String) {
90+ _draft.value = text
91+ container.draftStore.set(sessionId, text)
92+ }
93+
8594 init {
8695 // Watch the bus for run completion / errors / permission asks for this session.
8796 val conn = connection
@@ -137,6 +146,7 @@ class ChatViewModel(
137146 _running.value = true
138147 _error.value = null
139148 _failedDraft.value = null
149+ updateDraft("")
140150 viewModelScope.launch {
141151 val ok = runCatching {
142152 conn.repository.sendPrompt(
@@ -149,6 +159,7 @@ class ChatViewModel(
149159 _running.value = false
150160 if (!ok) {
151161 _failedDraft.value = trimmed
162+ updateDraft(trimmed)
152163 _error.value = "Failed to send"
153164 }
154165 }
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+26−2
@@ -17,7 +17,9 @@ import androidx.compose.ui.draw.clip
1717import androidx.compose.ui.unit.dp
1818import soy.iko.opencode.data.model.AssistantMessage
1919import soy.iko.opencode.data.model.MessageWithParts
20+import soy.iko.opencode.data.model.Tokens
2021import soy.iko.opencode.data.model.UserMessage
22+import soy.iko.opencode.ui.components.relativeTime
2123
2224/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
2325@Composable
@@ -40,6 +42,7 @@ private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
4042 verticalArrangement = Arrangement.spacedBy(6.dp),
4143 ) {
4244 for (part in message.parts) PartView(part)
45+ MessageTimestamp(message.info)
4346 }
4447 }
4548}
@@ -66,8 +69,8 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
6669 val tokens = info.tokens
6770 if (info.isComplete && (cost != null || tokens != null)) {
6871 val parts = buildList {
69− tokens?.let { add("${it.input}→${it.output} tok") }
70− cost?.takeIf { it > 0 }?.let { add("$%.4f".format(it)) }
72+ tokens?.let { add(formatTokens(it)) }
73+ cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
7174 }
7275 if (parts.isNotEmpty()) {
7376 Text(
@@ -78,5 +81,26 @@ private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, modifi
7881 }
7982 }
8083 }
84+ MessageTimestamp(message.info)
8185 }
8286}
87+
88+@Composable
89+private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
90+ val t = relativeTime(info.time?.created)
91+ if (t.isNotEmpty()) {
92+ Text(
93+ t,
94+ style = MaterialTheme.typography.labelSmall,
95+ color = MaterialTheme.colorScheme.onSurfaceVariant,
96+ )
97+ }
98+}
99+
100+private fun formatTokens(tokens: Tokens): String {
101+ val dec = java.text.DecimalFormat("#,###")
102+ return "${dec.format(tokens.input)} in · ${dec.format(tokens.output)} out"
103+}
104+
105+private fun formatCost(cost: Double): String =
106+ if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost)
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+36−1
@@ -33,10 +33,13 @@ import androidx.compose.runtime.Composable
3333import androidx.compose.runtime.getValue
3434import androidx.compose.ui.Alignment
3535import androidx.compose.ui.Modifier
36+import androidx.compose.ui.graphics.Color
37+import androidx.compose.ui.text.font.FontFamily
3638import androidx.compose.ui.text.style.TextOverflow
3739import androidx.compose.ui.unit.dp
3840import androidx.lifecycle.compose.collectAsStateWithLifecycle
3941import androidx.lifecycle.viewmodel.compose.viewModel
42+import soy.iko.opencode.data.model.FileStatusEntry
4043import soy.iko.opencode.di.AppContainer
4144import soy.iko.opencode.ui.vmFactory
4245
@@ -164,6 +167,7 @@ private fun DirectoryListing(
164167 icon = node.isDirectory,
165168 label = node.name,
166169 onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
170+ status = state.statusMap[node.path],
167171 )
168172 HorizontalDivider()
169173 }
@@ -171,7 +175,12 @@ private fun DirectoryListing(
171175}
172176
173177@Composable
174−private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
178+private fun FileRow(
179+ icon: Boolean,
180+ label: String,
181+ onClick: () -> Unit,
182+ status: FileStatusEntry? = null,
183+) {
175184 Row(
176185 modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
177186 verticalAlignment = Alignment.CenterVertically,
@@ -187,6 +196,32 @@ private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
187196 style = MaterialTheme.typography.bodyLarge,
188197 maxLines = 1,
189198 overflow = TextOverflow.Ellipsis,
199+ modifier = Modifier.weight(1f),
200+ )
201+ if (status != null) StatusBadge(status)
202+ }
203+}
204+
205+@Composable
206+private fun StatusBadge(status: FileStatusEntry) {
207+ val (letter, color) = when (status.status) {
208+ "added" -> "A" to Color(0xFF4CAF50)
209+ "modified" -> "M" to Color(0xFFFFA000)
210+ "deleted" -> "D" to MaterialTheme.colorScheme.error
211+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
212+ }
213+ Text(
214+ letter,
215+ style = MaterialTheme.typography.labelMedium,
216+ fontFamily = FontFamily.Monospace,
217+ color = color,
218+ )
219+ if (status.added > 0 || status.removed > 0) {
220+ Text(
221+ " +${status.added} −${status.removed}",
222+ style = MaterialTheme.typography.labelSmall,
223+ fontFamily = FontFamily.Monospace,
224+ color = MaterialTheme.colorScheme.onSurfaceVariant,
190225 )
191226 }
192227}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+14−1
@@ -3,6 +3,7 @@ package soy.iko.opencode.ui.file
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.FileStatusEntry
67import soy.iko.opencode.di.AppContainer
78import kotlinx.coroutines.Job
89import kotlinx.coroutines.delay
@@ -14,6 +15,7 @@ import kotlinx.coroutines.launch
1415data class FileBrowserState(
1516 val path: String = "",
1617 val entries: List<FileNode> = emptyList(),
18+ val statusMap: Map<String, FileStatusEntry> = emptyMap(),
1719 val query: String = "",
1820 val results: List<String> = emptyList(),
1921 val searching: Boolean = false,
@@ -32,7 +34,18 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
3234
3335 private var searchJob: Job? = null
3436
35− init { open("") }
37+ init { open(""); loadStatus() }
38+
39+ /** Fetch the repo-wide VCS status once so file rows can show git badges. */
40+ private fun loadStatus() {
41+ val client = api ?: return
42+ viewModelScope.launch {
43+ runCatching { client.fileStatus() }
44+ .onSuccess { entries ->
45+ _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
46+ }
47+ }
48+ }
3649
3750 fun open(path: String) {
3851 val client = api ?: run {
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+26−9
@@ -6,12 +6,17 @@ import soy.iko.opencode.data.model.ServerProfile
66import soy.iko.opencode.data.model.Session
77import soy.iko.opencode.data.model.TextPart
88import soy.iko.opencode.di.AppContainer
9+import kotlinx.coroutines.Job
910import kotlinx.coroutines.flow.MutableStateFlow
1011import kotlinx.coroutines.flow.SharingStarted
1112import kotlinx.coroutines.flow.StateFlow
1213import kotlinx.coroutines.flow.asStateFlow
1314import kotlinx.coroutines.flow.stateIn
15+import kotlinx.coroutines.flow.update
16+import kotlinx.coroutines.joinAll
1417import kotlinx.coroutines.launch
18+import kotlinx.coroutines.sync.Semaphore
19+import kotlinx.coroutines.sync.withPermit
1520
1621data class SessionListState(
1722 val sessions: List<Session> = emptyList(),
@@ -52,6 +57,8 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
5257 private val _state = MutableStateFlow(SessionListState())
5358 val state: StateFlow<SessionListState> = _state.asStateFlow()
5459
60+ private var previewJob: Job? = null
61+
5562 private val activeProfileId: String?
5663 get() = container.activeConnection.value?.profile?.id
5764
@@ -82,21 +89,31 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
8289
8390 /** Fetch the last text part of each session for list previews (best-effort, bounded). */
8491 private fun loadPreviews(sessions: List<Session>) {
92+ previewJob?.cancel()
8593 val conn = container.activeConnection.value ?: return
8694 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
95+ _state.update { it.copy(previews = emptyMap()) }
96+ previewJob = viewModelScope.launch {
97+ val semaphore = Semaphore(MAX_CONCURRENT_PREVIEWS)
98+ sessions.take(50).map { session ->
99+ launch {
100+ semaphore.withPermit {
101+ val preview = runCatching {
102+ api.listMessages(session.id).lastOrNull()?.let { msg ->
103+ msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
104+ }
105+ }.getOrNull()?.takeIf { it.isNotBlank() } ?: return@withPermit
106+ _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
93107 }
94− }.getOrNull()?.takeIf { p -> p.isNotBlank() } ?: return@launch
95− _state.value = _state.value.copy(previews = _state.value.previews + (session.id to preview))
96− }
108+ }
109+ }.joinAll()
97110 }
98111 }
99112
113+ private companion object {
114+ const val MAX_CONCURRENT_PREVIEWS = 8
115+ }
116+
100117 fun createSession(onCreated: (String) -> Unit) {
101118 val conn = container.activeConnection.value ?: return
102119 viewModelScope.launch {