git.iko.soy/opencode-android
on master help sign in
Implement all 12 features: attachments, revert, voice, search, sharing, share-target, summarize, mDNS discovery, sub-session tree, init/shell, biometric lock, background notifications
commitc0621ff7727fba2a159ade8639c7756fafd070bd authoriko <mail@iko.soy> dateThu 2 Jul 2026, 07:20 (3 months ago) parentea52e62
31 files changed+1920−121
app/build.gradle.kts+1 app/src/main/AndroidManifest.xml+26 app/src/main/java/soy/iko/opencode/MainActivity.kt+46−10 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+47−5 app/src/main/java/soy/iko/opencode/data/model/Search.kt+90 app/src/main/java/soy/iko/opencode/data/model/Session.kt+25 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+10 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt+109 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+88−1 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+12 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt+181 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+25−5 app/src/main/java/soy/iko/opencode/ui/Routes.kt+5−1 app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.kt+12 app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt+62 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+436−54 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+201−7 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+51−25 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+172−5 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+76−1 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+17−2 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+63 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+44−2 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+37−1 app/src/main/res/values/strings.xml+47 app/src/main/res/xml/file_paths.xml+6 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+2 config/detekt/detekt.yml+8 gradle/libs.versions.toml+2
app/build.gradle.kts+1
@@ -131,6 +131,7 @@ dependencies {
131131
132132 implementation(libs.androidx.datastore.preferences)
133133 implementation(libs.androidx.security.crypto)
134+ implementation(libs.androidx.biometric)
134135 implementation(libs.coil.compose)
135136 implementation(libs.markdown.renderer)
136137 implementation(libs.markdown.renderer.m3)
app/src/main/AndroidManifest.xml+26
@@ -8,6 +8,10 @@
88 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
99 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
1010
11+ <!-- Camera is used opportunistically for attachment capture via the system camera app
12+ (ACTION_IMAGE_CAPTURE), so it's optional — devices without a camera still install. -->
13+ <uses-feature android:name="android.hardware.camera.any" android:required="false" />
14+
1115 <application
1216 android:name=".OpencodeApp"
1317 android:allowBackup="false"
@@ -36,6 +40,17 @@
3640 <category android:name="android.intent.category.DEFAULT" />
3741 <data android:mimeType="text/plain" />
3842 </intent-filter>
43+ <!-- Receive images shared from other apps (single or multiple) to attach to a prompt. -->
44+ <intent-filter>
45+ <action android:name="android.intent.action.SEND" />
46+ <category android:name="android.intent.category.DEFAULT" />
47+ <data android:mimeType="image/*" />
48+ </intent-filter>
49+ <intent-filter>
50+ <action android:name="android.intent.action.SEND_MULTIPLE" />
51+ <category android:name="android.intent.category.DEFAULT" />
52+ <data android:mimeType="image/*" />
53+ </intent-filter>
3954 <!-- Deep link to a session: opencode://session/{sessionId} -->
4055 <intent-filter>
4156 <action android:name="android.intent.action.VIEW" />
@@ -49,6 +64,17 @@
4964 android:name=".notification.RunForegroundService"
5065 android:exported="false"
5166 android:foregroundServiceType="dataSync" />
67+
68+ <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
69+ <provider
70+ android:name="androidx.core.content.FileProvider"
71+ android:authorities="${applicationId}.fileprovider"
72+ android:exported="false"
73+ android:grantUriPermissions="true">
74+ <meta-data
75+ android:name="android.support.FILE_PROVIDER_PATHS"
76+ android:resource="@xml/file_paths" />
77+ </provider>
5278 </application>
5379
5480</manifest>
app/src/main/java/soy/iko/opencode/MainActivity.kt+46−10
@@ -3,9 +3,12 @@ package soy.iko.opencode
33import android.Manifest
44import android.content.Intent
55import android.content.pm.PackageManager
6+import android.net.Uri
67import android.os.Build
78import android.os.Bundle
8−import androidx.activity.ComponentActivity
9+import android.view.WindowManager
10+import androidx.core.content.IntentCompat
11+import soy.iko.opencode.data.network.NetworkConfig
912import androidx.activity.compose.setContent
1013import androidx.activity.enableEdgeToEdge
1114import androidx.activity.result.contract.ActivityResultContracts
@@ -16,12 +19,17 @@ import androidx.compose.runtime.remember
1619import kotlinx.coroutines.flow.combine
1720import androidx.core.content.ContextCompat
1821import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22+import androidx.fragment.app.FragmentActivity
1923import androidx.lifecycle.compose.collectAsStateWithLifecycle
2024import soy.iko.opencode.data.repo.ThemeMode
25+import soy.iko.opencode.ui.AppLockGate
2126import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
2227import soy.iko.opencode.ui.theme.OpencodeTheme
2328
24−class MainActivity : ComponentActivity() {
29+// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
30+// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
31+// /activity-result APIs are unaffected.
32+class MainActivity : FragmentActivity() {
2533
2634 private val requestNotificationPermission = registerForActivityResult(
2735 ActivityResultContracts.RequestPermission(),
@@ -51,7 +59,14 @@ class MainActivity : ComponentActivity() {
5159 container.settingsStore.themeMode
5260 .combine(container.settingsStore.dynamicColor, ::Pair)
5361 }.collectAsStateWithLifecycle(initialValue = null)
62+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
5463 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
64+ // Hide the app's content from screenshots / the recents thumbnail while app lock
65+ // is enabled, so protected server details don't leak there.
66+ LaunchedEffect(appLock) {
67+ if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
68+ else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
69+ }
5570 theme?.let { (themeMode, dynamicColor) ->
5671 val dark = when (themeMode) {
5772 ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -59,7 +74,9 @@ class MainActivity : ComponentActivity() {
5974 ThemeMode.DARK -> true
6075 }
6176 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
62− OpencodeAppUi(container = container)
77+ AppLockGate(enabled = appLock) {
78+ OpencodeAppUi(container = container)
79+ }
6380 }
6481 }
6582 }
@@ -77,13 +94,32 @@ class MainActivity : ComponentActivity() {
7794 * and session ids from notification taps / deep links so we can open them. */
7895 private fun handleIntent(intent: Intent?) {
7996 val container = (application as OpencodeApp).container
80− if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
81− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82− ?.takeIf { it.isNotBlank() }
83− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
84− if (text != null) {
85− container.setPendingShare(text)
86− shareIntentHandled = true
97+ val action = intent?.action
98+ if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
99+ val type = intent.type.orEmpty()
100+ if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
101+ // A single shared image → stage as an attachment in the next opened session.
102+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
103+ if (uri != null) {
104+ container.setPendingSharedMedia(listOf(uri.toString()))
105+ shareIntentHandled = true
106+ }
107+ } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
108+ val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
109+ ?.take(NetworkConfig.maxAttachments)
110+ .orEmpty()
111+ if (uris.isNotEmpty()) {
112+ container.setPendingSharedMedia(uris.map { it.toString() })
113+ shareIntentHandled = true
114+ }
115+ } else {
116+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
117+ ?.takeIf { it.isNotBlank() }
118+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
119+ if (text != null) {
120+ container.setPendingShare(text)
121+ shareIntentHandled = true
122+ }
87123 }
88124 }
89125 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+47−5
@@ -1,5 +1,6 @@
11package soy.iko.opencode.data.model
22
3+import kotlinx.serialization.SerialName
34import kotlinx.serialization.Serializable
45
56/** Body for `POST /session/:id/message`. */
@@ -10,12 +11,31 @@ data class PromptRequest(
1011 val agent: String? = null,
1112)
1213
13−/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14+/**
15+ * An input part for a prompt — a discriminated union on `type` (the global class
16+ * discriminator). We compose text and file (image/document attachment) parts; the server
17+ * models more (agent, subtask) that this client doesn't send.
18+ */
1419@Serializable
15−data class PromptPart(
16− val type: String = "text",
17− val text: String,
18−)
20+sealed interface PromptPart
21+
22+@Serializable
23+@SerialName("text")
24+data class TextPromptPart(val text: String) : PromptPart
25+
26+/**
27+ * A file/image attachment. [url] must be a data URL carrying the base64 payload —
28+ * `data:<mime>;base64,<bytes>` — which is how the server embeds media for the model to
29+ * see (see opencode's message-v2.ts). [mime] must match the payload; the server forwards
30+ * it as the model's mediaType.
31+ */
32+@Serializable
33+@SerialName("file")
34+data class FilePromptPart(
35+ val mime: String,
36+ val url: String,
37+ val filename: String? = null,
38+) : PromptPart
1939
2040/** Body for `POST /session/:id/command`. */
2141@Serializable
@@ -24,3 +44,25 @@ data class CommandRequest(
2444 val arguments: String = "",
2545 val agent: String? = null,
2646)
47+
48+/** Body for `POST /session/:id/revert`. */
49+@Serializable
50+data class RevertRequest(
51+ val messageID: String,
52+ val partID: String? = null,
53+)
54+
55+/** Body for `POST /session/:id/summarize`. */
56+@Serializable
57+data class SummarizeRequest(
58+ val providerID: String,
59+ val modelID: String,
60+)
61+
62+/** Body for `POST /session/:id/shell`. */
63+@Serializable
64+data class ShellRequest(
65+ val agent: String,
66+ val command: String,
67+ val model: ModelRef? = null,
68+)
app/src/main/java/soy/iko/opencode/data/model/Search.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+
7+/**
8+ * One ripgrep match from `GET /find?pattern=` (content search). [path] and [lines] are
9+ * wrapped `{ text }` objects on the wire; [submatches] carry the column offsets of each
10+ * hit within the line so the UI can highlight them.
11+ */
12+@Immutable
13+@Serializable
14+data class FindMatch(
15+ val path: TextField = TextField(),
16+ val lines: TextField = TextField(),
17+ @SerialName("line_number") val lineNumber: Int = 0,
18+ @SerialName("absolute_offset") val absoluteOffset: Long = 0,
19+ val submatches: List<SubMatch> = emptyList(),
20+) {
21+ @Immutable
22+ @Serializable
23+ data class TextField(val text: String = "")
24+
25+ @Immutable
26+ @Serializable
27+ data class SubMatch(
28+ val start: Int = 0,
29+ val end: Int = 0,
30+ )
31+
32+ /** The matched file's path (relative to the search root). */
33+ val filePath: String get() = path.text
34+
35+ /** The matching line's text, trimmed of a trailing newline for display. */
36+ val lineText: String get() = lines.text.trimEnd('\n', '\r')
37+}
38+
39+/**
40+ * One workspace symbol from `GET /find/symbol?query=`. [kind] is the LSP `SymbolKind`
41+ * integer; [location.uri] is a file URI and [location.range] the symbol's span.
42+ */
43+@Immutable
44+@Serializable
45+data class SymbolResult(
46+ val name: String = "",
47+ val kind: Int = 0,
48+ val location: Location = Location(),
49+) {
50+ @Immutable
51+ @Serializable
52+ data class Location(
53+ val uri: String = "",
54+ val range: Range = Range(),
55+ )
56+
57+ @Immutable
58+ @Serializable
59+ data class Range(
60+ val start: Pos = Pos(),
61+ val end: Pos = Pos(),
62+ )
63+
64+ @Immutable
65+ @Serializable
66+ data class Pos(
67+ val line: Int = 0,
68+ val character: Int = 0,
69+ )
70+
71+ /** The file path portion of [Location.uri], stripping a `file://` scheme when present. */
72+ val filePath: String
73+ get() = location.uri.removePrefix("file://").ifBlank { location.uri }
74+
75+ /** 1-based line for display; the wire range is 0-based. */
76+ val displayLine: Int get() = location.range.start.line + 1
77+}
78+
79+// LSP SymbolKind → label. A map lookup (rather than a large `when`) keeps the accessor
80+// trivial. Indices follow the LSP SymbolKind enum.
81+private val symbolKindLabels = mapOf(
82+ 1 to "file", 2 to "module", 3 to "namespace", 4 to "package", 5 to "class",
83+ 6 to "method", 7 to "property", 8 to "field", 9 to "constructor", 10 to "enum",
84+ 11 to "interface", 12 to "function", 13 to "variable", 14 to "constant", 15 to "string",
85+ 16 to "number", 17 to "boolean", 18 to "array", 19 to "object", 20 to "key",
86+ 22 to "enum member", 23 to "struct", 26 to "type",
87+)
88+
89+/** Human-readable label for an LSP [SymbolResult.kind]. */
90+fun symbolKindLabel(kind: Int): String = symbolKindLabels[kind] ?: "symbol"
app/src/main/java/soy/iko/opencode/data/model/Session.kt+25
@@ -17,6 +17,10 @@ data class Session(
1717 // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
1818 val directory: String? = null,
1919 val projectID: String? = null,
20+ // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
21+ val share: Share? = null,
22+ // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
23+ val revert: Revert? = null,
2024) {
2125 val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
2226
@@ -24,6 +28,27 @@ data class Session(
2428 * or the full path when it has no separators. Null/blank when no directory is set. */
2529 val displayDirectory: String?
2630 get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
31+
32+ /** True while the session has an active public share link. */
33+ val isShared: Boolean get() = !share?.url.isNullOrBlank()
34+
35+ /** True while a revert checkpoint is active (messages after it are hidden server-side). */
36+ val isReverted: Boolean get() = revert != null
37+
38+ /** Public share link for the session, from `POST /session/:id/share`. */
39+ @Immutable
40+ @Serializable
41+ data class Share(val url: String = "")
42+
43+ /** The active revert checkpoint, from `POST /session/:id/revert`. */
44+ @Immutable
45+ @Serializable
46+ data class Revert(
47+ val messageID: String = "",
48+ val partID: String? = null,
49+ val snapshot: String? = null,
50+ val diff: String? = null,
51+ )
2752}
2853
2954/** Body for `POST /session`. */
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+10
@@ -84,6 +84,16 @@ object NetworkConfig {
8484 /** Debounce delay before firing a file-search request after the user stops typing. */
8585 const val fileSearchDebounceMs = 250L
8686
87+ // --- Attachments (chat composer) ---
88+
89+ /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
90+ * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
91+ * finitely to avoid OOM / oversized requests. */
92+ const val maxAttachmentBytes = 10L * 1024 * 1024
93+
94+ /** Max number of attachments staged for one prompt, to bound memory and request size. */
95+ const val maxAttachments = 8
96+
8797 // --- StateFlow sharing (WhileSubscribed) ---
8898
8999 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.network
2+
3+import android.content.Context
4+import android.net.nsd.NsdManager
5+import android.net.nsd.NsdServiceInfo
6+import android.util.Log
7+import kotlinx.coroutines.channels.Channel
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.channels.awaitClose
10+import kotlinx.coroutines.flow.callbackFlow
11+import kotlinx.coroutines.launch
12+import kotlinx.coroutines.suspendCancellableCoroutine
13+import kotlin.coroutines.resume
14+
15+/** A server discovered on the local network via mDNS/DNS-SD. */
16+data class DiscoveredServer(val name: String, val host: String, val port: Int) {
17+ val baseUrl: String get() = "http://$host:$port"
18+}
19+
20+/**
21+ * Discovers opencode servers advertised on the LAN. opencode's `--mdns` publishes a DNS-SD
22+ * `_http._tcp` service named `opencode-<port>` (with a `path=/` TXT record), so we browse
23+ * that service type and keep only names that look like opencode. Requires no extra
24+ * permission — [NsdManager] manages the multicast lock internally.
25+ */
26+open class NsdDiscovery(context: Context?) {
27+
28+ protected constructor() : this(null)
29+
30+ private val appContext = context?.applicationContext
31+
32+ /**
33+ * A cold [Flow] that starts a discovery when collected and stops it when the collector
34+ * cancels. Emits the growing/shrinking set of discovered servers. Resolutions are
35+ * serialized through a channel because [NsdManager.resolveService] can only process one
36+ * at a time on older platforms (a concurrent resolve fails with ALREADY_ACTIVE).
37+ */
38+ open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
39+ val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
40+ if (nsd == null) {
41+ trySend(emptyList())
42+ close()
43+ return@callbackFlow
44+ }
45+ // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
46+ val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
47+ val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
48+
49+ val resolver = launch {
50+ for (info in toResolve) {
51+ val resolved = resolveService(nsd, info) ?: continue
52+ val name = resolved.serviceName.orEmpty()
53+ // Keep only opencode's own advertisements, not every _http._tcp service
54+ // (printers, routers, other web servers) on the network.
55+ if (!name.startsWith("opencode", ignoreCase = true)) continue
56+ @Suppress("DEPRECATION")
57+ val host = resolved.host?.hostAddress ?: continue
58+ found[name] = DiscoveredServer(name = name, host = host, port = resolved.port)
59+ trySend(found.values.sortedBy { it.name })
60+ }
61+ }
62+
63+ val listener = object : NsdManager.DiscoveryListener {
64+ override fun onDiscoveryStarted(serviceType: String?) {}
65+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
66+ toResolve.trySend(serviceInfo)
67+ }
68+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
69+ val name = serviceInfo.serviceName ?: return
70+ if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
71+ }
72+ override fun onDiscoveryStopped(serviceType: String?) {}
73+ override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
74+ Log.w(TAG, "mDNS discovery start failed: $errorCode")
75+ close()
76+ }
77+ override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
78+ }
79+
80+ runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
81+ .onFailure { close(it) }
82+
83+ awaitClose {
84+ resolver.cancel()
85+ toResolve.close()
86+ runCatching { nsd.stopServiceDiscovery(listener) }
87+ }
88+ }
89+
90+ private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
91+ suspendCancellableCoroutine { cont ->
92+ val listener = object : NsdManager.ResolveListener {
93+ override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
94+ if (cont.isActive) cont.resume(null)
95+ }
96+ override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
97+ if (cont.isActive) cont.resume(serviceInfo)
98+ }
99+ }
100+ runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
101+ .onFailure { if (cont.isActive) cont.resume(null) }
102+ }
103+
104+ private companion object {
105+ const val TAG = "NsdDiscovery"
106+ // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
107+ const val SERVICE_TYPE = "_http._tcp."
108+ }
109+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+88−1
@@ -10,13 +10,20 @@ import soy.iko.opencode.data.model.FileStatusEntry
1010import soy.iko.opencode.data.model.MessageWithParts
1111import soy.iko.opencode.data.model.ModelRef
1212import soy.iko.opencode.data.model.PathInfo
13+import soy.iko.opencode.data.model.FilePromptPart
14+import soy.iko.opencode.data.model.FindMatch
1315import soy.iko.opencode.data.model.PermissionReplyBody
1416import soy.iko.opencode.data.model.PermissionResponse
1517import soy.iko.opencode.data.model.Project
1618import soy.iko.opencode.data.model.PromptPart
1719import soy.iko.opencode.data.model.PromptRequest
1820import soy.iko.opencode.data.model.ProvidersResponse
21+import soy.iko.opencode.data.model.RevertRequest
1922import soy.iko.opencode.data.model.Session
23+import soy.iko.opencode.data.model.ShellRequest
24+import soy.iko.opencode.data.model.SummarizeRequest
25+import soy.iko.opencode.data.model.SymbolResult
26+import soy.iko.opencode.data.model.TextPromptPart
2027import soy.iko.opencode.data.model.UpdateSessionRequest
2128import io.ktor.client.HttpClient
2229import io.ktor.client.call.body
@@ -132,6 +139,7 @@ open class OpencodeApiClient private constructor(
132139 open suspend fun sendPrompt(
133140 sessionId: String,
134141 text: String,
142+ attachments: List<FilePromptPart> = emptyList(),
135143 model: ModelRef? = null,
136144 agent: String? = null,
137145 ): MessageWithParts {
@@ -140,13 +148,20 @@ open class OpencodeApiClient private constructor(
140148 // each attempt would get a fresh UUID and the server couldn't deduplicate a
141149 // request that reached it but whose response was lost (e.g. timeout).
142150 val idempotencyKey = java.util.UUID.randomUUID().toString()
151+ // A text part is only included when there's actual text, so an image-only prompt
152+ // (attachments, empty text) doesn't send an empty text part. Attachments follow
153+ // the text so the model reads the instruction first.
154+ val parts = buildList<PromptPart> {
155+ if (text.isNotEmpty()) add(TextPromptPart(text))
156+ addAll(attachments)
157+ }
143158 return withRetry {
144159 client!!.post("session/${encode(sessionId)}/message") {
145160 contentType(ContentType.Application.Json)
146161 header("Idempotency-Key", idempotencyKey)
147162 setBody(
148163 PromptRequest(
149− parts = listOf(PromptPart(text = text)),
164+ parts = parts,
150165 model = model,
151166 agent = agent,
152167 ),
@@ -179,6 +194,68 @@ open class OpencodeApiClient private constructor(
179194 }
180195 }
181196
197+ /** Revert the session to just before [messageId] (optionally part [partId]); the
198+ * server hides everything after the checkpoint. Returns the updated session. Naturally
199+ * idempotent (reverting to the same point twice yields the same state). */
200+ open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
201+ client!!.post("session/${encode(sessionId)}/revert") {
202+ contentType(ContentType.Application.Json)
203+ setBody(RevertRequest(messageID = messageId, partID = partId))
204+ }.body()
205+ }
206+
207+ /** Undo the active revert checkpoint, restoring the hidden messages. */
208+ open suspend fun unrevert(sessionId: String): Session = withRetry {
209+ client!!.post("session/${encode(sessionId)}/unrevert").body()
210+ }
211+
212+ /** Create a public share link for the session. Returns the session with [Session.share] set. */
213+ open suspend fun shareSession(sessionId: String): Session = withRetry {
214+ client!!.post("session/${encode(sessionId)}/share").body()
215+ }
216+
217+ /** Revoke the session's public share link. Returns the session with share cleared. */
218+ open suspend fun unshareSession(sessionId: String): Session = withRetry {
219+ client!!.delete("session/${encode(sessionId)}/share").body()
220+ }
221+
222+ /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
223+ * summary streams back via SSE like any run; this just triggers it. */
224+ open suspend fun summarize(sessionId: String, model: ModelRef) {
225+ val idempotencyKey = java.util.UUID.randomUUID().toString()
226+ withRetry {
227+ client!!.post("session/${encode(sessionId)}/summarize") {
228+ contentType(ContentType.Application.Json)
229+ header("Idempotency-Key", idempotencyKey)
230+ setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
231+ }.body<String>()
232+ }
233+ }
234+
235+ /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
236+ * assigns the message id and uses its default model; the run streams back via SSE. */
237+ open suspend fun initSession(sessionId: String) {
238+ val idempotencyKey = java.util.UUID.randomUUID().toString()
239+ withRetry {
240+ client!!.post("session/${encode(sessionId)}/init") {
241+ header("Idempotency-Key", idempotencyKey)
242+ }.body<String>()
243+ }
244+ }
245+
246+ /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
247+ * [agent] scopes the run (the server requires it). */
248+ open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
249+ val idempotencyKey = java.util.UUID.randomUUID().toString()
250+ withRetry {
251+ client!!.post("session/${encode(sessionId)}/shell") {
252+ contentType(ContentType.Application.Json)
253+ header("Idempotency-Key", idempotencyKey)
254+ setBody(ShellRequest(agent = agent, command = command, model = model))
255+ }.body<String>()
256+ }
257+ }
258+
182259 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
183260 val now = System.currentTimeMillis()
184261 val cached = cachedProviders
@@ -258,6 +335,16 @@ open class OpencodeApiClient private constructor(
258335 client!!.get("find/file") { parameter("query", query) }.body()
259336 }
260337
338+ /** Ripgrep content search across the project (`GET /find?pattern=`). */
339+ open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
340+ client!!.get("find") { parameter("pattern", pattern) }.body()
341+ }
342+
343+ /** LSP workspace symbol search (`GET /find/symbol?query=`). */
344+ open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
345+ client!!.get("find/symbol") { parameter("query", query) }.body()
346+ }
347+
261348 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
262349 client!!.get("file") { parameter("path", path) }.body()
263350 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2
@@ -1,6 +1,7 @@
11package soy.iko.opencode.data.repo
22
33import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.FilePromptPart
45import soy.iko.opencode.data.model.MessageInfo
56import soy.iko.opencode.data.model.MessagePartRemoved
67import soy.iko.opencode.data.model.MessagePartUpdated
@@ -44,8 +45,13 @@ open class SessionRepository(
4445 open suspend fun deleteSession(id: String) = api.deleteSession(id)
4546 open suspend fun abort(sessionId: String) = api.abort(sessionId)
4647
47− open suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
48− api.sendPrompt(sessionId, text, model, agent)
48+ open suspend fun sendPrompt(
49+ sessionId: String,
50+ text: String,
51+ attachments: List<FilePromptPart> = emptyList(),
52+ model: ModelRef?,
53+ agent: String? = null,
54+ ) = api.sendPrompt(sessionId, text, attachments, model, agent)
4955
5056 open suspend fun runCommand(
5157 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+12
@@ -19,6 +19,7 @@ class SettingsStore(context: Context) {
1919 private val themeKey = stringPreferencesKey("theme_mode")
2020 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
2121 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22+ private val appLockKey = booleanPreferencesKey("app_lock")
2223
2324 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
2425 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -36,6 +37,13 @@ class SettingsStore(context: Context) {
3637 prefs[sendOnEnterKey] ?: true
3738 }
3839
40+ /** When true, the app requires device biometric/credential authentication on launch
41+ * (and when returning from the background) before the UI is shown, protecting the
42+ * stored server credentials. Defaults to false so the app is usable out of the box. */
43+ val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
44+ prefs[appLockKey] ?: false
45+ }
46+
3947 suspend fun setThemeMode(mode: ThemeMode) {
4048 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
4149 }
@@ -47,4 +55,8 @@ class SettingsStore(context: Context) {
4755 suspend fun setSendOnEnter(enabled: Boolean) {
4856 appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
4957 }
58+
59+ suspend fun setAppLock(enabled: Boolean) {
60+ appContext.settingsDataStore.edit { it[appLockKey] = enabled }
61+ }
5062}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11
@@ -106,6 +106,17 @@ open class AppContainer private constructor(
106106 return if (_pendingShare.compareAndSet(current, null)) current else null
107107 }
108108
109+ /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
110+ * opened session. Consumed once by the first ChatScreen to compose after the share. */
111+ private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
112+ open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
113+ open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
114+ open fun consumePendingSharedMedia(): List<String> {
115+ val current = _pendingSharedMedia.value
116+ if (current.isEmpty()) return emptyList()
117+ return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
118+ }
119+
109120 /**
110121 * A session id to open from an external trigger (a notification tap or a deep link).
111122 * The nav host consumes it once a connection is active.
app/src/main/java/soy/iko/opencode/ui/AppLockGate.ktnew file+181
@@ -0,0 +1,181 @@
1+package soy.iko.opencode.ui
2+
3+import android.content.Context
4+import android.content.ContextWrapper
5+import android.os.Build
6+import androidx.biometric.BiometricManager
7+import androidx.biometric.BiometricPrompt
8+import androidx.compose.foundation.interaction.MutableInteractionSource
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.layout.Arrangement
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.Column
13+import androidx.compose.foundation.layout.fillMaxSize
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Lock
18+import androidx.compose.material3.Button
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.MaterialTheme
21+import androidx.compose.material3.Surface
22+import androidx.compose.material3.Text
23+import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.DisposableEffect
25+import androidx.compose.runtime.LaunchedEffect
26+import androidx.compose.runtime.getValue
27+import androidx.compose.runtime.mutableStateOf
28+import androidx.compose.runtime.remember
29+import androidx.compose.runtime.saveable.rememberSaveable
30+import androidx.compose.runtime.setValue
31+import androidx.compose.ui.Alignment
32+import androidx.compose.ui.Modifier
33+import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.res.stringResource
35+import androidx.compose.ui.unit.dp
36+import androidx.core.content.ContextCompat
37+import androidx.fragment.app.FragmentActivity
38+import androidx.lifecycle.Lifecycle
39+import androidx.lifecycle.LifecycleEventObserver
40+import androidx.lifecycle.compose.LocalLifecycleOwner
41+import soy.iko.opencode.R
42+
43+/**
44+ * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
45+ * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
46+ * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
47+ */
48+private val lockAuthenticators: Int
49+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
50+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
51+ } else {
52+ BiometricManager.Authenticators.BIOMETRIC_STRONG
53+ }
54+
55+/** Whether device authentication is currently possible, so Settings can gate the toggle. */
56+fun canAuthenticateForAppLock(context: Context): Boolean =
57+ BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
58+
59+/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
60+private fun Context.findFragmentActivity(): FragmentActivity? {
61+ var ctx: Context? = this
62+ while (ctx is ContextWrapper) {
63+ if (ctx is FragmentActivity) return ctx
64+ ctx = ctx.baseContext
65+ }
66+ return null
67+}
68+
69+/**
70+ * Gates [content] behind device authentication when [enabled]. The content stays composed
71+ * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
72+ * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
73+ * device can't authenticate, the gate is a no-op — the setting is only offered when
74+ * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
75+ */
76+@Composable
77+fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
78+ if (!enabled) {
79+ content()
80+ return
81+ }
82+ val context = LocalContext.current
83+ val activity = remember(context) { context.findFragmentActivity() }
84+ if (activity == null) {
85+ content()
86+ return
87+ }
88+
89+ // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
90+ // defaults to locked on a fresh start.
91+ var unlocked by rememberSaveable { mutableStateOf(false) }
92+
93+ // Re-lock when the app is backgrounded so returning to it requires authentication again.
94+ val lifecycleOwner = LocalLifecycleOwner.current
95+ DisposableEffect(lifecycleOwner) {
96+ val observer = LifecycleEventObserver { _, event ->
97+ if (event == Lifecycle.Event.ON_STOP) unlocked = false
98+ }
99+ lifecycleOwner.lifecycle.addObserver(observer)
100+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
101+ }
102+
103+ val title = stringResource(R.string.app_lock_prompt_title)
104+ val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
105+
106+ // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
107+ // BiometricPrompts, which throws.
108+ var authInFlight by remember { mutableStateOf(false) }
109+
110+ fun authenticate() {
111+ if (authInFlight || unlocked) return
112+ authInFlight = true
113+ val executor = ContextCompat.getMainExecutor(activity)
114+ val callback = object : BiometricPrompt.AuthenticationCallback() {
115+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
116+ authInFlight = false
117+ unlocked = true
118+ }
119+
120+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
121+ // User cancelled / lockout / hardware error: stay locked and let them retry
122+ // via the button rather than looping the system prompt.
123+ authInFlight = false
124+ }
125+
126+ override fun onAuthenticationFailed() {
127+ // A single non-matching attempt; the system UI lets the user try again.
128+ }
129+ }
130+ val info = BiometricPrompt.PromptInfo.Builder()
131+ .setTitle(title)
132+ .setSubtitle(subtitle)
133+ .setAllowedAuthenticators(lockAuthenticators)
134+ .build()
135+ runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
136+ .onFailure { authInFlight = false }
137+ }
138+
139+ // Auto-prompt on first show and whenever we return to the locked state.
140+ LaunchedEffect(unlocked) { if (!unlocked) authenticate() }
141+
142+ Box(modifier = Modifier.fillMaxSize()) {
143+ content()
144+ if (!unlocked) {
145+ // Opaque overlay covering the content. The no-op clickable swallows touches so
146+ // the hidden UI beneath can't be interacted with while locked.
147+ val interaction = remember { MutableInteractionSource() }
148+ Surface(
149+ modifier = Modifier
150+ .fillMaxSize()
151+ .clickable(interactionSource = interaction, indication = null) {},
152+ color = MaterialTheme.colorScheme.background,
153+ ) {
154+ Column(
155+ modifier = Modifier.fillMaxSize().padding(32.dp),
156+ horizontalAlignment = Alignment.CenterHorizontally,
157+ verticalArrangement = Arrangement.Center,
158+ ) {
159+ Icon(
160+ Icons.Filled.Lock,
161+ contentDescription = null,
162+ modifier = Modifier.size(48.dp),
163+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
164+ )
165+ Text(
166+ stringResource(R.string.app_locked),
167+ style = MaterialTheme.typography.titleMedium,
168+ modifier = Modifier.padding(top = 16.dp),
169+ )
170+ Button(
171+ onClick = { authenticate() },
172+ enabled = !authInFlight,
173+ modifier = Modifier.padding(top = 24.dp),
174+ ) {
175+ Text(stringResource(R.string.app_unlock))
176+ }
177+ }
178+ }
179+ }
180+ }
181+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+25−5
@@ -39,9 +39,24 @@ fun OpencodeApp(container: AppContainer) {
3939 val navController = rememberNavController()
4040 val scope = rememberCoroutineScope()
4141 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
4243 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
4344 val connection by container.activeConnection.collectAsStateWithLifecycle()
4445
46+ // Hold a foreground priority for as long as ANY session is actively running — not just
47+ // while a running chat is on screen — so a run started and then backgrounded (or left
48+ // for the session list) still keeps the SSE stream alive to deliver its completion
49+ // notification. Doze/app-standby would otherwise choke the socket once priority drops.
50+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
51+ val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
52+ LaunchedEffect(anyRunActive) {
53+ if (anyRunActive) {
54+ soy.iko.opencode.notification.RunForegroundService.start(appContext)
55+ } else {
56+ soy.iko.opencode.notification.RunForegroundService.stop(appContext)
57+ }
58+ }
59+
4560 // When text is shared into the app, surface the session list so the user can pick
4661 // (or create) a conversation to drop it into. The chosen session's draft is set
4762 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
@@ -50,8 +65,8 @@ fun OpencodeApp(container: AppContainer) {
5065 // change) doesn't re-bounce the user to the session list for a share they've
5166 // already decided to abandon; only the null -> non-null initial connect retries.
5267 var lastConnectionId by remember { mutableStateOf<String?>(null) }
53− LaunchedEffect(pendingShare, connection) {
54− if (pendingShare == null) return@LaunchedEffect
68+ LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
69+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
5570 if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
5671 val currentId = connection?.profile?.id
5772 // Only (re)navigate on the initial null -> non-null connect. A subsequent
@@ -194,18 +209,23 @@ fun OpencodeApp(container: AppContainer) {
194209 composable(Routes.FILES) {
195210 FileBrowserScreen(
196211 container = container,
197− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
212+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
198213 onBack = { navController.popBackStack() },
199214 )
200215 }
201216
202217 composable(
203− route = "${Routes.FILE_VIEW}?path={path}",
204− arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
218+ route = "${Routes.FILE_VIEW}?path={path}&line={line}",
219+ arguments = listOf(
220+ navArgument("path") { type = NavType.StringType; defaultValue = "" },
221+ navArgument("line") { type = NavType.IntType; defaultValue = -1 },
222+ ),
205223 ) { entry ->
224+ val line = entry.arguments?.getInt("line") ?: -1
206225 FileViewScreen(
207226 container = container,
208227 path = entry.arguments?.getString("path").orEmpty(),
228+ initialLine = line.takeIf { it > 0 },
209229 onBack = { navController.popBackStack() },
210230 )
211231 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+5−1
@@ -19,5 +19,9 @@ object Routes {
1919 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
2020
2121 const val FILE_VIEW = "file_view"
22− fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
22+ fun fileView(path: String, line: Int? = null): String {
23+ val base = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
24+ // A positive [line] scrolls the viewer to that 1-based line (from a search hit).
25+ return if (line != null && line > 0) "$base&line=$line" else base
26+ }
2327}
app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.ktnew file+12
@@ -0,0 +1,12 @@
1+package soy.iko.opencode.ui.chat
2+
3+/** Outcome of turning a picked Uri into a [PendingAttachment] (see `Uri.toAttachmentResult`). */
4+sealed interface AttachmentResult {
5+ data class Ok(val attachment: PendingAttachment) : AttachmentResult
6+
7+ /** The file exceeded the size cap ([soy.iko.opencode.data.network.NetworkConfig.maxAttachmentBytes]). */
8+ data object TooLarge : AttachmentResult
9+
10+ /** The file couldn't be read/resolved. */
11+ data object Failed : AttachmentResult
12+}
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.ktnew file+62
@@ -0,0 +1,62 @@
1+package soy.iko.opencode.ui.chat
2+
3+import android.content.Context
4+import android.net.Uri
5+import android.provider.OpenableColumns
6+import android.util.Base64
7+import androidx.core.content.FileProvider
8+import soy.iko.opencode.data.model.FilePromptPart
9+import soy.iko.opencode.data.network.NetworkConfig
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.withContext
12+import java.io.File
13+
14+/**
15+ * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
16+ * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
17+ * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
18+ */
19+suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
20+ val resolver = context.contentResolver
21+ val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
22+ val name = displayName(context) ?: defaultName(mime)
23+ val bytes = runCatching {
24+ resolver.openInputStream(this@toAttachmentResult)?.use { it.readBytes() }
25+ }.getOrNull() ?: return@withContext AttachmentResult.Failed
26+ if (bytes.size > NetworkConfig.maxAttachmentBytes) return@withContext AttachmentResult.TooLarge
27+ val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
28+ val dataUrl = "data:$mime;base64,$b64"
29+ AttachmentResult.Ok(
30+ PendingAttachment(
31+ id = java.util.UUID.randomUUID().toString(),
32+ name = name,
33+ mime = mime,
34+ previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
35+ part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
36+ ),
37+ )
38+}
39+
40+/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
41+private fun Uri.displayName(context: Context): String? = runCatching {
42+ context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
43+ val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
44+ if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
45+ }
46+}.getOrNull()?.takeIf { it.isNotBlank() }
47+
48+private fun defaultName(mime: String): String {
49+ val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
50+ return "attachment.$ext"
51+}
52+
53+/**
54+ * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
55+ * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
56+ * created. The file is overwritten on each capture; the OS cleans the cache dir.
57+ */
58+fun newCameraCaptureUri(context: Context): Uri? = runCatching {
59+ val dir = File(context.cacheDir, "captures").apply { mkdirs() }
60+ val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
61+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
62+}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+436−54
@@ -1,7 +1,16 @@
11package soy.iko.opencode.ui.chat
22
3+import android.app.Activity
4+import android.content.Intent
5+import android.net.Uri
6+import android.speech.RecognizerIntent
37import androidx.activity.compose.BackHandler
8+import androidx.activity.compose.rememberLauncherForActivityResult
9+import androidx.activity.result.PickVisualMediaRequest
10+import androidx.activity.result.contract.ActivityResultContracts
411import androidx.compose.animation.AnimatedVisibility
12+import androidx.compose.foundation.horizontalScroll
13+import androidx.compose.foundation.rememberScrollState
514import androidx.compose.foundation.clickable
615import androidx.compose.foundation.layout.Arrangement
716import androidx.compose.foundation.layout.Box
@@ -17,6 +26,9 @@ import androidx.compose.foundation.layout.imePadding
1726import androidx.compose.foundation.layout.navigationBars
1827import androidx.compose.foundation.layout.padding
1928import androidx.compose.foundation.layout.size
29+import androidx.compose.foundation.layout.widthIn
30+import androidx.compose.ui.draw.clip
31+import androidx.compose.ui.layout.ContentScale
2032import androidx.compose.foundation.layout.union
2133import androidx.compose.foundation.layout.windowInsetsPadding
2234import androidx.compose.foundation.lazy.LazyColumn
@@ -25,12 +37,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
2537import androidx.compose.material.icons.Icons
2638import androidx.compose.material.icons.automirrored.filled.ArrowBack
2739import androidx.compose.material.icons.automirrored.filled.Send
40+import androidx.compose.material.icons.filled.Add
2841import androidx.compose.material.icons.filled.AutoAwesome
42+import androidx.compose.material.icons.filled.Close
2943import androidx.compose.material.icons.filled.Delete
44+import androidx.compose.material.icons.filled.Description
3045import androidx.compose.material.icons.filled.Edit
3146import androidx.compose.material.icons.filled.KeyboardArrowDown
47+import androidx.compose.material.icons.filled.Mic
3248import androidx.compose.material.icons.filled.MoreVert
49+import androidx.compose.material.icons.filled.PhotoCamera
50+import androidx.compose.material.icons.filled.PhotoLibrary
3351import androidx.compose.material.icons.filled.Refresh
52+import androidx.compose.material.icons.filled.Restore
3453import androidx.compose.material.icons.filled.Stop
3554import androidx.compose.material3.AlertDialog
3655import androidx.compose.material3.Button
@@ -91,6 +110,7 @@ import androidx.compose.ui.text.style.TextOverflow
91110import androidx.compose.ui.unit.dp
92111import androidx.lifecycle.compose.collectAsStateWithLifecycle
93112import androidx.lifecycle.viewmodel.compose.viewModel
113+import coil.compose.AsyncImage
94114import kotlinx.coroutines.Dispatchers
95115import kotlinx.coroutines.launch
96116import kotlinx.coroutines.withContext
@@ -107,6 +127,7 @@ import soy.iko.opencode.data.network.NetworkConfig
107127import soy.iko.opencode.di.AppContainer
108128import soy.iko.opencode.R
109129import soy.iko.opencode.ui.components.ConnectionBanner
130+import soy.iko.opencode.ui.components.copyToClipboard
110131import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111132import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112133import soy.iko.opencode.ui.components.showToast
@@ -145,6 +166,9 @@ fun ChatScreen(
145166 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146167 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147168 val draft by vm.draft.collectAsStateWithLifecycle()
169+ val attachments by vm.attachments.collectAsStateWithLifecycle()
170+ val reverted by vm.reverted.collectAsStateWithLifecycle()
171+ val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
148172 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149173 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150174 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -201,18 +225,18 @@ fun ChatScreen(
201225 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202226 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203227 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
228+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
204229
205− // Keep the screen awake and hold a foreground priority while the agent is working,
206− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
230+ // Keep the screen awake while the agent is working in *this* session. The foreground
231+ // service that holds process priority during a run is managed app-wide off
232+ // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
233+ // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
234+ // SSE stream before the completion notification fires.
207235 val currentView = LocalView.current
208236 val appContext = LocalContext.current.applicationContext
209237 DisposableEffect(running) {
210238 currentView.keepScreenOn = running
211− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
212− onDispose {
213− currentView.keepScreenOn = false
214− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
215− }
239+ onDispose { currentView.keepScreenOn = false }
216240 }
217241
218242 LaunchedEffect(Unit) {
@@ -228,6 +252,104 @@ fun ChatScreen(
228252 }
229253 }
230254
255+ // --- Attachments, voice, and share-link plumbing ---
256+ val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
257+ val attachFailedMsg = stringResource(R.string.attachment_failed)
258+ val attachLimitMsg = stringResource(R.string.attachment_limit)
259+ val noCameraMsg = stringResource(R.string.no_camera_app)
260+ val noVoiceMsg = stringResource(R.string.no_voice_app)
261+ val voicePrompt = stringResource(R.string.voice_prompt)
262+ val linkCopiedMsg = stringResource(R.string.link_copied)
263+
264+ // Convert each picked Uri to a base64 attachment off the main thread, honoring the
265+ // per-prompt count cap and surfacing per-file errors without aborting the batch.
266+ fun stageUris(uris: List<Uri>) {
267+ if (uris.isEmpty()) return
268+ scope.launch {
269+ for (uri in uris) {
270+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
271+ snackbar.showSnackbar(attachLimitMsg)
272+ break
273+ }
274+ when (val result = uri.toAttachmentResult(appContext)) {
275+ is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
276+ AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
277+ AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
278+ }
279+ }
280+ }
281+ }
282+
283+ val photoPicker = rememberLauncherForActivityResult(
284+ ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
285+ ) { uris -> stageUris(uris) }
286+
287+ val filePicker = rememberLauncherForActivityResult(
288+ ActivityResultContracts.GetMultipleContents(),
289+ ) { uris -> stageUris(uris) }
290+
291+ // Uri survives a config change mid-capture so the TakePicture result still resolves.
292+ var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
293+ val cameraLauncher = rememberLauncherForActivityResult(
294+ ActivityResultContracts.TakePicture(),
295+ ) { success ->
296+ val uri = pendingCaptureUri
297+ pendingCaptureUri = null
298+ if (success && uri != null) stageUris(listOf(uri))
299+ }
300+
301+ val voiceLauncher = rememberLauncherForActivityResult(
302+ ActivityResultContracts.StartActivityForResult(),
303+ ) { result ->
304+ if (result.resultCode == Activity.RESULT_OK) {
305+ val spoken = result.data
306+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
307+ ?.firstOrNull()
308+ ?.takeIf { it.isNotBlank() }
309+ if (spoken != null) {
310+ // Append to the existing draft rather than replacing, so dictation adds to
311+ // whatever the user already typed.
312+ val current = vm.draft.value
313+ val combined = if (current.isBlank()) spoken else "$current $spoken"
314+ vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
315+ }
316+ }
317+ }
318+
319+ fun launchCamera() {
320+ val uri = newCameraCaptureUri(appContext)
321+ if (uri == null) {
322+ scope.launch { snackbar.showSnackbar(attachFailedMsg) }
323+ return
324+ }
325+ pendingCaptureUri = uri
326+ runCatching { cameraLauncher.launch(uri) }
327+ .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
328+ }
329+
330+ fun launchVoice() {
331+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
332+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
333+ putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
334+ }
335+ runCatching { voiceLauncher.launch(intent) }
336+ .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
337+ }
338+
339+ // A freshly-created share link is copied to the clipboard so the user can paste it right away.
340+ LaunchedEffect(Unit) {
341+ vm.shareLinkEvents.collect { url ->
342+ copyToClipboard(shareContext, "share", url)
343+ snackbar.showSnackbar(linkCopiedMsg)
344+ }
345+ }
346+
347+ // Stage images shared into the app (via the system share sheet) as attachments, once.
348+ LaunchedEffect(Unit) {
349+ val shared = container.consumePendingSharedMedia()
350+ if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
351+ }
352+
231353 fun doSend() {
232354 if (vm.send(draft)) {
233355 // TextHandleMove (a light tick) is semantically right for a send commit;
@@ -331,6 +453,12 @@ fun ChatScreen(
331453 val modelLabel = stringResource(R.string.choose_model)
332454 val renameLabel = stringResource(R.string.rename_session_chat)
333455 val deleteLabel = stringResource(R.string.delete_session_chat)
456+ val createShareLabel = stringResource(R.string.create_share_link)
457+ val copyShareLabel = stringResource(R.string.copy_share_link)
458+ val stopShareLabel = stringResource(R.string.stop_sharing)
459+ val summarizeLabel = stringResource(R.string.summarize_conversation)
460+ val initLabel = stringResource(R.string.generate_agents_md)
461+ val shellLabel = stringResource(R.string.run_shell_command)
334462 Box {
335463 IconButton(onClick = { showOverflowMenu = true }) {
336464 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -374,6 +502,45 @@ fun ChatScreen(
374502 onClick = { showOverflowMenu = false; showModelPicker = true },
375503 )
376504 androidx.compose.material3.HorizontalDivider()
505+ // Session sharing: create a public link, copy an existing one, or revoke it.
506+ if (shareUrl == null) {
507+ DropdownMenuItem(
508+ text = { Text(createShareLabel) },
509+ enabled = hasMessages,
510+ onClick = { showOverflowMenu = false; vm.shareSession() },
511+ )
512+ } else {
513+ DropdownMenuItem(
514+ text = { Text(copyShareLabel) },
515+ onClick = {
516+ showOverflowMenu = false
517+ shareUrl?.let {
518+ copyToClipboard(shareContext, "share", it)
519+ scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
520+ }
521+ },
522+ )
523+ DropdownMenuItem(
524+ text = { Text(stopShareLabel) },
525+ onClick = { showOverflowMenu = false; vm.unshareSession() },
526+ )
527+ }
528+ DropdownMenuItem(
529+ text = { Text(summarizeLabel) },
530+ enabled = hasMessages && !running,
531+ onClick = { showOverflowMenu = false; vm.summarize() },
532+ )
533+ DropdownMenuItem(
534+ text = { Text(initLabel) },
535+ enabled = !running,
536+ onClick = { showOverflowMenu = false; vm.initProject() },
537+ )
538+ DropdownMenuItem(
539+ text = { Text(shellLabel) },
540+ enabled = !running,
541+ onClick = { showOverflowMenu = false; showShellDialog = true },
542+ )
543+ androidx.compose.material3.HorizontalDivider()
377544 DropdownMenuItem(
378545 text = { Text(renameLabel) },
379546 onClick = { showOverflowMenu = false; showRenameDialog = true },
@@ -407,20 +574,35 @@ fun ChatScreen(
407574 snackbarHost = { SnackbarHost(snackbar) },
408575 bottomBar = {
409576 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
410− ChatInputBar(
411− value = draft,
412− onValueChange = vm::updateDraft,
413− running = running,
414− aborting = aborting,
415− enabled = activeConnection != null,
416− sendOnEnter = sendOnEnter,
417− onSend = ::doSend,
418− onAbort = { showStopConfirm = true },
419− queuedFollowUp = queuedFollowUp,
420− onQueueFollowUp = vm::queueFollowUp,
421− onCancelQueue = { vm.queueFollowUp("") },
422− focusRequester = inputFocusRequester,
423− )
577+ Column {
578+ AnimatedVisibility(visible = reverted) {
579+ RevertBanner(onUndo = { vm.unrevert() })
580+ }
581+ ChatInputBar(
582+ value = draft,
583+ onValueChange = vm::updateDraft,
584+ running = running,
585+ aborting = aborting,
586+ enabled = activeConnection != null,
587+ sendOnEnter = sendOnEnter,
588+ onSend = ::doSend,
589+ onAbort = { showStopConfirm = true },
590+ queuedFollowUp = queuedFollowUp,
591+ onQueueFollowUp = vm::queueFollowUp,
592+ onCancelQueue = { vm.queueFollowUp("") },
593+ focusRequester = inputFocusRequester,
594+ attachments = attachments,
595+ onRemoveAttachment = vm::removeAttachment,
596+ onPickPhoto = {
597+ photoPicker.launch(
598+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
599+ )
600+ },
601+ onPickFile = { filePicker.launch("*/*") },
602+ onCamera = { launchCamera() },
603+ onVoice = { launchVoice() },
604+ )
605+ }
424606 },
425607 ) { padding ->
426608 // Collect messages inside the content lambda so streaming token updates
@@ -647,6 +829,7 @@ fun ChatScreen(
647829 imageContext = imageContext,
648830 modelLabel = modelLabel,
649831 onOpenFile = onOpenFile,
832+ onRevert = { vm.revertTo(message.info.id) },
650833 )
651834 }
652835 }
@@ -758,6 +941,16 @@ fun ChatScreen(
758941 )
759942 }
760943
944+ if (showShellDialog) {
945+ ShellCommandDialog(
946+ onDismiss = { showShellDialog = false },
947+ onRun = { cmd ->
948+ showShellDialog = false
949+ vm.runShell(cmd)
950+ },
951+ )
952+ }
953+
761954 if (showModelPicker) {
762955 ModelPickerSheet(
763956 options = models,
@@ -815,7 +1008,15 @@ private fun ChatInputBar(
8151008 onQueueFollowUp: (String) -> Unit,
8161009 onCancelQueue: () -> Unit,
8171010 focusRequester: androidx.compose.ui.focus.FocusRequester,
1011+ attachments: List<PendingAttachment>,
1012+ onRemoveAttachment: (String) -> Unit,
1013+ onPickPhoto: () -> Unit,
1014+ onPickFile: () -> Unit,
1015+ onCamera: () -> Unit,
1016+ onVoice: () -> Unit,
8181017) {
1018+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1019+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
8191020 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
8201021 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
8211022 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -846,10 +1047,52 @@ private fun ChatInputBar(
8461047 }
8471048 }
8481049 }
1050+ // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1051+ if (attachments.isNotEmpty()) {
1052+ Row(
1053+ modifier = Modifier
1054+ .fillMaxWidth()
1055+ .horizontalScroll(rememberScrollState())
1056+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
1057+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1058+ ) {
1059+ attachments.forEach { att ->
1060+ AttachmentChip(att, onRemove = { onRemoveAttachment(att.id) })
1061+ }
1062+ }
1063+ }
8491064 Row(
8501065 modifier = Modifier.fillMaxWidth().padding(8.dp),
8511066 verticalAlignment = Alignment.Bottom,
8521067 ) {
1068+ // Attach menu: photo picker, camera, or any file.
1069+ var showAttachMenu by remember { mutableStateOf(false) }
1070+ Box {
1071+ IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1072+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1073+ }
1074+ DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1075+ DropdownMenuItem(
1076+ leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1077+ text = { Text(stringResource(R.string.attach_photo)) },
1078+ onClick = { showAttachMenu = false; onPickPhoto() },
1079+ )
1080+ DropdownMenuItem(
1081+ leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1082+ text = { Text(stringResource(R.string.attach_camera)) },
1083+ onClick = { showAttachMenu = false; onCamera() },
1084+ )
1085+ DropdownMenuItem(
1086+ leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1087+ text = { Text(stringResource(R.string.attach_file)) },
1088+ onClick = { showAttachMenu = false; onPickFile() },
1089+ )
1090+ }
1091+ }
1092+ // Voice dictation: appends recognized speech to the draft.
1093+ IconButton(onClick = onVoice, enabled = enabled) {
1094+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1095+ }
8531096 OutlinedTextField(
8541097 value = value,
8551098 onValueChange = { v ->
@@ -865,11 +1108,13 @@ private fun ChatInputBar(
8651108 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
8661109 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
8671110 // With it off, Enter inserts a newline and Ctrl+Enter sends.
868− val send = when {
869− !enabled || value.isBlank() -> false
870− sendOnEnter -> !event.isShiftPressed
871− else -> event.isCtrlPressed
872− }
1111+ val send = enterShouldSend(
1112+ enabled = enabled,
1113+ hasContent = hasContent,
1114+ sendOnEnter = sendOnEnter,
1115+ shift = event.isShiftPressed,
1116+ ctrl = event.isCtrlPressed,
1117+ )
8731118 if (send) {
8741119 if (running) onQueueFollowUp(value) else onSend()
8751120 true
@@ -904,40 +1149,73 @@ private fun ChatInputBar(
9041149 capitalization = KeyboardCapitalization.Sentences,
9051150 ),
9061151 keyboardActions = KeyboardActions(onSend = {
907− if (enabled && value.isNotBlank()) {
1152+ if (enabled && hasContent) {
9081153 if (running) onQueueFollowUp(value) else onSend()
9091154 }
9101155 }),
9111156 )
912− if (running) {
913− // Show a spinner while the abort REST call is in flight so the user
914− // sees the stop was sent, and disable to prevent a double-tap.
915− IconButton(
916− onClick = onAbort,
917− enabled = !aborting,
918− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
919− ) {
920− if (aborting) {
921− val stopLabel = stringResource(R.string.stop)
922− CircularProgressIndicator(
923− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924− strokeWidth = 2.dp,
925− )
926− } else {
927− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
928− }
929− }
930− } else {
931− IconButton(
932− onClick = onSend,
933− enabled = enabled && value.isNotBlank(),
934− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
935− ) {
936− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
937− }
938− }
1157+ ComposerTrailingButton(
1158+ running = running,
1159+ aborting = aborting,
1160+ canSend = enabled && hasContent,
1161+ onSend = onSend,
1162+ onAbort = onAbort,
1163+ )
1164+ }
1165+ }
1166+ }
1167+}
1168+
1169+/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
1170+ * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
1171+private fun enterShouldSend(
1172+ enabled: Boolean,
1173+ hasContent: Boolean,
1174+ sendOnEnter: Boolean,
1175+ shift: Boolean,
1176+ ctrl: Boolean,
1177+): Boolean = when {
1178+ !enabled || !hasContent -> false
1179+ sendOnEnter -> !shift
1180+ else -> ctrl
1181+}
1182+
1183+/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
1184+ * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1185+@Composable
1186+private fun ComposerTrailingButton(
1187+ running: Boolean,
1188+ aborting: Boolean,
1189+ canSend: Boolean,
1190+ onSend: () -> Unit,
1191+ onAbort: () -> Unit,
1192+) {
1193+ if (running) {
1194+ // Show a spinner while the abort REST call is in flight so the user sees the stop
1195+ // was sent, and disable to prevent a double-tap.
1196+ IconButton(
1197+ onClick = onAbort,
1198+ enabled = !aborting,
1199+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1200+ ) {
1201+ if (aborting) {
1202+ val stopLabel = stringResource(R.string.stop)
1203+ CircularProgressIndicator(
1204+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1205+ strokeWidth = 2.dp,
1206+ )
1207+ } else {
1208+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
9391209 }
9401210 }
1211+ } else {
1212+ IconButton(
1213+ onClick = onSend,
1214+ enabled = canSend,
1215+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1216+ ) {
1217+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1218+ }
9411219 }
9421220}
9431221
@@ -997,6 +1275,110 @@ private fun EmptyConversation(
9971275 }
9981276}
9991277
1278+/** A staged attachment: an image thumbnail (or a generic file icon) with its name and a
1279+ * remove button, shown in the composer above the input field. */
1280+@Composable
1281+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
1282+ Surface(
1283+ shape = MaterialTheme.shapes.small,
1284+ color = MaterialTheme.colorScheme.surfaceVariant,
1285+ tonalElevation = 1.dp,
1286+ ) {
1287+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
1288+ if (attachment.previewModel != null) {
1289+ AsyncImage(
1290+ model = attachment.previewModel,
1291+ contentDescription = attachment.name,
1292+ contentScale = ContentScale.Crop,
1293+ modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
1294+ )
1295+ } else {
1296+ Icon(
1297+ Icons.Filled.Description,
1298+ contentDescription = null,
1299+ modifier = Modifier.size(24.dp),
1300+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1301+ )
1302+ }
1303+ Text(
1304+ attachment.name,
1305+ style = MaterialTheme.typography.labelMedium,
1306+ maxLines = 1,
1307+ overflow = TextOverflow.Ellipsis,
1308+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
1309+ )
1310+ IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
1311+ Icon(
1312+ Icons.Filled.Close,
1313+ contentDescription = stringResource(R.string.remove),
1314+ modifier = Modifier.size(16.dp),
1315+ )
1316+ }
1317+ }
1318+ }
1319+}
1320+
1321+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1322+@Composable
1323+private fun RevertBanner(onUndo: () -> Unit) {
1324+ Surface(
1325+ color = MaterialTheme.colorScheme.tertiaryContainer,
1326+ tonalElevation = 2.dp,
1327+ modifier = Modifier.fillMaxWidth(),
1328+ ) {
1329+ Row(
1330+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1331+ verticalAlignment = Alignment.CenterVertically,
1332+ horizontalArrangement = Arrangement.SpaceBetween,
1333+ ) {
1334+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1335+ Icon(
1336+ Icons.Filled.Restore,
1337+ contentDescription = null,
1338+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
1339+ modifier = Modifier.size(18.dp),
1340+ )
1341+ Text(
1342+ stringResource(R.string.reverted_banner),
1343+ style = MaterialTheme.typography.bodySmall,
1344+ color = MaterialTheme.colorScheme.onTertiaryContainer,
1345+ modifier = Modifier.padding(start = 8.dp),
1346+ )
1347+ }
1348+ TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1349+ }
1350+ }
1351+}
1352+
1353+/** Prompt for a one-off shell command to run in the session's worktree. */
1354+@Composable
1355+private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1356+ var command by rememberSaveable { mutableStateOf("") }
1357+ AlertDialog(
1358+ onDismissRequest = onDismiss,
1359+ title = { Text(stringResource(R.string.run_shell_command)) },
1360+ text = {
1361+ OutlinedTextField(
1362+ value = command,
1363+ onValueChange = { command = it },
1364+ modifier = Modifier.fillMaxWidth(),
1365+ placeholder = { Text(stringResource(R.string.shell_command_hint)) },
1366+ label = { Text(stringResource(R.string.shell_command_label)) },
1367+ singleLine = true,
1368+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1369+ keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1370+ )
1371+ },
1372+ confirmButton = {
1373+ TextButton(
1374+ onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1375+ enabled = command.isNotBlank(),
1376+ ) { Text(stringResource(R.string.run)) }
1377+ },
1378+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1379+ )
1380+}
1381+
10001382/**
10011383 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
10021384 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+201−7
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
55import soy.iko.opencode.data.model.Agent
66import soy.iko.opencode.data.model.Command
7+import soy.iko.opencode.data.model.FilePromptPart
78import soy.iko.opencode.data.model.MessageWithParts
89import soy.iko.opencode.data.model.ModelOption
910import soy.iko.opencode.data.model.Permission
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.merge
4546import kotlinx.coroutines.flow.onEach
4647import kotlinx.coroutines.flow.retryWhen
4748import kotlinx.coroutines.flow.stateIn
49+import kotlinx.coroutines.flow.update
4850import kotlinx.coroutines.delay
4951import kotlinx.coroutines.launch
5052
@@ -56,6 +58,23 @@ import kotlinx.coroutines.launch
5658 */
5759data class ChatError(val message: String, val retryable: Boolean = false)
5860
61+/**
62+ * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
63+ * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
64+ * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
65+ * the server.
66+ */
67+@androidx.compose.runtime.Immutable
68+data class PendingAttachment(
69+ val id: String,
70+ val name: String,
71+ val mime: String,
72+ val previewModel: Any?,
73+ val part: FilePromptPart,
74+) {
75+ val isImage: Boolean get() = mime.startsWith("image/")
76+}
77+
5978@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
6079class ChatViewModel(
6180 private val container: AppContainer,
@@ -245,6 +264,28 @@ class ChatViewModel(
245264 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
246265 val draft: StateFlow<String> = _draft.asStateFlow()
247266
267+ /** Attachments staged for the next prompt (images/files). In-memory only — a huge base64
268+ * payload isn't worth persisting across process death, and the source Uris wouldn't
269+ * survive anyway. Cleared on a successful send. */
270+ private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
271+ val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
272+
273+ /** True while a revert checkpoint is active for this session (messages after it are hidden
274+ * server-side). Drives the "reverted" banner with its Undo. */
275+ private val _reverted = MutableStateFlow(false)
276+ val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
277+
278+ /** The active public share URL for this session, or null when not shared. */
279+ private val _shareUrl = MutableStateFlow<String?>(null)
280+ val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
281+
282+ /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
283+ private val _shareLinkEvents = Channel<String>(
284+ capacity = NetworkConfig.snackbarEventBufferCapacity,
285+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
286+ )
287+ val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
288+
248289 init {
249290 // DraftStore loads SharedPreferences asynchronously; on a cold start the
250291 // synchronous get() above returns "" until the background load completes.
@@ -415,7 +456,7 @@ class ChatViewModel(
415456 val queued = _queuedFollowUp.value
416457 if (queued != null) {
417458 setQueuedFollowUp(null)
418− send(queued)
459+ send(queued, includeAttachments = false)
419460 }
420461 }
421462 if (SessionRepository.isError(event, sessionId)) {
@@ -436,7 +477,12 @@ class ChatViewModel(
436477 is PermissionReplied ->
437478 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
438479 is SessionUpdated ->
439− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
480+ if (event.properties.info.id == sessionId) {
481+ val info = event.properties.info
482+ _sessionTitle.value = info.displayTitle
483+ _reverted.value = info.isReverted
484+ _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
485+ }
440486 is SessionDeleted ->
441487 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
442488 _sessionDeleted.value = true
@@ -503,7 +549,11 @@ class ChatViewModel(
503549 runCatchingCancellable { conn.repository.listSessions() }
504550 .getOrNull()
505551 ?.firstOrNull { it.id == sessionId }
506− ?.let { _sessionTitle.value = it.displayTitle }
552+ ?.let { session ->
553+ _sessionTitle.value = session.displayTitle
554+ _reverted.value = session.isReverted
555+ _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
556+ }
507557 }
508558 }
509559 // If the SSE stream drops mid-run, the run indicator would spin forever;
@@ -527,12 +577,25 @@ class ChatViewModel(
527577 }
528578 }
529579
530− /** Sends [text]; returns true on success so the caller can clear the draft only then. */
531− fun send(text: String): Boolean {
580+ /** Sends [text] together with any staged attachments; returns true on success so the
581+ * caller can clear the draft only then. */
582+ fun send(text: String): Boolean = send(text, includeAttachments = true)
583+
584+ /**
585+ * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
586+ * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
587+ */
588+ private fun send(text: String, includeAttachments: Boolean): Boolean {
532589 val conn = connection ?: return false
533590 val trimmed = text.trim()
534− if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
591+ val attachments = if (includeAttachments) _attachments.value else emptyList()
592+ // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
593+ // attached is not.
594+ if ((trimmed.isEmpty() && attachments.isEmpty()) || !_running.compareAndSet(false, true)) return false
535595 _failedDraft.value = null
596+ // Clear the staged attachments optimistically so the composer empties immediately;
597+ // restore them if the send fails (mirrors the draft handling below).
598+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
536599 // Clear the in-memory draft for the UI immediately, but don't persist the clear
537600 // yet — if the send fails and the process dies before we restore, the draft
538601 // would be lost forever. The persisted draft is cleared only on success.
@@ -551,6 +614,7 @@ class ChatViewModel(
551614 conn.repository.sendPrompt(
552615 sessionId,
553616 trimmed,
617+ attachments = attachments.map { it.part },
554618 model = _selectedModel.value?.ref,
555619 agent = _selectedAgent.value,
556620 )
@@ -559,6 +623,9 @@ class ChatViewModel(
559623 _failedDraft.value = trimmed
560624 // Only restore the draft if the user hasn't typed anything new since.
561625 if (_draft.value.isBlank()) updateDraft(trimmed)
626+ // Restore the staged attachments too, so a failed send doesn't lose them —
627+ // but only if the user hasn't staged new ones in the meantime.
628+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
562629 // Retryable: this is the failed send whose prompt Retry re-submits.
563630 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
564631 _running.value = false
@@ -585,7 +652,9 @@ class ChatViewModel(
585652 val draft = _failedDraft.value ?: return
586653 // Don't clear _failedDraft until send() accepts the text — if _running is
587654 // already true, send() returns false and the draft would be lost forever.
588− if (send(draft)) {
655+ // Attachments aren't re-sent on retry (they were optimistically restored to the
656+ // composer on the original failure, so they'll ride the next manual send).
657+ if (send(draft, includeAttachments = false)) {
589658 _failedDraft.value = null
590659 }
591660 }
@@ -675,6 +744,125 @@ class ChatViewModel(
675744 }
676745 }
677746
747+ fun addAttachment(attachment: PendingAttachment) {
748+ _attachments.update { it + attachment }
749+ }
750+
751+ fun removeAttachment(id: String) {
752+ _attachments.update { list -> list.filterNot { it.id == id } }
753+ }
754+
755+ fun clearAttachments() {
756+ _attachments.value = emptyList()
757+ }
758+
759+ /** Revert the conversation to just before [messageId], hiding everything after it. The
760+ * authoritative state also arrives via SessionUpdated; we flag it here for immediate
761+ * feedback. */
762+ fun revertTo(messageId: String) {
763+ val conn = connection ?: return
764+ viewModelScope.launch {
765+ runCatchingCancellable { conn.api.revert(sessionId, messageId) }
766+ .onSuccess { _reverted.value = it.isReverted }
767+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
768+ }
769+ }
770+
771+ /** Undo the active revert checkpoint, restoring the hidden messages. */
772+ fun unrevert() {
773+ val conn = connection ?: return
774+ viewModelScope.launch {
775+ runCatchingCancellable { conn.api.unrevert(sessionId) }
776+ .onSuccess { _reverted.value = it.isReverted }
777+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
778+ }
779+ }
780+
781+ /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
782+ * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
783+ fun shareSession() {
784+ val conn = connection ?: return
785+ viewModelScope.launch {
786+ runCatchingCancellable { conn.api.shareSession(sessionId) }
787+ .onSuccess { session ->
788+ val url = session.share?.url?.takeIf { it.isNotBlank() }
789+ _shareUrl.value = url
790+ if (url != null) _shareLinkEvents.trySend(url)
791+ }
792+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
793+ }
794+ }
795+
796+ /** Revoke the session's public share link. */
797+ fun unshareSession() {
798+ val conn = connection ?: return
799+ viewModelScope.launch {
800+ runCatchingCancellable { conn.api.unshareSession(sessionId) }
801+ .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
802+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
803+ }
804+ }
805+
806+ /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
807+ * Uses the currently-selected model (required by the endpoint). */
808+ fun summarize() {
809+ val conn = connection ?: return
810+ val model = _selectedModel.value?.ref ?: run {
811+ _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
812+ return
813+ }
814+ if (!_running.compareAndSet(false, true)) {
815+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
816+ return
817+ }
818+ viewModelScope.launch {
819+ runCatchingCancellable { conn.api.summarize(sessionId, model) }
820+ .onFailure {
821+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
822+ _running.value = false
823+ }
824+ }
825+ }
826+
827+ /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
828+ fun initProject() {
829+ val conn = connection ?: return
830+ if (!_running.compareAndSet(false, true)) {
831+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
832+ return
833+ }
834+ viewModelScope.launch {
835+ runCatchingCancellable { conn.api.initSession(sessionId) }
836+ .onFailure {
837+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
838+ _running.value = false
839+ }
840+ }
841+ }
842+
843+ /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
844+ * The server requires an agent to scope the run — use the selected one, else the primary. */
845+ fun runShell(command: String) {
846+ val conn = connection ?: return
847+ val cmd = command.trim()
848+ if (cmd.isEmpty()) return
849+ val agent = _selectedAgent.value
850+ ?: _agents.value.firstOrNull { it.isPrimary }?.name
851+ ?: _agents.value.firstOrNull()?.name
852+ ?: DEFAULT_SHELL_AGENT
853+ if (!_running.compareAndSet(false, true)) {
854+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
855+ return
856+ }
857+ viewModelScope.launch {
858+ runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
859+ .onFailure {
860+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
861+ _running.value = false
862+ }
863+ }
864+ }
865+
678866 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
679867 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
680868 * the caller keeps the dialog open so the user can retry without retyping. */
@@ -751,6 +939,12 @@ class ChatViewModel(
751939 }
752940 }
753941
942+ private companion object {
943+ /** Fallback agent for the shell endpoint when none is selected and no agent catalog
944+ * loaded. opencode's default primary agent is "build". */
945+ const val DEFAULT_SHELL_AGENT = "build"
946+ }
947+
754948 override fun onCleared() {
755949 super.onCleared()
756950 // Flush any pending debounced draft so it survives navigation. Uses an
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+51−25
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.size
1010import androidx.compose.material.icons.Icons
1111import androidx.compose.material.icons.automirrored.filled.HelpOutline
1212import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material.icons.filled.Restore
1314import androidx.compose.material3.Icon
1415import androidx.compose.material3.IconButton
1516import androidx.compose.material3.MaterialTheme
@@ -58,11 +59,28 @@ fun MessageBubble(
5859 imageContext: ImageLoadContext? = null,
5960 modelLabel: String? = null,
6061 onOpenFile: ((String) -> Unit)? = null,
62+ onRevert: (() -> Unit)? = null,
6163) {
6264 when (message.info) {
63− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
65+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert)
6466 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
65− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
67+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert)
68+ }
69+}
70+
71+/** A small "revert to before this message" icon button, shown next to Copy when a revert
72+ * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
73+ * immediately without a confirmation dialog. */
74+@Composable
75+private fun RevertButton(onRevert: () -> Unit) {
76+ val label = stringResource(R.string.revert_to_here)
77+ IconButton(onClick = onRevert) {
78+ Icon(
79+ Icons.Filled.Restore,
80+ contentDescription = label,
81+ modifier = Modifier.size(18.dp),
82+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
83+ )
6684 }
6785}
6886
@@ -107,6 +125,7 @@ private fun UserBubble(
107125 imageContext: ImageLoadContext?,
108126 modifier: Modifier,
109127 onOpenFile: ((String) -> Unit)? = null,
128+ onRevert: (() -> Unit)? = null,
110129) {
111130 val context = LocalContext.current
112131 val copyLabel = stringResource(R.string.copy)
@@ -134,19 +153,22 @@ private fun UserBubble(
134153 verticalAlignment = Alignment.CenterVertically,
135154 ) {
136155 MessageTimestampText(message.info)
137− // Copy affordance for user prompts, mirroring the assistant block.
138− // Without it, reusing a prior prompt requires discovering the
139− // long-press on the markdown text (and only TextParts support that).
140− if (textToCopy != null) {
141− IconButton(
142− onClick = { copyToClipboard(context, "message", textToCopy) },
143− ) {
144− Icon(
145− Icons.Filled.ContentCopy,
146− contentDescription = copyLabel,
147− modifier = Modifier.size(18.dp),
148− tint = MaterialTheme.colorScheme.onSurfaceVariant,
149− )
156+ Row(verticalAlignment = Alignment.CenterVertically) {
157+ onRevert?.let { RevertButton(it) }
158+ // Copy affordance for user prompts, mirroring the assistant block.
159+ // Without it, reusing a prior prompt requires discovering the
160+ // long-press on the markdown text (and only TextParts support that).
161+ if (textToCopy != null) {
162+ IconButton(
163+ onClick = { copyToClipboard(context, "message", textToCopy) },
164+ ) {
165+ Icon(
166+ Icons.Filled.ContentCopy,
167+ contentDescription = copyLabel,
168+ modifier = Modifier.size(18.dp),
169+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
170+ )
171+ }
150172 }
151173 }
152174 }
@@ -162,6 +184,7 @@ private fun AssistantBlock(
162184 modifier: Modifier,
163185 modelLabel: String? = null,
164186 onOpenFile: ((String) -> Unit)? = null,
187+ onRevert: (() -> Unit)? = null,
165188) {
166189 Column(
167190 modifier = modifier.fillMaxWidth(),
@@ -229,16 +252,19 @@ private fun AssistantBlock(
229252 .joinToString("\n\n") { it.text }
230253 .takeIf { it.isNotBlank() }
231254 }
232− if (textToCopy != null) {
233− IconButton(
234− onClick = { copyToClipboard(context, "message", textToCopy) },
235− ) {
236− Icon(
237− Icons.Filled.ContentCopy,
238− contentDescription = copyLabel,
239− modifier = Modifier.size(18.dp),
240− tint = MaterialTheme.colorScheme.onSurfaceVariant,
241− )
255+ Row(verticalAlignment = Alignment.CenterVertically) {
256+ onRevert?.let { RevertButton(it) }
257+ if (textToCopy != null) {
258+ IconButton(
259+ onClick = { copyToClipboard(context, "message", textToCopy) },
260+ ) {
261+ Icon(
262+ Icons.Filled.ContentCopy,
263+ contentDescription = copyLabel,
264+ modifier = Modifier.size(18.dp),
265+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
266+ )
267+ }
242268 }
243269 }
244270 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+172−5
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.file
22
33import androidx.compose.foundation.clickable
44import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
56import androidx.compose.foundation.layout.Box
67import androidx.compose.foundation.layout.Column
78import androidx.compose.foundation.layout.Row
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.padding
1314import androidx.compose.foundation.layout.size
1415import androidx.compose.foundation.lazy.LazyColumn
1516import androidx.compose.foundation.lazy.items
17+import androidx.compose.foundation.lazy.itemsIndexed
1618import androidx.compose.foundation.rememberScrollState
1719import androidx.compose.foundation.text.KeyboardActions
1820import androidx.compose.foundation.text.KeyboardOptions
@@ -26,6 +28,7 @@ import androidx.compose.material.icons.filled.Home
2628import androidx.compose.material.icons.filled.Search
2729import androidx.compose.material3.CircularProgressIndicator
2830import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.FilterChip
2932import androidx.compose.material3.HorizontalDivider
3033import androidx.compose.material3.Icon
3134import androidx.compose.material3.IconButton
@@ -49,14 +52,21 @@ import androidx.compose.ui.semantics.Role
4952import androidx.compose.ui.semantics.contentDescription
5053import androidx.compose.ui.semantics.role
5154import androidx.compose.ui.semantics.semantics
55+import androidx.compose.ui.text.SpanStyle
56+import androidx.compose.ui.text.buildAnnotatedString
5257import androidx.compose.ui.text.font.FontFamily
58+import androidx.compose.ui.text.font.FontWeight
5359import androidx.compose.ui.text.input.ImeAction
5460import androidx.compose.ui.text.style.TextOverflow
61+import androidx.compose.ui.text.withStyle
5562import androidx.compose.ui.unit.dp
5663import androidx.compose.ui.res.stringResource
5764import androidx.lifecycle.compose.collectAsStateWithLifecycle
5865import androidx.lifecycle.viewmodel.compose.viewModel
5966import soy.iko.opencode.data.model.FileStatusEntry
67+import soy.iko.opencode.data.model.FindMatch
68+import soy.iko.opencode.data.model.SymbolResult
69+import soy.iko.opencode.data.model.symbolKindLabel
6070import soy.iko.opencode.di.AppContainer
6171import soy.iko.opencode.R
6272import soy.iko.opencode.ui.vmFactory
@@ -65,7 +75,7 @@ import soy.iko.opencode.ui.vmFactory
6575@Composable
6676fun FileBrowserScreen(
6777 container: AppContainer,
68− onOpenFile: (String) -> Unit,
78+ onOpenFile: (path: String, line: Int?) -> Unit,
6979 onBack: () -> Unit,
7080) {
7181 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
@@ -103,8 +113,18 @@ fun FileBrowserScreen(
103113 OutlinedTextField(
104114 value = state.query,
105115 onValueChange = vm::setQuery,
106− modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
107− label = { Text(stringResource(R.string.search_files)) },
116+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
117+ label = {
118+ Text(
119+ stringResource(
120+ when (state.mode) {
121+ SearchMode.FILES -> R.string.search_files
122+ SearchMode.TEXT -> R.string.search_in_files
123+ SearchMode.SYMBOL -> R.string.search_symbols_label
124+ },
125+ ),
126+ )
127+ },
108128 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
109129 trailingIcon = if (state.query.isNotEmpty()) {
110130 {
@@ -117,6 +137,16 @@ fun FileBrowserScreen(
117137 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
118138 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
119139 )
140+ // Mode selector: file names / contents / symbols.
141+ Row(
142+ modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
143+ horizontalArrangement = Arrangement.spacedBy(8.dp),
144+ ) {
145+ SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
146+ SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
147+ SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
148+ }
149+ Spacer(Modifier.size(8.dp))
120150
121151 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
122152 isRefreshing = refreshing,
@@ -149,12 +179,16 @@ fun FileBrowserScreen(
149179 Text(stringResource(R.string.retry))
150180 }
151181 }
152− state.isSearching -> SearchResults(state.results, onOpenFile)
182+ state.mode == SearchMode.TEXT ->
183+ TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
184+ state.mode == SearchMode.SYMBOL ->
185+ SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
186+ state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
153187 else -> DirectoryListing(
154188 state = state,
155189 onOpenDir = vm::open,
156190 onUp = vm::up,
157− onOpenFile = onOpenFile,
191+ onOpenFile = { onOpenFile(it, null) },
158192 )
159193 }
160194 }
@@ -226,6 +260,139 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
226260 }
227261}
228262
263+@OptIn(ExperimentalMaterial3Api::class)
264+@Composable
265+private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
266+ FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
267+}
268+
269+/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
270+@Composable
271+private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
272+ if (results.isEmpty()) {
273+ EmptyFileState(
274+ icon = Icons.Filled.Search,
275+ message = stringResource(R.string.no_matches),
276+ modifier = Modifier.fillMaxWidth().padding(24.dp),
277+ )
278+ return
279+ }
280+ LazyColumn(modifier = Modifier.fillMaxSize()) {
281+ itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
282+ val name = match.filePath.substringAfterLast('/')
283+ val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
284+ val highlighted = remember(match) { highlightMatchLine(match) }
285+ Column(
286+ modifier = Modifier
287+ .fillMaxWidth()
288+ .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
289+ .padding(horizontal = 16.dp, vertical = 10.dp),
290+ ) {
291+ Row(verticalAlignment = Alignment.CenterVertically) {
292+ Text(
293+ name,
294+ style = MaterialTheme.typography.bodyMedium,
295+ maxLines = 1,
296+ overflow = TextOverflow.Ellipsis,
297+ modifier = Modifier.weight(1f, fill = false),
298+ )
299+ Text(
300+ ":${match.lineNumber}",
301+ style = MaterialTheme.typography.labelSmall,
302+ color = MaterialTheme.colorScheme.onSurfaceVariant,
303+ fontFamily = FontFamily.Monospace,
304+ modifier = Modifier.padding(start = 4.dp),
305+ )
306+ }
307+ if (dir.isNotEmpty()) {
308+ Text(
309+ dir,
310+ style = MaterialTheme.typography.labelSmall,
311+ color = MaterialTheme.colorScheme.onSurfaceVariant,
312+ maxLines = 1,
313+ overflow = TextOverflow.Ellipsis,
314+ )
315+ }
316+ Text(
317+ highlighted,
318+ style = MaterialTheme.typography.bodySmall,
319+ fontFamily = FontFamily.Monospace,
320+ maxLines = 2,
321+ overflow = TextOverflow.Ellipsis,
322+ modifier = Modifier.padding(top = 2.dp),
323+ )
324+ }
325+ HorizontalDivider()
326+ }
327+ }
328+}
329+
330+/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
331+@Composable
332+private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
333+ if (results.isEmpty()) {
334+ EmptyFileState(
335+ icon = Icons.Filled.Search,
336+ message = stringResource(R.string.no_matches),
337+ modifier = Modifier.fillMaxWidth().padding(24.dp),
338+ )
339+ return
340+ }
341+ LazyColumn(modifier = Modifier.fillMaxSize()) {
342+ itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
343+ val fileName = symbol.filePath.substringAfterLast('/')
344+ Column(
345+ modifier = Modifier
346+ .fillMaxWidth()
347+ .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
348+ .padding(horizontal = 16.dp, vertical = 10.dp),
349+ ) {
350+ Row(verticalAlignment = Alignment.CenterVertically) {
351+ Text(
352+ symbol.name,
353+ style = MaterialTheme.typography.bodyMedium,
354+ maxLines = 1,
355+ overflow = TextOverflow.Ellipsis,
356+ modifier = Modifier.weight(1f, fill = false),
357+ )
358+ Text(
359+ symbolKindLabel(symbol.kind),
360+ style = MaterialTheme.typography.labelSmall,
361+ color = MaterialTheme.colorScheme.primary,
362+ modifier = Modifier.padding(start = 8.dp),
363+ )
364+ }
365+ Text(
366+ "$fileName:${symbol.displayLine}",
367+ style = MaterialTheme.typography.labelSmall,
368+ color = MaterialTheme.colorScheme.onSurfaceVariant,
369+ fontFamily = FontFamily.Monospace,
370+ maxLines = 1,
371+ overflow = TextOverflow.Ellipsis,
372+ )
373+ }
374+ HorizontalDivider()
375+ }
376+ }
377+}
378+
379+/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
380+ * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
381+private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
382+ val line = match.lineText
383+ return buildAnnotatedString {
384+ var cursor = 0
385+ for (sub in match.submatches.sortedBy { it.start }) {
386+ val start = sub.start.coerceIn(0, line.length)
387+ val end = sub.end.coerceIn(start, line.length)
388+ if (start > cursor) append(line.substring(cursor, start))
389+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
390+ cursor = end
391+ }
392+ if (cursor < line.length) append(line.substring(cursor))
393+ }
394+}
395+
229396@Composable
230397private fun DirectoryListing(
231398 state: FileBrowserState,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+76−1
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
55import androidx.lifecycle.viewModelScope
66import soy.iko.opencode.data.model.FileNode
77import soy.iko.opencode.data.model.FileStatusEntry
8+import soy.iko.opencode.data.model.FindMatch
9+import soy.iko.opencode.data.model.SymbolResult
810import soy.iko.opencode.data.network.NetworkConfig
911import soy.iko.opencode.di.AppContainer
1012import soy.iko.opencode.R
@@ -23,18 +25,26 @@ import kotlinx.coroutines.flow.collectLatest
2325import kotlinx.coroutines.flow.update
2426import kotlinx.coroutines.launch
2527
28+/** What a search query targets: file names, file contents (ripgrep), or workspace symbols. */
29+enum class SearchMode { FILES, TEXT, SYMBOL }
30+
2631@Immutable
2732data class FileBrowserState(
2833 val path: String = "",
2934 val entries: List<FileNode> = emptyList(),
3035 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
36+ val mode: SearchMode = SearchMode.FILES,
3137 val query: String = "",
3238 val results: List<String> = emptyList(),
39+ val textResults: List<FindMatch> = emptyList(),
40+ val symbolResults: List<SymbolResult> = emptyList(),
3341 val searching: Boolean = false,
3442 val loading: Boolean = true,
3543 val error: String? = null,
3644) {
37− val isSearching: Boolean get() = query.isNotBlank()
45+ /** True when the results view (rather than the directory listing) should be shown:
46+ * any content/symbol mode, or file-name mode with a non-blank query. */
47+ val isSearching: Boolean get() = mode != SearchMode.FILES || query.isNotBlank()
3848}
3949
4050@OptIn(ExperimentalCoroutinesApi::class)
@@ -131,8 +141,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
131141 }
132142 _state.update { it.copy(
133143 path = path,
144+ mode = SearchMode.FILES,
134145 query = "",
135146 results = emptyList(),
147+ textResults = emptyList(),
148+ symbolResults = emptyList(),
136149 searching = false,
137150 loading = true,
138151 error = null,
@@ -159,10 +172,36 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
159172 open(parent)
160173 }
161174
175+ /** Switch what the query targets (file names / contents / symbols) and re-run it. */
176+ fun setMode(mode: SearchMode) {
177+ if (_state.value.mode == mode) return
178+ searchJob?.cancel()
179+ _state.update {
180+ it.copy(
181+ mode = mode,
182+ results = emptyList(),
183+ textResults = emptyList(),
184+ symbolResults = emptyList(),
185+ searching = false,
186+ error = null,
187+ )
188+ }
189+ val query = _state.value.query
190+ if (mode == SearchMode.FILES && query.isBlank()) open(_state.value.path) else setQuery(query)
191+ }
192+
162193 fun setQuery(query: String) {
163194 _state.update { it.copy(query = query, error = null) }
164195 searchJob?.cancel()
165196 openJob?.cancel()
197+ when (_state.value.mode) {
198+ SearchMode.FILES -> searchFiles(query)
199+ SearchMode.TEXT -> searchContent(query) { it.findText(query) }
200+ SearchMode.SYMBOL -> searchSymbols(query)
201+ }
202+ }
203+
204+ private fun searchFiles(query: String) {
166205 if (query.isBlank()) {
167206 _state.update { it.copy(results = emptyList(), searching = false) }
168207 // Cancelling openJob above may have interrupted an in-flight directory load,
@@ -188,4 +227,40 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
188227 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
189228 }
190229 }
230+
231+ private fun searchContent(query: String, fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> List<FindMatch>) {
232+ if (query.isBlank()) {
233+ _state.update { it.copy(textResults = emptyList(), searching = false, loading = false) }
234+ return
235+ }
236+ _state.update { it.copy(loading = false, searching = true) }
237+ val client = api ?: run {
238+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
239+ return
240+ }
241+ searchJob = viewModelScope.launch {
242+ delay(NetworkConfig.fileSearchDebounceMs)
243+ runCatchingCancellable { fetch(client) }
244+ .onSuccess { results -> _state.update { it.copy(textResults = results, searching = false) } }
245+ .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
246+ }
247+ }
248+
249+ private fun searchSymbols(query: String) {
250+ if (query.isBlank()) {
251+ _state.update { it.copy(symbolResults = emptyList(), searching = false, loading = false) }
252+ return
253+ }
254+ _state.update { it.copy(loading = false, searching = true) }
255+ val client = api ?: run {
256+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
257+ return
258+ }
259+ searchJob = viewModelScope.launch {
260+ delay(NetworkConfig.fileSearchDebounceMs)
261+ runCatchingCancellable { client.findSymbol(query) }
262+ .onSuccess { results -> _state.update { it.copy(symbolResults = results, searching = false) } }
263+ .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
264+ }
265+ }
191266}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+17−2
@@ -92,6 +92,7 @@ fun FileViewScreen(
9292 container: AppContainer,
9393 path: String,
9494 onBack: () -> Unit,
95+ initialLine: Int? = null,
9596) {
9697 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
9798 val state by vm.state.collectAsStateWithLifecycle()
@@ -102,9 +103,11 @@ fun FileViewScreen(
102103 }
103104 val shareLabel = stringResource(R.string.share)
104105 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
105− // switch to the raw new content. Persisted via rememberSaveable across rotation.
106+ // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
107+ // viewer is opened at a specific line (from a search hit), start on the raw view so the
108+ // targeted line is actually rendered (the diff view has no line list to scroll).
106109 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
107− var showDiff by rememberSaveable(path) { mutableStateOf(true) }
110+ var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
108111 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
109112 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
110113 // user's query/mode.
@@ -138,6 +141,18 @@ fun FileViewScreen(
138141 LaunchedEffect(matchPos, matchIndices) {
139142 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
140143 }
144+ // Opened at a specific line (from a search hit): scroll there once the raw content loads.
145+ // One-shot so a later find-in-file navigation isn't overridden.
146+ var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
147+ LaunchedEffect(state.content, initialLine) {
148+ val line = initialLine ?: return@LaunchedEffect
149+ if (didInitialLineScroll) return@LaunchedEffect
150+ val content = state.content ?: return@LaunchedEffect
151+ if (content.isBinary) return@LaunchedEffect
152+ val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
153+ runCatchingCancellable { listState.scrollToItem(target) }
154+ didInitialLineScroll = true
155+ }
141156
142157 Scaffold(
143158 topBar = {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+63
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.server
22
33import androidx.activity.compose.BackHandler
44import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.clickable
56import androidx.compose.foundation.layout.Arrangement
67import androidx.compose.foundation.layout.Box
78import androidx.compose.foundation.layout.Column
@@ -20,6 +21,7 @@ import androidx.compose.material.icons.Icons
2021import androidx.compose.material.icons.automirrored.filled.ArrowBack
2122import androidx.compose.material.icons.filled.Visibility
2223import androidx.compose.material.icons.filled.VisibilityOff
24+import androidx.compose.material.icons.filled.Wifi
2325import androidx.compose.material3.AlertDialog
2426import androidx.compose.material3.Button
2527import androidx.compose.material3.CircularProgressIndicator
@@ -41,6 +43,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
4143import androidx.compose.runtime.setValue
4244import androidx.compose.ui.Alignment
4345import androidx.compose.ui.Modifier
46+import androidx.compose.ui.platform.LocalContext
4447import androidx.compose.ui.platform.testTag
4548import androidx.compose.ui.semantics.contentDescription
4649import androidx.compose.ui.semantics.semantics
@@ -52,6 +55,7 @@ import androidx.compose.ui.res.stringResource
5255import androidx.compose.ui.unit.dp
5356import androidx.lifecycle.compose.collectAsStateWithLifecycle
5457import androidx.lifecycle.viewmodel.compose.viewModel
58+import soy.iko.opencode.data.network.NsdDiscovery
5559import soy.iko.opencode.di.AppContainer
5660import soy.iko.opencode.R
5761import soy.iko.opencode.ui.vmFactory
@@ -218,6 +222,9 @@ private fun ServerEditForm(
218222 modifier = Modifier.fillMaxWidth(),
219223 )
220224 }
225+ DiscoverySection(
226+ onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
227+ )
221228 if (!state.authFieldsVisible) {
222229 Text(
223230 stringResource(R.string.auth_help),
@@ -354,3 +361,59 @@ private fun AuthFields(
354361 }
355362 }
356363}
364+
365+/**
366+ * Optional LAN discovery: browses for opencode servers advertised via mDNS (`--mdns`) and
367+ * offers each as a one-tap fill for the base URL. Collapsed by default so the discovery (and
368+ * its multicast traffic) only runs while the section is expanded — the collection stops when
369+ * the section is collapsed or the screen leaves composition.
370+ */
371+@Composable
372+private fun DiscoverySection(onPick: (String) -> Unit) {
373+ val context = LocalContext.current
374+ var expanded by rememberSaveable { mutableStateOf(false) }
375+ Column(modifier = Modifier.fillMaxWidth()) {
376+ TextButton(
377+ onClick = { expanded = !expanded },
378+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
379+ ) {
380+ Icon(Icons.Filled.Wifi, contentDescription = null, modifier = Modifier.size(18.dp))
381+ Text(stringResource(R.string.discover_on_network), modifier = Modifier.padding(start = 8.dp))
382+ }
383+ if (expanded) {
384+ val discovery = remember { NsdDiscovery(context) }
385+ val servers by remember(discovery) { discovery.discover() }
386+ .collectAsStateWithLifecycle(initialValue = emptyList())
387+ if (servers.isEmpty()) {
388+ Row(
389+ verticalAlignment = Alignment.CenterVertically,
390+ modifier = Modifier.padding(vertical = 8.dp),
391+ ) {
392+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
393+ Text(
394+ stringResource(R.string.searching_network),
395+ style = MaterialTheme.typography.bodySmall,
396+ color = MaterialTheme.colorScheme.onSurfaceVariant,
397+ modifier = Modifier.padding(start = 8.dp),
398+ )
399+ }
400+ } else {
401+ servers.forEach { server ->
402+ Column(
403+ modifier = Modifier
404+ .fillMaxWidth()
405+ .clickable { onPick(server.baseUrl) }
406+ .padding(vertical = 8.dp),
407+ ) {
408+ Text(server.name, style = MaterialTheme.typography.bodyMedium)
409+ Text(
410+ server.baseUrl,
411+ style = MaterialTheme.typography.bodySmall,
412+ color = MaterialTheme.colorScheme.onSurfaceVariant,
413+ )
414+ }
415+ }
416+ }
417+ }
418+ }
419+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+44−2
@@ -446,6 +446,9 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
446446 }
447447 }
448448 } else {
449+ // Order child (sub-)sessions immediately under their parent, tracking depth
450+ // so the UI can indent them into a tree.
451+ val nodes = remember(sessions) { buildSessionTree(sessions) }
449452 PullToRefreshBox(
450453 isRefreshing = refreshing,
451454 onRefresh = onRefresh,
@@ -456,7 +459,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
456459 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
457460 verticalArrangement = Arrangement.spacedBy(12.dp),
458461 ) {
459− items(sessions, key = { it.id }) { session ->
462+ items(nodes, key = { it.session.id }) { node ->
463+ val session = node.session
460464 // Swipe end-to-start reveals a delete affordance and opens the
461465 // same confirmation dialog as the trash icon. We never commit
462466 // the dismissal (always reset to Settled) so the card snaps back
@@ -482,7 +486,11 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
482486 SwipeToDismissBox(
483487 state = swipeState,
484488 enableDismissFromStartToEnd = false,
485− modifier = Modifier.animateItem(),
489+ // Indent sub-sessions under their parent (capped so deep
490+ // nesting doesn't squeeze the card off-screen).
491+ modifier = Modifier
492+ .animateItem()
493+ .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
486494 backgroundContent = {
487495 Box(
488496 modifier = Modifier
@@ -519,6 +527,40 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
519527 }
520528}
521529
530+/** A session plus its nesting [depth] (0 = top level) for the sub-session tree. */
531+private data class SessionNode(val session: soy.iko.opencode.data.model.Session, val depth: Int)
532+
533+/**
534+ * Order [sessions] so each child (a session whose `parentID` is also present) appears
535+ * immediately after its parent, tracking depth for indentation. The input order (already
536+ * sorted by the VM) is preserved for roots and within each sibling group. Sessions whose
537+ * parent isn't in the list are treated as roots; a `parentID` cycle can't drop a row (any
538+ * session not reached from a root is appended at depth 0).
539+ */
540+private fun buildSessionTree(sessions: List<soy.iko.opencode.data.model.Session>): List<SessionNode> {
541+ // Fast path: no parent links at all (the common case) → everything is top-level.
542+ if (sessions.none { it.parentID != null }) return sessions.map { SessionNode(it, 0) }
543+ val ids = sessions.mapTo(HashSet()) { it.id }
544+ val childrenByParent = LinkedHashMap<String, MutableList<soy.iko.opencode.data.model.Session>>()
545+ val roots = ArrayList<soy.iko.opencode.data.model.Session>()
546+ for (s in sessions) {
547+ val parent = s.parentID
548+ if (parent != null && parent in ids) childrenByParent.getOrPut(parent) { ArrayList() }.add(s)
549+ else roots.add(s)
550+ }
551+ val result = ArrayList<SessionNode>(sessions.size)
552+ val visited = HashSet<String>()
553+ fun emit(session: soy.iko.opencode.data.model.Session, depth: Int) {
554+ if (!visited.add(session.id)) return
555+ result.add(SessionNode(session, depth))
556+ childrenByParent[session.id]?.forEach { emit(it, depth + 1) }
557+ }
558+ roots.forEach { emit(it, 0) }
559+ // Safety: append any session not reachable from a root (e.g. a parentID cycle).
560+ for (s in sessions) if (s.id !in visited) result.add(SessionNode(s, 0))
561+ return result
562+}
563+
522564@Composable
523565private fun RenameSessionDialog(
524566 title: String,
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+37−1
@@ -63,6 +63,7 @@ import soy.iko.opencode.data.repo.CrashLogger
6363import soy.iko.opencode.data.repo.ThemeMode
6464import soy.iko.opencode.di.AppContainer
6565import soy.iko.opencode.R
66+import soy.iko.opencode.ui.canAuthenticateForAppLock
6667import soy.iko.opencode.ui.theme.LightPaletteSwatches
6768import soy.iko.opencode.ui.theme.DarkPaletteSwatches
6869import soy.iko.opencode.util.runCatchingCancellable
@@ -82,7 +83,8 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
8283 container.settingsStore.themeMode,
8384 container.settingsStore.dynamicColor,
8485 container.settingsStore.sendOnEnter,
85− ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
86+ container.settingsStore.appLock,
87+ ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
8688 }
8789 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
8890 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -204,6 +206,39 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
204206 onCheckedChange = null,
205207 )
206208 }
209+
210+ // App lock: gate on device capability. Always allow turning it *off* (in case
211+ // the enrolled biometric was later removed), but only allow turning it on when
212+ // a biometric or device credential is actually available.
213+ val appLockAvailable = remember { canAuthenticateForAppLock(context) }
214+ val appLockToggleable = appLockAvailable || s.appLock
215+ Spacer(Modifier.size(4.dp))
216+ Row(
217+ modifier = Modifier
218+ .fillMaxWidth()
219+ .toggleable(
220+ value = s.appLock,
221+ enabled = appLockToggleable,
222+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setAppLock(it) } } },
223+ role = Role.Switch,
224+ )
225+ .padding(vertical = 8.dp),
226+ verticalAlignment = Alignment.CenterVertically,
227+ ) {
228+ Column(modifier = Modifier.weight(1f)) {
229+ Text(stringResource(R.string.app_lock), style = MaterialTheme.typography.bodyLarge)
230+ Text(
231+ stringResource(if (appLockToggleable) R.string.app_lock_desc else R.string.app_lock_unavailable),
232+ style = MaterialTheme.typography.bodySmall,
233+ color = MaterialTheme.colorScheme.onSurfaceVariant,
234+ )
235+ }
236+ Switch(
237+ checked = s.appLock,
238+ onCheckedChange = null,
239+ enabled = appLockToggleable,
240+ )
241+ }
207242 } else {
208243 Box(
209244 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
@@ -388,6 +423,7 @@ private data class SettingsValues(
388423 val themeMode: ThemeMode,
389424 val dynamicColor: Boolean,
390425 val sendOnEnter: Boolean,
426+ val appLock: Boolean,
391427)
392428
393429/** Small circular count badge used to indicate pending crash reports. */
app/src/main/res/values/strings.xml+47
@@ -73,6 +73,8 @@
7373 <string name="show_password">Show password</string>
7474 <string name="hide_password">Hide password</string>
7575 <string name="test_credentials">Test credentials</string>
76+ <string name="discover_on_network">Discover on network</string>
77+ <string name="searching_network">Searching the local network…</string>
7678 <string name="testing_credentials">Testing…</string>
7779 <string name="credentials_ok">Credentials accepted.</string>
7880 <string name="credentials_rejected">Credentials rejected.</string>
@@ -281,4 +283,49 @@
281283
282284 <!-- Search -->
283285 <string name="clear_search">Clear search</string>
286+ <string name="search_in_files">Search file contents…</string>
287+ <string name="search_symbols_label">Search symbols…</string>
288+ <string name="search_mode_files">Files</string>
289+ <string name="search_mode_text">Contents</string>
290+ <string name="search_mode_symbols">Symbols</string>
291+
292+ <!-- Attachments -->
293+ <string name="add_attachment">Add attachment</string>
294+ <string name="attach_photo">Photo</string>
295+ <string name="attach_camera">Camera</string>
296+ <string name="attach_file">File</string>
297+ <string name="attachment_too_large">File is too large to attach.</string>
298+ <string name="attachment_failed">Couldn\'t attach that file.</string>
299+ <string name="attachment_limit">Attachment limit reached.</string>
300+ <string name="no_camera_app">No camera available.</string>
301+
302+ <!-- Voice input -->
303+ <string name="voice_input">Voice input</string>
304+ <string name="voice_prompt">Speak your message</string>
305+ <string name="no_voice_app">Voice input isn\'t available on this device.</string>
306+
307+ <!-- Revert -->
308+ <string name="revert_to_here">Revert to here</string>
309+ <string name="reverted_banner">Reverted — later messages are hidden.</string>
310+
311+ <!-- Sharing / summarize / init / shell -->
312+ <string name="create_share_link">Create share link</string>
313+ <string name="copy_share_link">Copy share link</string>
314+ <string name="stop_sharing">Stop sharing</string>
315+ <string name="link_copied">Link copied</string>
316+ <string name="summarize_conversation">Summarize conversation</string>
317+ <string name="generate_agents_md">Generate AGENTS.md</string>
318+ <string name="run_shell_command">Run shell command</string>
319+ <string name="shell_command_label">Command</string>
320+ <string name="shell_command_hint">ls -la</string>
321+ <string name="needs_model">Select a model first.</string>
322+
323+ <!-- App lock (biometric) -->
324+ <string name="app_lock">App lock</string>
325+ <string name="app_lock_desc">Require biometric or device credential to open the app.</string>
326+ <string name="app_lock_unavailable">No biometric or device credential is set up.</string>
327+ <string name="app_lock_prompt_title">Unlock opencode</string>
328+ <string name="app_lock_prompt_subtitle">Authenticate to access your servers.</string>
329+ <string name="app_locked">opencode is locked</string>
330+ <string name="app_unlock">Unlock</string>
284331</resources>
app/src/main/res/xml/file_paths.xmlnew file+6
@@ -0,0 +1,6 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<paths>
3+ <!-- Camera captures are written to cache/captures and shared with the camera app via
4+ the app's FileProvider (see AndroidManifest). -->
5+ <cache-path name="captures" path="captures/" />
6+</paths>
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+2
@@ -173,6 +173,7 @@ class FakeOpencodeApiClient : OpencodeApiClient() {
173173 override suspend fun sendPrompt(
174174 sessionId: String,
175175 text: String,
176+ attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
176177 model: ModelRef?,
177178 agent: String?,
178179 ): MessageWithParts {
@@ -276,6 +277,7 @@ class FakeSessionRepository(
276277 override suspend fun sendPrompt(
277278 sessionId: String,
278279 text: String,
280+ attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
279281 model: ModelRef?,
280282 agent: String?,
281283 ): MessageWithParts {
config/detekt/detekt.yml+8
@@ -47,6 +47,14 @@ style:
4747 FunctionOnlyReturningConstant:
4848 active: false
4949
50+empty-blocks:
51+ active: true
52+ EmptyFunctionBlock:
53+ active: true
54+ # Android listener interfaces (e.g. NsdManager.DiscoveryListener) force overriding
55+ # callbacks the caller doesn't need; empty no-op overrides are idiomatic there.
56+ ignoreOverridden: true
57+
5058exceptions:
5159 active: true
5260 TooGenericExceptionCaught:
gradle/libs.versions.toml+2
@@ -11,6 +11,7 @@ navigationCompose = "2.8.5"
1111datastore = "1.1.1"
1212securityCrypto = "1.1.0-alpha06"
1313coil = "2.7.0"
14+biometric = "1.1.0"
1415markdownRenderer = "0.27.0"
1516coreKtx = "1.13.1"
1617splashscreen = "1.0.1"
@@ -52,6 +53,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
5253androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
5354androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
5455coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
56+androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
5557markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
5658markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
5759