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
c0621ffiko
app/build.gradle.kts+1
@@ -131,6 +131,7 @@ dependencies {
131
131
132 implementation(libs.androidx.datastore.preferences)
132 implementation(libs.androidx.datastore.preferences)
133 implementation(libs.androidx.security.crypto)
133 implementation(libs.androidx.security.crypto)
134+ implementation(libs.androidx.biometric)
134 implementation(libs.coil.compose)
135 implementation(libs.coil.compose)
135 implementation(libs.markdown.renderer)
136 implementation(libs.markdown.renderer)
136 implementation(libs.markdown.renderer.m3)
137 implementation(libs.markdown.renderer.m3)
app/src/main/AndroidManifest.xml+26
@@ -8,6 +8,10 @@
8 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
8 <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
9 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10
10
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+
11 <application
15 <application
12 android:name=".OpencodeApp"
16 android:name=".OpencodeApp"
13 android:allowBackup="false"
17 android:allowBackup="false"
@@ -36,6 +40,17 @@
36 <category android:name="android.intent.category.DEFAULT" />
40 <category android:name="android.intent.category.DEFAULT" />
37 <data android:mimeType="text/plain" />
41 <data android:mimeType="text/plain" />
38 </intent-filter>
42 </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>
39 <!-- Deep link to a session: opencode://session/{sessionId} -->
54 <!-- Deep link to a session: opencode://session/{sessionId} -->
40 <intent-filter>
55 <intent-filter>
41 <action android:name="android.intent.action.VIEW" />
56 <action android:name="android.intent.action.VIEW" />
@@ -49,6 +64,17 @@
49 android:name=".notification.RunForegroundService"
64 android:name=".notification.RunForegroundService"
50 android:exported="false"
65 android:exported="false"
51 android:foregroundServiceType="dataSync" />
66 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>
52 </application>
78 </application>
53
79
54</manifest>
80</manifest>
app/src/main/java/soy/iko/opencode/MainActivity.kt+46−10
@@ -3,9 +3,12 @@ package soy.iko.opencode
3import android.Manifest
3import android.Manifest
4import android.content.Intent
4import android.content.Intent
5import android.content.pm.PackageManager
5import android.content.pm.PackageManager
6+import android.net.Uri
6import android.os.Build
7import android.os.Build
7import android.os.Bundle
8import 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
9import androidx.activity.compose.setContent
12import androidx.activity.compose.setContent
10import androidx.activity.enableEdgeToEdge
13import androidx.activity.enableEdgeToEdge
11import androidx.activity.result.contract.ActivityResultContracts
14import androidx.activity.result.contract.ActivityResultContracts
@@ -16,12 +19,17 @@ import androidx.compose.runtime.remember
16import kotlinx.coroutines.flow.combine
19import kotlinx.coroutines.flow.combine
17import androidx.core.content.ContextCompat
20import androidx.core.content.ContextCompat
18import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
21import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22+import androidx.fragment.app.FragmentActivity
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
20import soy.iko.opencode.data.repo.ThemeMode
24import soy.iko.opencode.data.repo.ThemeMode
25+import soy.iko.opencode.ui.AppLockGate
21import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
26import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
22import soy.iko.opencode.ui.theme.OpencodeTheme
27import soy.iko.opencode.ui.theme.OpencodeTheme
23
28
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.
24−class MainActivity : ComponentActivity() {
32+class MainActivity : FragmentActivity() {
25
33
26 private val requestNotificationPermission = registerForActivityResult(
34 private val requestNotificationPermission = registerForActivityResult(
27 ActivityResultContracts.RequestPermission(),
35 ActivityResultContracts.RequestPermission(),
@@ -51,7 +59,14 @@ class MainActivity : ComponentActivity() {
51 container.settingsStore.themeMode
59 container.settingsStore.themeMode
52 .combine(container.settingsStore.dynamicColor, ::Pair)
60 .combine(container.settingsStore.dynamicColor, ::Pair)
53 }.collectAsStateWithLifecycle(initialValue = null)
61 }.collectAsStateWithLifecycle(initialValue = null)
62+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
54 LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
63 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+ }
55 theme?.let { (themeMode, dynamicColor) ->
70 theme?.let { (themeMode, dynamicColor) ->
56 val dark = when (themeMode) {
71 val dark = when (themeMode) {
57 ThemeMode.SYSTEM -> isSystemInDarkTheme()
72 ThemeMode.SYSTEM -> isSystemInDarkTheme()
@@ -59,7 +74,9 @@ class MainActivity : ComponentActivity() {
59 ThemeMode.DARK -> true
74 ThemeMode.DARK -> true
60 }
75 }
61 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
76 OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
77+ AppLockGate(enabled = appLock) {
62− OpencodeAppUi(container = container)
78+ OpencodeAppUi(container = container)
79+ }
63 }
80 }
64 }
81 }
65 }
82 }
@@ -77,13 +94,32 @@ class MainActivity : ComponentActivity() {
77 * and session ids from notification taps / deep links so we can open them. */
94 * and session ids from notification taps / deep links so we can open them. */
78 private fun handleIntent(intent: Intent?) {
95 private fun handleIntent(intent: Intent?) {
79 val container = (application as OpencodeApp).container
96 val container = (application as OpencodeApp).container
97+ val action = intent?.action
80− if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
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 {
81− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
116+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82− ?.takeIf { it.isNotBlank() }
117+ ?.takeIf { it.isNotBlank() }
83− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
118+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
84− if (text != null) {
119+ if (text != null) {
85− container.setPendingShare(text)
120+ container.setPendingShare(text)
86− shareIntentHandled = true
121+ shareIntentHandled = true
122+ }
87 }
123 }
88 }
124 }
89 // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
125 // 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 @@
1package soy.iko.opencode.data.model
1package soy.iko.opencode.data.model
2
2
3+import kotlinx.serialization.SerialName
3import kotlinx.serialization.Serializable
4import kotlinx.serialization.Serializable
4
5
5/** Body for `POST /session/:id/message`. */
6/** Body for `POST /session/:id/message`. */
@@ -10,12 +11,31 @@ data class PromptRequest(
10 val agent: String? = null,
11 val agent: String? = null,
11)
12)
12
13
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+ */
14@Serializable
19@Serializable
20+sealed interface PromptPart
21+
22+@Serializable
23+@SerialName("text")
15−data class PromptPart(
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(
16− val type: String = "text",
35+ val mime: String,
17− val text: String,
36+ val url: String,
18−)
37+ val filename: String? = null,
38+) : PromptPart
19
39
20/** Body for `POST /session/:id/command`. */
40/** Body for `POST /session/:id/command`. */
21@Serializable
41@Serializable
@@ -24,3 +44,25 @@ data class CommandRequest(
24 val arguments: String = "",
44 val arguments: String = "",
25 val agent: String? = null,
45 val agent: String? = null,
26)
46)
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(
17 // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
17 // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
18 val directory: String? = null,
18 val directory: String? = null,
19 val projectID: String? = null,
19 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,
20) {
24) {
21 val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
25 val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
22
26
@@ -24,6 +28,27 @@ data class Session(
24 * or the full path when it has no separators. Null/blank when no directory is set. */
28 * or the full path when it has no separators. Null/blank when no directory is set. */
25 val displayDirectory: String?
29 val displayDirectory: String?
26 get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
30 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+ )
27}
52}
28
53
29/** Body for `POST /session`. */
54/** Body for `POST /session`. */
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+10
@@ -84,6 +84,16 @@ object NetworkConfig {
84 /** Debounce delay before firing a file-search request after the user stops typing. */
84 /** Debounce delay before firing a file-search request after the user stops typing. */
85 const val fileSearchDebounceMs = 250L
85 const val fileSearchDebounceMs = 250L
86
86
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+
87 // --- StateFlow sharing (WhileSubscribed) ---
97 // --- StateFlow sharing (WhileSubscribed) ---
88
98
89 /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
99 /** 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
10import soy.iko.opencode.data.model.MessageWithParts
10import soy.iko.opencode.data.model.MessageWithParts
11import soy.iko.opencode.data.model.ModelRef
11import soy.iko.opencode.data.model.ModelRef
12import soy.iko.opencode.data.model.PathInfo
12import soy.iko.opencode.data.model.PathInfo
13+import soy.iko.opencode.data.model.FilePromptPart
14+import soy.iko.opencode.data.model.FindMatch
13import soy.iko.opencode.data.model.PermissionReplyBody
15import soy.iko.opencode.data.model.PermissionReplyBody
14import soy.iko.opencode.data.model.PermissionResponse
16import soy.iko.opencode.data.model.PermissionResponse
15import soy.iko.opencode.data.model.Project
17import soy.iko.opencode.data.model.Project
16import soy.iko.opencode.data.model.PromptPart
18import soy.iko.opencode.data.model.PromptPart
17import soy.iko.opencode.data.model.PromptRequest
19import soy.iko.opencode.data.model.PromptRequest
18import soy.iko.opencode.data.model.ProvidersResponse
20import soy.iko.opencode.data.model.ProvidersResponse
21+import soy.iko.opencode.data.model.RevertRequest
19import soy.iko.opencode.data.model.Session
22import 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
20import soy.iko.opencode.data.model.UpdateSessionRequest
27import soy.iko.opencode.data.model.UpdateSessionRequest
21import io.ktor.client.HttpClient
28import io.ktor.client.HttpClient
22import io.ktor.client.call.body
29import io.ktor.client.call.body
@@ -132,6 +139,7 @@ open class OpencodeApiClient private constructor(
132 open suspend fun sendPrompt(
139 open suspend fun sendPrompt(
133 sessionId: String,
140 sessionId: String,
134 text: String,
141 text: String,
142+ attachments: List<FilePromptPart> = emptyList(),
135 model: ModelRef? = null,
143 model: ModelRef? = null,
136 agent: String? = null,
144 agent: String? = null,
137 ): MessageWithParts {
145 ): MessageWithParts {
@@ -140,13 +148,20 @@ open class OpencodeApiClient private constructor(
140 // each attempt would get a fresh UUID and the server couldn't deduplicate a
148 // each attempt would get a fresh UUID and the server couldn't deduplicate a
141 // request that reached it but whose response was lost (e.g. timeout).
149 // request that reached it but whose response was lost (e.g. timeout).
142 val idempotencyKey = java.util.UUID.randomUUID().toString()
150 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+ }
143 return withRetry {
158 return withRetry {
144 client!!.post("session/${encode(sessionId)}/message") {
159 client!!.post("session/${encode(sessionId)}/message") {
145 contentType(ContentType.Application.Json)
160 contentType(ContentType.Application.Json)
146 header("Idempotency-Key", idempotencyKey)
161 header("Idempotency-Key", idempotencyKey)
147 setBody(
162 setBody(
148 PromptRequest(
163 PromptRequest(
149− parts = listOf(PromptPart(text = text)),
164+ parts = parts,
150 model = model,
165 model = model,
151 agent = agent,
166 agent = agent,
152 ),
167 ),
@@ -179,6 +194,68 @@ open class OpencodeApiClient private constructor(
179 }
194 }
180 }
195 }
181
196
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+
182 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
259 open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
183 val now = System.currentTimeMillis()
260 val now = System.currentTimeMillis()
184 val cached = cachedProviders
261 val cached = cachedProviders
@@ -258,6 +335,16 @@ open class OpencodeApiClient private constructor(
258 client!!.get("find/file") { parameter("query", query) }.body()
335 client!!.get("find/file") { parameter("query", query) }.body()
259 }
336 }
260
337
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+
261 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
348 open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
262 client!!.get("file") { parameter("path", path) }.body()
349 client!!.get("file") { parameter("path", path) }.body()
263 }
350 }
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+8−2
@@ -1,6 +1,7 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import soy.iko.opencode.data.model.BusEvent
3import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.FilePromptPart
4import soy.iko.opencode.data.model.MessageInfo
5import soy.iko.opencode.data.model.MessageInfo
5import soy.iko.opencode.data.model.MessagePartRemoved
6import soy.iko.opencode.data.model.MessagePartRemoved
6import soy.iko.opencode.data.model.MessagePartUpdated
7import soy.iko.opencode.data.model.MessagePartUpdated
@@ -44,8 +45,13 @@ open class SessionRepository(
44 open suspend fun deleteSession(id: String) = api.deleteSession(id)
45 open suspend fun deleteSession(id: String) = api.deleteSession(id)
45 open suspend fun abort(sessionId: String) = api.abort(sessionId)
46 open suspend fun abort(sessionId: String) = api.abort(sessionId)
46
47
47− open suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
48+ open suspend fun sendPrompt(
49+ sessionId: String,
50+ text: String,
51+ attachments: List<FilePromptPart> = emptyList(),
52+ model: ModelRef?,
53+ agent: String? = null,
48− api.sendPrompt(sessionId, text, model, agent)
54+ ) = api.sendPrompt(sessionId, text, attachments, model, agent)
49
55
50 open suspend fun runCommand(
56 open suspend fun runCommand(
51 sessionId: String,
57 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+12
@@ -19,6 +19,7 @@ class SettingsStore(context: Context) {
19 private val themeKey = stringPreferencesKey("theme_mode")
19 private val themeKey = stringPreferencesKey("theme_mode")
20 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
20 private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
21 private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22+ private val appLockKey = booleanPreferencesKey("app_lock")
22
23
23 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
24 val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
24 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
25 runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
@@ -36,6 +37,13 @@ class SettingsStore(context: Context) {
36 prefs[sendOnEnterKey] ?: true
37 prefs[sendOnEnterKey] ?: true
37 }
38 }
38
39
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+
39 suspend fun setThemeMode(mode: ThemeMode) {
47 suspend fun setThemeMode(mode: ThemeMode) {
40 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
48 appContext.settingsDataStore.edit { it[themeKey] = mode.name }
41 }
49 }
@@ -47,4 +55,8 @@ class SettingsStore(context: Context) {
47 suspend fun setSendOnEnter(enabled: Boolean) {
55 suspend fun setSendOnEnter(enabled: Boolean) {
48 appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
56 appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
49 }
57 }
58+
59+ suspend fun setAppLock(enabled: Boolean) {
60+ appContext.settingsDataStore.edit { it[appLockKey] = enabled }
61+ }
50}
62}
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11
@@ -106,6 +106,17 @@ open class AppContainer private constructor(
106 return if (_pendingShare.compareAndSet(current, null)) current else null
106 return if (_pendingShare.compareAndSet(current, null)) current else null
107 }
107 }
108
108
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+
109 /**
120 /**
110 * A session id to open from an external trigger (a notification tap or a deep link).
121 * A session id to open from an external trigger (a notification tap or a deep link).
111 * The nav host consumes it once a connection is active.
122 * 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) {
39 val navController = rememberNavController()
39 val navController = rememberNavController()
40 val scope = rememberCoroutineScope()
40 val scope = rememberCoroutineScope()
41 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
41 val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
42 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
43 val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
43 val connection by container.activeConnection.collectAsStateWithLifecycle()
44 val connection by container.activeConnection.collectAsStateWithLifecycle()
44
45
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+
45 // When text is shared into the app, surface the session list so the user can pick
60 // When text is shared into the app, surface the session list so the user can pick
46 // (or create) a conversation to drop it into. The chosen session's draft is set
61 // (or create) a conversation to drop it into. The chosen session's draft is set
47 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
62 // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
@@ -50,8 +65,8 @@ fun OpencodeApp(container: AppContainer) {
50 // change) doesn't re-bounce the user to the session list for a share they've
65 // change) doesn't re-bounce the user to the session list for a share they've
51 // already decided to abandon; only the null -> non-null initial connect retries.
66 // already decided to abandon; only the null -> non-null initial connect retries.
52 var lastConnectionId by remember { mutableStateOf<String?>(null) }
67 var lastConnectionId by remember { mutableStateOf<String?>(null) }
53− LaunchedEffect(pendingShare, connection) {
68+ LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
54− if (pendingShare == null) return@LaunchedEffect
69+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
55 if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
70 if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
56 val currentId = connection?.profile?.id
71 val currentId = connection?.profile?.id
57 // Only (re)navigate on the initial null -> non-null connect. A subsequent
72 // Only (re)navigate on the initial null -> non-null connect. A subsequent
@@ -194,18 +209,23 @@ fun OpencodeApp(container: AppContainer) {
194 composable(Routes.FILES) {
209 composable(Routes.FILES) {
195 FileBrowserScreen(
210 FileBrowserScreen(
196 container = container,
211 container = container,
197− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
212+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
198 onBack = { navController.popBackStack() },
213 onBack = { navController.popBackStack() },
199 )
214 )
200 }
215 }
201
216
202 composable(
217 composable(
203− route = "${Routes.FILE_VIEW}?path={path}",
218+ route = "${Routes.FILE_VIEW}?path={path}&line={line}",
219+ arguments = listOf(
204− arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
220+ navArgument("path") { type = NavType.StringType; defaultValue = "" },
221+ navArgument("line") { type = NavType.IntType; defaultValue = -1 },
222+ ),
205 ) { entry ->
223 ) { entry ->
224+ val line = entry.arguments?.getInt("line") ?: -1
206 FileViewScreen(
225 FileViewScreen(
207 container = container,
226 container = container,
208 path = entry.arguments?.getString("path").orEmpty(),
227 path = entry.arguments?.getString("path").orEmpty(),
228+ initialLine = line.takeIf { it > 0 },
209 onBack = { navController.popBackStack() },
229 onBack = { navController.popBackStack() },
210 )
230 )
211 }
231 }
app/src/main/java/soy/iko/opencode/ui/Routes.kt+5−1
@@ -19,5 +19,9 @@ object Routes {
19 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
19 fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
20
20
21 const val FILE_VIEW = "file_view"
21 const val FILE_VIEW = "file_view"
22+ fun fileView(path: String, line: Int? = null): String {
22− fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
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+ }
23}
27}
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 @@
1package soy.iko.opencode.ui.chat
1package soy.iko.opencode.ui.chat
2
2
3+import android.app.Activity
4+import android.content.Intent
5+import android.net.Uri
6+import android.speech.RecognizerIntent
3import androidx.activity.compose.BackHandler
7import androidx.activity.compose.BackHandler
8+import androidx.activity.compose.rememberLauncherForActivityResult
9+import androidx.activity.result.PickVisualMediaRequest
10+import androidx.activity.result.contract.ActivityResultContracts
4import androidx.compose.animation.AnimatedVisibility
11import androidx.compose.animation.AnimatedVisibility
12+import androidx.compose.foundation.horizontalScroll
13+import androidx.compose.foundation.rememberScrollState
5import androidx.compose.foundation.clickable
14import androidx.compose.foundation.clickable
6import androidx.compose.foundation.layout.Arrangement
15import androidx.compose.foundation.layout.Arrangement
7import androidx.compose.foundation.layout.Box
16import androidx.compose.foundation.layout.Box
@@ -17,6 +26,9 @@ import androidx.compose.foundation.layout.imePadding
17import androidx.compose.foundation.layout.navigationBars
26import androidx.compose.foundation.layout.navigationBars
18import androidx.compose.foundation.layout.padding
27import androidx.compose.foundation.layout.padding
19import androidx.compose.foundation.layout.size
28import 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
20import androidx.compose.foundation.layout.union
32import androidx.compose.foundation.layout.union
21import androidx.compose.foundation.layout.windowInsetsPadding
33import androidx.compose.foundation.layout.windowInsetsPadding
22import androidx.compose.foundation.lazy.LazyColumn
34import androidx.compose.foundation.lazy.LazyColumn
@@ -25,12 +37,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState
25import androidx.compose.material.icons.Icons
37import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.automirrored.filled.ArrowBack
38import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.Send
39import androidx.compose.material.icons.automirrored.filled.Send
40+import androidx.compose.material.icons.filled.Add
28import androidx.compose.material.icons.filled.AutoAwesome
41import androidx.compose.material.icons.filled.AutoAwesome
42+import androidx.compose.material.icons.filled.Close
29import androidx.compose.material.icons.filled.Delete
43import androidx.compose.material.icons.filled.Delete
44+import androidx.compose.material.icons.filled.Description
30import androidx.compose.material.icons.filled.Edit
45import androidx.compose.material.icons.filled.Edit
31import androidx.compose.material.icons.filled.KeyboardArrowDown
46import androidx.compose.material.icons.filled.KeyboardArrowDown
47+import androidx.compose.material.icons.filled.Mic
32import androidx.compose.material.icons.filled.MoreVert
48import androidx.compose.material.icons.filled.MoreVert
49+import androidx.compose.material.icons.filled.PhotoCamera
50+import androidx.compose.material.icons.filled.PhotoLibrary
33import androidx.compose.material.icons.filled.Refresh
51import androidx.compose.material.icons.filled.Refresh
52+import androidx.compose.material.icons.filled.Restore
34import androidx.compose.material.icons.filled.Stop
53import androidx.compose.material.icons.filled.Stop
35import androidx.compose.material3.AlertDialog
54import androidx.compose.material3.AlertDialog
36import androidx.compose.material3.Button
55import androidx.compose.material3.Button
@@ -91,6 +110,7 @@ import androidx.compose.ui.text.style.TextOverflow
91import androidx.compose.ui.unit.dp
110import androidx.compose.ui.unit.dp
92import androidx.lifecycle.compose.collectAsStateWithLifecycle
111import androidx.lifecycle.compose.collectAsStateWithLifecycle
93import androidx.lifecycle.viewmodel.compose.viewModel
112import androidx.lifecycle.viewmodel.compose.viewModel
113+import coil.compose.AsyncImage
94import kotlinx.coroutines.Dispatchers
114import kotlinx.coroutines.Dispatchers
95import kotlinx.coroutines.launch
115import kotlinx.coroutines.launch
96import kotlinx.coroutines.withContext
116import kotlinx.coroutines.withContext
@@ -107,6 +127,7 @@ import soy.iko.opencode.data.network.NetworkConfig
107import soy.iko.opencode.di.AppContainer
127import soy.iko.opencode.di.AppContainer
108import soy.iko.opencode.R
128import soy.iko.opencode.R
109import soy.iko.opencode.ui.components.ConnectionBanner
129import soy.iko.opencode.ui.components.ConnectionBanner
130+import soy.iko.opencode.ui.components.copyToClipboard
110import soy.iko.opencode.ui.components.LocalRelativeTimeTick
131import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111import soy.iko.opencode.ui.components.rememberRelativeTimeTick
132import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112import soy.iko.opencode.ui.components.showToast
133import soy.iko.opencode.ui.components.showToast
@@ -145,6 +166,9 @@ fun ChatScreen(
145 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
166 val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
167 val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147 val draft by vm.draft.collectAsStateWithLifecycle()
168 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()
148 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
172 val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
173 val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150 val isOnline by container.isOnline.collectAsStateWithLifecycle()
174 val isOnline by container.isOnline.collectAsStateWithLifecycle()
@@ -201,18 +225,18 @@ fun ChatScreen(
201 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
225 var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
226 var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
227 var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
228+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
204
229
205− // Keep the screen awake and hold a foreground priority while the agent is working,
230+ // Keep the screen awake while the agent is working in *this* session. The foreground
206− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
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.
207 val currentView = LocalView.current
235 val currentView = LocalView.current
208 val appContext = LocalContext.current.applicationContext
236 val appContext = LocalContext.current.applicationContext
209 DisposableEffect(running) {
237 DisposableEffect(running) {
210 currentView.keepScreenOn = running
238 currentView.keepScreenOn = running
211− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
212− onDispose {
213− currentView.keepScreenOn = false
239+ onDispose { currentView.keepScreenOn = false }
214− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
215− }
216 }
240 }
217
241
218 LaunchedEffect(Unit) {
242 LaunchedEffect(Unit) {
@@ -228,6 +252,104 @@ fun ChatScreen(
228 }
252 }
229 }
253 }
230
254
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+
231 fun doSend() {
353 fun doSend() {
232 if (vm.send(draft)) {
354 if (vm.send(draft)) {
233 // TextHandleMove (a light tick) is semantically right for a send commit;
355 // TextHandleMove (a light tick) is semantically right for a send commit;
@@ -331,6 +453,12 @@ fun ChatScreen(
331 val modelLabel = stringResource(R.string.choose_model)
453 val modelLabel = stringResource(R.string.choose_model)
332 val renameLabel = stringResource(R.string.rename_session_chat)
454 val renameLabel = stringResource(R.string.rename_session_chat)
333 val deleteLabel = stringResource(R.string.delete_session_chat)
455 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)
334 Box {
462 Box {
335 IconButton(onClick = { showOverflowMenu = true }) {
463 IconButton(onClick = { showOverflowMenu = true }) {
336 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
464 Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
@@ -374,6 +502,45 @@ fun ChatScreen(
374 onClick = { showOverflowMenu = false; showModelPicker = true },
502 onClick = { showOverflowMenu = false; showModelPicker = true },
375 )
503 )
376 androidx.compose.material3.HorizontalDivider()
504 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()
377 DropdownMenuItem(
544 DropdownMenuItem(
378 text = { Text(renameLabel) },
545 text = { Text(renameLabel) },
379 onClick = { showOverflowMenu = false; showRenameDialog = true },
546 onClick = { showOverflowMenu = false; showRenameDialog = true },
@@ -407,20 +574,35 @@ fun ChatScreen(
407 snackbarHost = { SnackbarHost(snackbar) },
574 snackbarHost = { SnackbarHost(snackbar) },
408 bottomBar = {
575 bottomBar = {
409 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
576 val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
577+ Column {
578+ AnimatedVisibility(visible = reverted) {
579+ RevertBanner(onUndo = { vm.unrevert() })
580+ }
410− ChatInputBar(
581+ ChatInputBar(
411− value = draft,
582+ value = draft,
412− onValueChange = vm::updateDraft,
583+ onValueChange = vm::updateDraft,
413− running = running,
584+ running = running,
414− aborting = aborting,
585+ aborting = aborting,
415− enabled = activeConnection != null,
586+ enabled = activeConnection != null,
416− sendOnEnter = sendOnEnter,
587+ sendOnEnter = sendOnEnter,
417− onSend = ::doSend,
588+ onSend = ::doSend,
418− onAbort = { showStopConfirm = true },
589+ onAbort = { showStopConfirm = true },
419− queuedFollowUp = queuedFollowUp,
590+ queuedFollowUp = queuedFollowUp,
420− onQueueFollowUp = vm::queueFollowUp,
591+ onQueueFollowUp = vm::queueFollowUp,
421− onCancelQueue = { vm.queueFollowUp("") },
592+ onCancelQueue = { vm.queueFollowUp("") },
422− focusRequester = inputFocusRequester,
593+ focusRequester = inputFocusRequester,
594+ attachments = attachments,
595+ onRemoveAttachment = vm::removeAttachment,
596+ onPickPhoto = {
597+ photoPicker.launch(
598+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
423− )
599+ )
600+ },
601+ onPickFile = { filePicker.launch("*/*") },
602+ onCamera = { launchCamera() },
603+ onVoice = { launchVoice() },
604+ )
605+ }
424 },
606 },
425 ) { padding ->
607 ) { padding ->
426 // Collect messages inside the content lambda so streaming token updates
608 // Collect messages inside the content lambda so streaming token updates
@@ -647,6 +829,7 @@ fun ChatScreen(
647 imageContext = imageContext,
829 imageContext = imageContext,
648 modelLabel = modelLabel,
830 modelLabel = modelLabel,
649 onOpenFile = onOpenFile,
831 onOpenFile = onOpenFile,
832+ onRevert = { vm.revertTo(message.info.id) },
650 )
833 )
651 }
834 }
652 }
835 }
@@ -758,6 +941,16 @@ fun ChatScreen(
758 )
941 )
759 }
942 }
760
943
944+ if (showShellDialog) {
945+ ShellCommandDialog(
946+ onDismiss = { showShellDialog = false },
947+ onRun = { cmd ->
948+ showShellDialog = false
949+ vm.runShell(cmd)
950+ },
951+ )
952+ }
953+
761 if (showModelPicker) {
954 if (showModelPicker) {
762 ModelPickerSheet(
955 ModelPickerSheet(
763 options = models,
956 options = models,
@@ -815,7 +1008,15 @@ private fun ChatInputBar(
815 onQueueFollowUp: (String) -> Unit,
1008 onQueueFollowUp: (String) -> Unit,
816 onCancelQueue: () -> Unit,
1009 onCancelQueue: () -> Unit,
817 focusRequester: androidx.compose.ui.focus.FocusRequester,
1010 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,
818) {
1017) {
1018+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1019+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
819 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1020 Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
820 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1021 Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
821 // A queued follow-up replaces the Stop button with a "queued" chip so the
1022 // A queued follow-up replaces the Stop button with a "queued" chip so the
@@ -846,10 +1047,52 @@ private fun ChatInputBar(
846 }
1047 }
847 }
1048 }
848 }
1049 }
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+ }
849 Row(
1064 Row(
850 modifier = Modifier.fillMaxWidth().padding(8.dp),
1065 modifier = Modifier.fillMaxWidth().padding(8.dp),
851 verticalAlignment = Alignment.Bottom,
1066 verticalAlignment = Alignment.Bottom,
852 ) {
1067 ) {
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+ }
853 OutlinedTextField(
1096 OutlinedTextField(
854 value = value,
1097 value = value,
855 onValueChange = { v ->
1098 onValueChange = { v ->
@@ -865,11 +1108,13 @@ private fun ChatInputBar(
865 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1108 if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
866 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1109 // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
867 // With it off, Enter inserts a newline and Ctrl+Enter sends.
1110 // With it off, Enter inserts a newline and Ctrl+Enter sends.
868− val send = when {
1111+ val send = enterShouldSend(
869− !enabled || value.isBlank() -> false
1112+ enabled = enabled,
1113+ hasContent = hasContent,
1114+ sendOnEnter = sendOnEnter,
870− sendOnEnter -> !event.isShiftPressed
1115+ shift = event.isShiftPressed,
871− else -> event.isCtrlPressed
1116+ ctrl = event.isCtrlPressed,
872− }
1117+ )
873 if (send) {
1118 if (send) {
874 if (running) onQueueFollowUp(value) else onSend()
1119 if (running) onQueueFollowUp(value) else onSend()
875 true
1120 true
@@ -904,40 +1149,73 @@ private fun ChatInputBar(
904 capitalization = KeyboardCapitalization.Sentences,
1149 capitalization = KeyboardCapitalization.Sentences,
905 ),
1150 ),
906 keyboardActions = KeyboardActions(onSend = {
1151 keyboardActions = KeyboardActions(onSend = {
907− if (enabled && value.isNotBlank()) {
1152+ if (enabled && hasContent) {
908 if (running) onQueueFollowUp(value) else onSend()
1153 if (running) onQueueFollowUp(value) else onSend()
909 }
1154 }
910 }),
1155 }),
911 )
1156 )
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+) {
912− if (running) {
1193+ if (running) {
913− // Show a spinner while the abort REST call is in flight so the user
1194+ // Show a spinner while the abort REST call is in flight so the user sees the stop
914− // sees the stop was sent, and disable to prevent a double-tap.
1195+ // was sent, and disable to prevent a double-tap.
915− IconButton(
1196+ IconButton(
916− onClick = onAbort,
1197+ onClick = onAbort,
917− enabled = !aborting,
1198+ enabled = !aborting,
918− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1199+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
919− ) {
1200+ ) {
920− if (aborting) {
1201+ if (aborting) {
921− val stopLabel = stringResource(R.string.stop)
1202+ val stopLabel = stringResource(R.string.stop)
922− CircularProgressIndicator(
1203+ CircularProgressIndicator(
923− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1204+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924− strokeWidth = 2.dp,
1205+ strokeWidth = 2.dp,
925− )
1206+ )
926− } else {
1207+ } else {
927− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1208+ 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− }
939 }
1209 }
940 }
1210 }
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+ }
941 }
1219 }
942}
1220}
943
1221
@@ -997,6 +1275,110 @@ private fun EmptyConversation(
997 }
1275 }
998}
1276}
999
1277
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+
1000/**
1382/**
1001 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1383 * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1002 * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1384 * 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
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5import soy.iko.opencode.data.model.Agent
5import soy.iko.opencode.data.model.Agent
6import soy.iko.opencode.data.model.Command
6import soy.iko.opencode.data.model.Command
7+import soy.iko.opencode.data.model.FilePromptPart
7import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.MessageWithParts
8import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.ModelOption
9import soy.iko.opencode.data.model.Permission
10import soy.iko.opencode.data.model.Permission
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.merge
45import kotlinx.coroutines.flow.onEach
46import kotlinx.coroutines.flow.onEach
46import kotlinx.coroutines.flow.retryWhen
47import kotlinx.coroutines.flow.retryWhen
47import kotlinx.coroutines.flow.stateIn
48import kotlinx.coroutines.flow.stateIn
49+import kotlinx.coroutines.flow.update
48import kotlinx.coroutines.delay
50import kotlinx.coroutines.delay
49import kotlinx.coroutines.launch
51import kotlinx.coroutines.launch
50
52
@@ -56,6 +58,23 @@ import kotlinx.coroutines.launch
56 */
58 */
57data class ChatError(val message: String, val retryable: Boolean = false)
59data class ChatError(val message: String, val retryable: Boolean = false)
58
60
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+
59@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
78@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
60class ChatViewModel(
79class ChatViewModel(
61 private val container: AppContainer,
80 private val container: AppContainer,
@@ -245,6 +264,28 @@ class ChatViewModel(
245 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
264 private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
246 val draft: StateFlow<String> = _draft.asStateFlow()
265 val draft: StateFlow<String> = _draft.asStateFlow()
247
266
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+
248 init {
289 init {
249 // DraftStore loads SharedPreferences asynchronously; on a cold start the
290 // DraftStore loads SharedPreferences asynchronously; on a cold start the
250 // synchronous get() above returns "" until the background load completes.
291 // synchronous get() above returns "" until the background load completes.
@@ -415,7 +456,7 @@ class ChatViewModel(
415 val queued = _queuedFollowUp.value
456 val queued = _queuedFollowUp.value
416 if (queued != null) {
457 if (queued != null) {
417 setQueuedFollowUp(null)
458 setQueuedFollowUp(null)
418− send(queued)
459+ send(queued, includeAttachments = false)
419 }
460 }
420 }
461 }
421 if (SessionRepository.isError(event, sessionId)) {
462 if (SessionRepository.isError(event, sessionId)) {
@@ -436,7 +477,12 @@ class ChatViewModel(
436 is PermissionReplied ->
477 is PermissionReplied ->
437 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
478 if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
438 is SessionUpdated ->
479 is SessionUpdated ->
480+ if (event.properties.info.id == sessionId) {
481+ val info = event.properties.info
439− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
482+ _sessionTitle.value = info.displayTitle
483+ _reverted.value = info.isReverted
484+ _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
485+ }
440 is SessionDeleted ->
486 is SessionDeleted ->
441 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
487 if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
442 _sessionDeleted.value = true
488 _sessionDeleted.value = true
@@ -503,7 +549,11 @@ class ChatViewModel(
503 runCatchingCancellable { conn.repository.listSessions() }
549 runCatchingCancellable { conn.repository.listSessions() }
504 .getOrNull()
550 .getOrNull()
505 ?.firstOrNull { it.id == sessionId }
551 ?.firstOrNull { it.id == sessionId }
552+ ?.let { session ->
506− ?.let { _sessionTitle.value = it.displayTitle }
553+ _sessionTitle.value = session.displayTitle
554+ _reverted.value = session.isReverted
555+ _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
556+ }
507 }
557 }
508 }
558 }
509 // If the SSE stream drops mid-run, the run indicator would spin forever;
559 // If the SSE stream drops mid-run, the run indicator would spin forever;
@@ -527,12 +577,25 @@ class ChatViewModel(
527 }
577 }
528 }
578 }
529
579
580+ /** Sends [text] together with any staged attachments; returns true on success so the
530− /** Sends [text]; returns true on success so the caller can clear the draft only then. */
581+ * caller can clear the draft only then. */
531− fun send(text: String): Boolean {
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 {
532 val conn = connection ?: return false
589 val conn = connection ?: return false
533 val trimmed = text.trim()
590 val trimmed = text.trim()
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.
534− if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
594+ if ((trimmed.isEmpty() && attachments.isEmpty()) || !_running.compareAndSet(false, true)) return false
535 _failedDraft.value = null
595 _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()
536 // Clear the in-memory draft for the UI immediately, but don't persist the clear
599 // Clear the in-memory draft for the UI immediately, but don't persist the clear
537 // yet — if the send fails and the process dies before we restore, the draft
600 // yet — if the send fails and the process dies before we restore, the draft
538 // would be lost forever. The persisted draft is cleared only on success.
601 // would be lost forever. The persisted draft is cleared only on success.
@@ -551,6 +614,7 @@ class ChatViewModel(
551 conn.repository.sendPrompt(
614 conn.repository.sendPrompt(
552 sessionId,
615 sessionId,
553 trimmed,
616 trimmed,
617+ attachments = attachments.map { it.part },
554 model = _selectedModel.value?.ref,
618 model = _selectedModel.value?.ref,
555 agent = _selectedAgent.value,
619 agent = _selectedAgent.value,
556 )
620 )
@@ -559,6 +623,9 @@ class ChatViewModel(
559 _failedDraft.value = trimmed
623 _failedDraft.value = trimmed
560 // Only restore the draft if the user hasn't typed anything new since.
624 // Only restore the draft if the user hasn't typed anything new since.
561 if (_draft.value.isBlank()) updateDraft(trimmed)
625 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
562 // Retryable: this is the failed send whose prompt Retry re-submits.
629 // Retryable: this is the failed send whose prompt Retry re-submits.
563 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
630 _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
564 _running.value = false
631 _running.value = false
@@ -585,7 +652,9 @@ class ChatViewModel(
585 val draft = _failedDraft.value ?: return
652 val draft = _failedDraft.value ?: return
586 // Don't clear _failedDraft until send() accepts the text — if _running is
653 // Don't clear _failedDraft until send() accepts the text — if _running is
587 // already true, send() returns false and the draft would be lost forever.
654 // already true, send() returns false and the draft would be lost forever.
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).
588− if (send(draft)) {
657+ if (send(draft, includeAttachments = false)) {
589 _failedDraft.value = null
658 _failedDraft.value = null
590 }
659 }
591 }
660 }
@@ -675,6 +744,125 @@ class ChatViewModel(
675 }
744 }
676 }
745 }
677
746
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+
678 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
866 /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
679 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
867 * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
680 * the caller keeps the dialog open so the user can retry without retyping. */
868 * the caller keeps the dialog open so the user can retry without retyping. */
@@ -751,6 +939,12 @@ class ChatViewModel(
751 }
939 }
752 }
940 }
753
941
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+
754 override fun onCleared() {
948 override fun onCleared() {
755 super.onCleared()
949 super.onCleared()
756 // Flush any pending debounced draft so it survives navigation. Uses an
950 // 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
10import androidx.compose.material.icons.Icons
10import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
11import androidx.compose.material.icons.automirrored.filled.HelpOutline
12import androidx.compose.material.icons.filled.ContentCopy
12import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material.icons.filled.Restore
13import androidx.compose.material3.Icon
14import androidx.compose.material3.Icon
14import androidx.compose.material3.IconButton
15import androidx.compose.material3.IconButton
15import androidx.compose.material3.MaterialTheme
16import androidx.compose.material3.MaterialTheme
@@ -58,11 +59,28 @@ fun MessageBubble(
58 imageContext: ImageLoadContext? = null,
59 imageContext: ImageLoadContext? = null,
59 modelLabel: String? = null,
60 modelLabel: String? = null,
60 onOpenFile: ((String) -> Unit)? = null,
61 onOpenFile: ((String) -> Unit)? = null,
62+ onRevert: (() -> Unit)? = null,
61) {
63) {
62 when (message.info) {
64 when (message.info) {
63− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
65+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert)
64 is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
66 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+ )
66 }
84 }
67}
85}
68
86
@@ -107,6 +125,7 @@ private fun UserBubble(
107 imageContext: ImageLoadContext?,
125 imageContext: ImageLoadContext?,
108 modifier: Modifier,
126 modifier: Modifier,
109 onOpenFile: ((String) -> Unit)? = null,
127 onOpenFile: ((String) -> Unit)? = null,
128+ onRevert: (() -> Unit)? = null,
110) {
129) {
111 val context = LocalContext.current
130 val context = LocalContext.current
112 val copyLabel = stringResource(R.string.copy)
131 val copyLabel = stringResource(R.string.copy)
@@ -134,19 +153,22 @@ private fun UserBubble(
134 verticalAlignment = Alignment.CenterVertically,
153 verticalAlignment = Alignment.CenterVertically,
135 ) {
154 ) {
136 MessageTimestampText(message.info)
155 MessageTimestampText(message.info)
156+ Row(verticalAlignment = Alignment.CenterVertically) {
157+ onRevert?.let { RevertButton(it) }
137− // Copy affordance for user prompts, mirroring the assistant block.
158+ // Copy affordance for user prompts, mirroring the assistant block.
138− // Without it, reusing a prior prompt requires discovering the
159+ // Without it, reusing a prior prompt requires discovering the
139− // long-press on the markdown text (and only TextParts support that).
160+ // long-press on the markdown text (and only TextParts support that).
140− if (textToCopy != null) {
161+ if (textToCopy != null) {
141− IconButton(
162+ IconButton(
142− onClick = { copyToClipboard(context, "message", textToCopy) },
163+ onClick = { copyToClipboard(context, "message", textToCopy) },
143− ) {
164+ ) {
144− Icon(
165+ Icon(
145− Icons.Filled.ContentCopy,
166+ Icons.Filled.ContentCopy,
146− contentDescription = copyLabel,
167+ contentDescription = copyLabel,
147− modifier = Modifier.size(18.dp),
168+ modifier = Modifier.size(18.dp),
148− tint = MaterialTheme.colorScheme.onSurfaceVariant,
169+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
149− )
170+ )
171+ }
150 }
172 }
151 }
173 }
152 }
174 }
@@ -162,6 +184,7 @@ private fun AssistantBlock(
162 modifier: Modifier,
184 modifier: Modifier,
163 modelLabel: String? = null,
185 modelLabel: String? = null,
164 onOpenFile: ((String) -> Unit)? = null,
186 onOpenFile: ((String) -> Unit)? = null,
187+ onRevert: (() -> Unit)? = null,
165) {
188) {
166 Column(
189 Column(
167 modifier = modifier.fillMaxWidth(),
190 modifier = modifier.fillMaxWidth(),
@@ -229,16 +252,19 @@ private fun AssistantBlock(
229 .joinToString("\n\n") { it.text }
252 .joinToString("\n\n") { it.text }
230 .takeIf { it.isNotBlank() }
253 .takeIf { it.isNotBlank() }
231 }
254 }
255+ Row(verticalAlignment = Alignment.CenterVertically) {
256+ onRevert?.let { RevertButton(it) }
232− if (textToCopy != null) {
257+ if (textToCopy != null) {
233− IconButton(
258+ IconButton(
234− onClick = { copyToClipboard(context, "message", textToCopy) },
259+ onClick = { copyToClipboard(context, "message", textToCopy) },
235− ) {
260+ ) {
236− Icon(
261+ Icon(
237− Icons.Filled.ContentCopy,
262+ Icons.Filled.ContentCopy,
238− contentDescription = copyLabel,
263+ contentDescription = copyLabel,
239− modifier = Modifier.size(18.dp),
264+ modifier = Modifier.size(18.dp),
240− tint = MaterialTheme.colorScheme.onSurfaceVariant,
265+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
241− )
266+ )
267+ }
242 }
268 }
243 }
269 }
244 }
270 }
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+172−5
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.file
2
2
3import androidx.compose.foundation.clickable
3import androidx.compose.foundation.clickable
4import androidx.compose.foundation.horizontalScroll
4import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Row
8import androidx.compose.foundation.layout.Row
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.padding
13import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.layout.size
14import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.LazyColumn
15import androidx.compose.foundation.lazy.items
16import androidx.compose.foundation.lazy.items
17+import androidx.compose.foundation.lazy.itemsIndexed
16import androidx.compose.foundation.rememberScrollState
18import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.KeyboardActions
19import androidx.compose.foundation.text.KeyboardActions
18import androidx.compose.foundation.text.KeyboardOptions
20import androidx.compose.foundation.text.KeyboardOptions
@@ -26,6 +28,7 @@ import androidx.compose.material.icons.filled.Home
26import androidx.compose.material.icons.filled.Search
28import androidx.compose.material.icons.filled.Search
27import androidx.compose.material3.CircularProgressIndicator
29import androidx.compose.material3.CircularProgressIndicator
28import androidx.compose.material3.ExperimentalMaterial3Api
30import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.FilterChip
29import androidx.compose.material3.HorizontalDivider
32import androidx.compose.material3.HorizontalDivider
30import androidx.compose.material3.Icon
33import androidx.compose.material3.Icon
31import androidx.compose.material3.IconButton
34import androidx.compose.material3.IconButton
@@ -49,14 +52,21 @@ import androidx.compose.ui.semantics.Role
49import androidx.compose.ui.semantics.contentDescription
52import androidx.compose.ui.semantics.contentDescription
50import androidx.compose.ui.semantics.role
53import androidx.compose.ui.semantics.role
51import androidx.compose.ui.semantics.semantics
54import androidx.compose.ui.semantics.semantics
55+import androidx.compose.ui.text.SpanStyle
56+import androidx.compose.ui.text.buildAnnotatedString
52import androidx.compose.ui.text.font.FontFamily
57import androidx.compose.ui.text.font.FontFamily
58+import androidx.compose.ui.text.font.FontWeight
53import androidx.compose.ui.text.input.ImeAction
59import androidx.compose.ui.text.input.ImeAction
54import androidx.compose.ui.text.style.TextOverflow
60import androidx.compose.ui.text.style.TextOverflow
61+import androidx.compose.ui.text.withStyle
55import androidx.compose.ui.unit.dp
62import androidx.compose.ui.unit.dp
56import androidx.compose.ui.res.stringResource
63import androidx.compose.ui.res.stringResource
57import androidx.lifecycle.compose.collectAsStateWithLifecycle
64import androidx.lifecycle.compose.collectAsStateWithLifecycle
58import androidx.lifecycle.viewmodel.compose.viewModel
65import androidx.lifecycle.viewmodel.compose.viewModel
59import soy.iko.opencode.data.model.FileStatusEntry
66import 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
60import soy.iko.opencode.di.AppContainer
70import soy.iko.opencode.di.AppContainer
61import soy.iko.opencode.R
71import soy.iko.opencode.R
62import soy.iko.opencode.ui.vmFactory
72import soy.iko.opencode.ui.vmFactory
@@ -65,7 +75,7 @@ import soy.iko.opencode.ui.vmFactory
65@Composable
75@Composable
66fun FileBrowserScreen(
76fun FileBrowserScreen(
67 container: AppContainer,
77 container: AppContainer,
68− onOpenFile: (String) -> Unit,
78+ onOpenFile: (path: String, line: Int?) -> Unit,
69 onBack: () -> Unit,
79 onBack: () -> Unit,
70) {
80) {
71 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
81 val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
@@ -103,8 +113,18 @@ fun FileBrowserScreen(
103 OutlinedTextField(
113 OutlinedTextField(
104 value = state.query,
114 value = state.query,
105 onValueChange = vm::setQuery,
115 onValueChange = vm::setQuery,
106− modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
116+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
117+ label = {
118+ Text(
107− label = { Text(stringResource(R.string.search_files)) },
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+ },
108 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
128 leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
109 trailingIcon = if (state.query.isNotEmpty()) {
129 trailingIcon = if (state.query.isNotEmpty()) {
110 {
130 {
@@ -117,6 +137,16 @@ fun FileBrowserScreen(
117 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
137 keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
118 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
138 keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
119 )
139 )
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))
120
150
121 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
151 androidx.compose.material3.pulltorefresh.PullToRefreshBox(
122 isRefreshing = refreshing,
152 isRefreshing = refreshing,
@@ -149,12 +179,16 @@ fun FileBrowserScreen(
149 Text(stringResource(R.string.retry))
179 Text(stringResource(R.string.retry))
150 }
180 }
151 }
181 }
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) }
152− state.isSearching -> SearchResults(state.results, onOpenFile)
186+ state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
153 else -> DirectoryListing(
187 else -> DirectoryListing(
154 state = state,
188 state = state,
155 onOpenDir = vm::open,
189 onOpenDir = vm::open,
156 onUp = vm::up,
190 onUp = vm::up,
157− onOpenFile = onOpenFile,
191+ onOpenFile = { onOpenFile(it, null) },
158 )
192 )
159 }
193 }
160 }
194 }
@@ -226,6 +260,139 @@ private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
226 }
260 }
227}
261}
228
262
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+
229@Composable
396@Composable
230private fun DirectoryListing(
397private fun DirectoryListing(
231 state: FileBrowserState,
398 state: FileBrowserState,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+76−1
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
5import androidx.lifecycle.viewModelScope
5import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.FileNode
6import soy.iko.opencode.data.model.FileNode
7import soy.iko.opencode.data.model.FileStatusEntry
7import soy.iko.opencode.data.model.FileStatusEntry
8+import soy.iko.opencode.data.model.FindMatch
9+import soy.iko.opencode.data.model.SymbolResult
8import soy.iko.opencode.data.network.NetworkConfig
10import soy.iko.opencode.data.network.NetworkConfig
9import soy.iko.opencode.di.AppContainer
11import soy.iko.opencode.di.AppContainer
10import soy.iko.opencode.R
12import soy.iko.opencode.R
@@ -23,18 +25,26 @@ import kotlinx.coroutines.flow.collectLatest
23import kotlinx.coroutines.flow.update
25import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.launch
26import kotlinx.coroutines.launch
25
27
28+/** What a search query targets: file names, file contents (ripgrep), or workspace symbols. */
29+enum class SearchMode { FILES, TEXT, SYMBOL }
30+
26@Immutable
31@Immutable
27data class FileBrowserState(
32data class FileBrowserState(
28 val path: String = "",
33 val path: String = "",
29 val entries: List<FileNode> = emptyList(),
34 val entries: List<FileNode> = emptyList(),
30 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
35 val statusMap: Map<String, FileStatusEntry> = emptyMap(),
36+ val mode: SearchMode = SearchMode.FILES,
31 val query: String = "",
37 val query: String = "",
32 val results: List<String> = emptyList(),
38 val results: List<String> = emptyList(),
39+ val textResults: List<FindMatch> = emptyList(),
40+ val symbolResults: List<SymbolResult> = emptyList(),
33 val searching: Boolean = false,
41 val searching: Boolean = false,
34 val loading: Boolean = true,
42 val loading: Boolean = true,
35 val error: String? = null,
43 val error: String? = null,
36) {
44) {
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. */
37− val isSearching: Boolean get() = query.isNotBlank()
47+ val isSearching: Boolean get() = mode != SearchMode.FILES || query.isNotBlank()
38}
48}
39
49
40@OptIn(ExperimentalCoroutinesApi::class)
50@OptIn(ExperimentalCoroutinesApi::class)
@@ -131,8 +141,11 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
131 }
141 }
132 _state.update { it.copy(
142 _state.update { it.copy(
133 path = path,
143 path = path,
144+ mode = SearchMode.FILES,
134 query = "",
145 query = "",
135 results = emptyList(),
146 results = emptyList(),
147+ textResults = emptyList(),
148+ symbolResults = emptyList(),
136 searching = false,
149 searching = false,
137 loading = true,
150 loading = true,
138 error = null,
151 error = null,
@@ -159,10 +172,36 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
159 open(parent)
172 open(parent)
160 }
173 }
161
174
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+
162 fun setQuery(query: String) {
193 fun setQuery(query: String) {
163 _state.update { it.copy(query = query, error = null) }
194 _state.update { it.copy(query = query, error = null) }
164 searchJob?.cancel()
195 searchJob?.cancel()
165 openJob?.cancel()
196 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) {
166 if (query.isBlank()) {
205 if (query.isBlank()) {
167 _state.update { it.copy(results = emptyList(), searching = false) }
206 _state.update { it.copy(results = emptyList(), searching = false) }
168 // Cancelling openJob above may have interrupted an in-flight directory load,
207 // Cancelling openJob above may have interrupted an in-flight directory load,
@@ -188,4 +227,40 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
188 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
227 .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
189 }
228 }
190 }
229 }
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+ }
191}
266}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+17−2
@@ -92,6 +92,7 @@ fun FileViewScreen(
92 container: AppContainer,
92 container: AppContainer,
93 path: String,
93 path: String,
94 onBack: () -> Unit,
94 onBack: () -> Unit,
95+ initialLine: Int? = null,
95) {
96) {
96 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
97 val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
97 val state by vm.state.collectAsStateWithLifecycle()
98 val state by vm.state.collectAsStateWithLifecycle()
@@ -102,9 +103,11 @@ fun FileViewScreen(
102 }
103 }
103 val shareLabel = stringResource(R.string.share)
104 val shareLabel = stringResource(R.string.share)
104 // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
105 // 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).
106 val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
109 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) }
108 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
111 val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
109 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
112 // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
110 // user's query/mode.
113 // user's query/mode.
@@ -138,6 +141,18 @@ fun FileViewScreen(
138 LaunchedEffect(matchPos, matchIndices) {
141 LaunchedEffect(matchPos, matchIndices) {
139 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
142 matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
140 }
143 }
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+ }
141
156
142 Scaffold(
157 Scaffold(
143 topBar = {
158 topBar = {
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+63
@@ -2,6 +2,7 @@ package soy.iko.opencode.ui.server
2
2
3import androidx.activity.compose.BackHandler
3import androidx.activity.compose.BackHandler
4import androidx.compose.animation.AnimatedVisibility
4import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Box
7import androidx.compose.foundation.layout.Column
8import androidx.compose.foundation.layout.Column
@@ -20,6 +21,7 @@ import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.automirrored.filled.ArrowBack
21import androidx.compose.material.icons.filled.Visibility
22import androidx.compose.material.icons.filled.Visibility
22import androidx.compose.material.icons.filled.VisibilityOff
23import androidx.compose.material.icons.filled.VisibilityOff
24+import androidx.compose.material.icons.filled.Wifi
23import androidx.compose.material3.AlertDialog
25import androidx.compose.material3.AlertDialog
24import androidx.compose.material3.Button
26import androidx.compose.material3.Button
25import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.CircularProgressIndicator
@@ -41,6 +43,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
41import androidx.compose.runtime.setValue
43import androidx.compose.runtime.setValue
42import androidx.compose.ui.Alignment
44import androidx.compose.ui.Alignment
43import androidx.compose.ui.Modifier
45import androidx.compose.ui.Modifier
46+import androidx.compose.ui.platform.LocalContext
44import androidx.compose.ui.platform.testTag
47import androidx.compose.ui.platform.testTag
45import androidx.compose.ui.semantics.contentDescription
48import androidx.compose.ui.semantics.contentDescription
46import androidx.compose.ui.semantics.semantics
49import androidx.compose.ui.semantics.semantics
@@ -52,6 +55,7 @@ import androidx.compose.ui.res.stringResource
52import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
53import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
54import androidx.lifecycle.viewmodel.compose.viewModel
57import androidx.lifecycle.viewmodel.compose.viewModel
58+import soy.iko.opencode.data.network.NsdDiscovery
55import soy.iko.opencode.di.AppContainer
59import soy.iko.opencode.di.AppContainer
56import soy.iko.opencode.R
60import soy.iko.opencode.R
57import soy.iko.opencode.ui.vmFactory
61import soy.iko.opencode.ui.vmFactory
@@ -218,6 +222,9 @@ private fun ServerEditForm(
218 modifier = Modifier.fillMaxWidth(),
222 modifier = Modifier.fillMaxWidth(),
219 )
223 )
220 }
224 }
225+ DiscoverySection(
226+ onPick = { url -> vm.update { it.copy(baseUrl = url, error = null, probeReachable = null) } },
227+ )
221 if (!state.authFieldsVisible) {
228 if (!state.authFieldsVisible) {
222 Text(
229 Text(
223 stringResource(R.string.auth_help),
230 stringResource(R.string.auth_help),
@@ -354,3 +361,59 @@ private fun AuthFields(
354 }
361 }
355 }
362 }
356}
363}
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(
446 }
446 }
447 }
447 }
448 } else {
448 } 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) }
449 PullToRefreshBox(
452 PullToRefreshBox(
450 isRefreshing = refreshing,
453 isRefreshing = refreshing,
451 onRefresh = onRefresh,
454 onRefresh = onRefresh,
@@ -456,7 +459,8 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
456 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
459 contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
457 verticalArrangement = Arrangement.spacedBy(12.dp),
460 verticalArrangement = Arrangement.spacedBy(12.dp),
458 ) {
461 ) {
459− items(sessions, key = { it.id }) { session ->
462+ items(nodes, key = { it.session.id }) { node ->
463+ val session = node.session
460 // Swipe end-to-start reveals a delete affordance and opens the
464 // Swipe end-to-start reveals a delete affordance and opens the
461 // same confirmation dialog as the trash icon. We never commit
465 // same confirmation dialog as the trash icon. We never commit
462 // the dismissal (always reset to Settled) so the card snaps back
466 // the dismissal (always reset to Settled) so the card snaps back
@@ -482,7 +486,11 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
482 SwipeToDismissBox(
486 SwipeToDismissBox(
483 state = swipeState,
487 state = swipeState,
484 enableDismissFromStartToEnd = false,
488 enableDismissFromStartToEnd = false,
489+ // Indent sub-sessions under their parent (capped so deep
490+ // nesting doesn't squeeze the card off-screen).
485− modifier = Modifier.animateItem(),
491+ modifier = Modifier
492+ .animateItem()
493+ .padding(start = (node.depth.coerceAtMost(3) * 16).dp),
486 backgroundContent = {
494 backgroundContent = {
487 Box(
495 Box(
488 modifier = Modifier
496 modifier = Modifier
@@ -519,6 +527,40 @@ private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
519 }
527 }
520}
528}
521
529
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+
522@Composable
564@Composable
523private fun RenameSessionDialog(
565private fun RenameSessionDialog(
524 title: String,
566 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
63import soy.iko.opencode.data.repo.ThemeMode
63import soy.iko.opencode.data.repo.ThemeMode
64import soy.iko.opencode.di.AppContainer
64import soy.iko.opencode.di.AppContainer
65import soy.iko.opencode.R
65import soy.iko.opencode.R
66+import soy.iko.opencode.ui.canAuthenticateForAppLock
66import soy.iko.opencode.ui.theme.LightPaletteSwatches
67import soy.iko.opencode.ui.theme.LightPaletteSwatches
67import soy.iko.opencode.ui.theme.DarkPaletteSwatches
68import soy.iko.opencode.ui.theme.DarkPaletteSwatches
68import soy.iko.opencode.util.runCatchingCancellable
69import soy.iko.opencode.util.runCatchingCancellable
@@ -82,7 +83,8 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
82 container.settingsStore.themeMode,
83 container.settingsStore.themeMode,
83 container.settingsStore.dynamicColor,
84 container.settingsStore.dynamicColor,
84 container.settingsStore.sendOnEnter,
85 container.settingsStore.sendOnEnter,
86+ container.settingsStore.appLock,
85− ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
87+ ) { theme, dyn, enter, lock -> SettingsValues(theme, dyn, enter, lock) as SettingsValues? }
86 }
88 }
87 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
89 val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
88 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
90 val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
@@ -204,6 +206,39 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
204 onCheckedChange = null,
206 onCheckedChange = null,
205 )
207 )
206 }
208 }
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+ }
207 } else {
242 } else {
208 Box(
243 Box(
209 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
244 modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
@@ -388,6 +423,7 @@ private data class SettingsValues(
388 val themeMode: ThemeMode,
423 val themeMode: ThemeMode,
389 val dynamicColor: Boolean,
424 val dynamicColor: Boolean,
390 val sendOnEnter: Boolean,
425 val sendOnEnter: Boolean,
426+ val appLock: Boolean,
391)
427)
392
428
393/** Small circular count badge used to indicate pending crash reports. */
429/** Small circular count badge used to indicate pending crash reports. */
app/src/main/res/values/strings.xml+47
@@ -73,6 +73,8 @@
73 <string name="show_password">Show password</string>
73 <string name="show_password">Show password</string>
74 <string name="hide_password">Hide password</string>
74 <string name="hide_password">Hide password</string>
75 <string name="test_credentials">Test credentials</string>
75 <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>
76 <string name="testing_credentials">Testing…</string>
78 <string name="testing_credentials">Testing…</string>
77 <string name="credentials_ok">Credentials accepted.</string>
79 <string name="credentials_ok">Credentials accepted.</string>
78 <string name="credentials_rejected">Credentials rejected.</string>
80 <string name="credentials_rejected">Credentials rejected.</string>
@@ -281,4 +283,49 @@
281
283
282 <!-- Search -->
284 <!-- Search -->
283 <string name="clear_search">Clear search</string>
285 <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>
284</resources>
331</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() {
173 override suspend fun sendPrompt(
173 override suspend fun sendPrompt(
174 sessionId: String,
174 sessionId: String,
175 text: String,
175 text: String,
176+ attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
176 model: ModelRef?,
177 model: ModelRef?,
177 agent: String?,
178 agent: String?,
178 ): MessageWithParts {
179 ): MessageWithParts {
@@ -276,6 +277,7 @@ class FakeSessionRepository(
276 override suspend fun sendPrompt(
277 override suspend fun sendPrompt(
277 sessionId: String,
278 sessionId: String,
278 text: String,
279 text: String,
280+ attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
279 model: ModelRef?,
281 model: ModelRef?,
280 agent: String?,
282 agent: String?,
281 ): MessageWithParts {
283 ): MessageWithParts {
config/detekt/detekt.yml+8
@@ -47,6 +47,14 @@ style:
47 FunctionOnlyReturningConstant:
47 FunctionOnlyReturningConstant:
48 active: false
48 active: false
49
49
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+
50exceptions:
58exceptions:
51 active: true
59 active: true
52 TooGenericExceptionCaught:
60 TooGenericExceptionCaught:
gradle/libs.versions.toml+2
@@ -11,6 +11,7 @@ navigationCompose = "2.8.5"
11datastore = "1.1.1"
11datastore = "1.1.1"
12securityCrypto = "1.1.0-alpha06"
12securityCrypto = "1.1.0-alpha06"
13coil = "2.7.0"
13coil = "2.7.0"
14+biometric = "1.1.0"
14markdownRenderer = "0.27.0"
15markdownRenderer = "0.27.0"
15coreKtx = "1.13.1"
16coreKtx = "1.13.1"
16splashscreen = "1.0.1"
17splashscreen = "1.0.1"
@@ -52,6 +53,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
52androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
53androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
53androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
54androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
54coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
55coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
56+androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
55markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
57markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
56markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
58markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
57
59