git.iko.soy/opencode-android
on master help sign in
init
commitf9ed438c4ec80466f6dda5c64506530f688b887e authoriko <mail@iko.soy> dateMon 29 Jun 2026, 06:45 (3 months ago)
64 files changed+4201
.gitignore+30 README.md+67 app/build.gradle.kts+82 app/proguard-rules.pro+9 app/src/main/AndroidManifest.xml+27 app/src/main/java/soy/iko/opencode/MainActivity.kt+32 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+14 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+109 app/src/main/java/soy/iko/opencode/data/model/Common.kt+24 app/src/main/java/soy/iko/opencode/data/model/Files.kt+34 app/src/main/java/soy/iko/opencode/data/model/Message.kt+60 app/src/main/java/soy/iko/opencode/data/model/Part.kt+91 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+43 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+18 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+66 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+18 app/src/main/java/soy/iko/opencode/data/model/Session.kt+28 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+49 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+75 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+79 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+110 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+36 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+118 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+154 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+28 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+39 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+31 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+97 app/src/main/java/soy/iko/opencode/ui/Routes.kt+19 app/src/main/java/soy/iko/opencode/ui/VmFactory.kt+16 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+238 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+128 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+82 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+70 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+151 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+55 app/src/main/java/soy/iko/opencode/ui/components/StubScreen.kt+41 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+150 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+79 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+38 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+79 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+107 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+75 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+132 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+52 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+126 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+64 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+96 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+47 app/src/main/res/values/strings.xml+4 app/src/main/res/values/themes.xml+4 app/src/main/res/xml/network_security_config.xml+13 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+107 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+88 build.gradle.kts+6 flake.lock+61 flake.nix+71 gradle.properties+5 gradle/libs.versions.toml+53 gradle/wrapper/gradle-wrapper.jar gradle/wrapper/gradle-wrapper.properties+7 gradlew+251 gradlew.bat+94 settings.gradle.kts+24
.gitignorenew file+30
@@ -0,0 +1,30 @@
1+# Gradle
2+.gradle/
3+build/
4+
5+# Kotlin
6+.kotlin/
7+
8+# Local configuration (signing keys, api keys, etc.)
9+local.properties
10+*.keystore
11+*.jks
12+
13+# IntelliJ / Android Studio
14+*.iml
15+.idea/
16+captures/
17+.externalNativeBuild/
18+.cxx/
19+
20+# Eclipse
21+.classpath
22+.project
23+.settings/
24+
25+# OS
26+.DS_Store
27+Thumbs.db
28+
29+# Nix
30+result
README.mdnew file+67
@@ -0,0 +1,67 @@
1+# opencode-android
2+
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
4+
5+Application id: `soy.iko.opencode`
6+
7+## Status
8+
9+Milestones M0–M5 complete:
10+
11+- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12+ of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
13+- **M5:** per-session **model picker** (`GET /config/providers`); **permission prompts** — the
14+ agent's `permission.updated` asks surface as an allow-once / always / reject dialog, answered via
15+ `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
16+ (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
17+ banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
18+
19+Not yet built: rich diff/patch rendering, agents/commands UI, markdown rendering, multi-server
20+quick-switch.
21+
22+## Architecture
23+
24+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
25+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
26+ (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
27+ discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
28+ can't break decoding.
29+- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
30+- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
31+ `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
32+ Compose collects with `collectAsStateWithLifecycle`.
33+- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
34+
35+Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
36+(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
37+
38+## Build
39+
40+```bash
41+nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
42+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
43+./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
44+```
45+
46+## Install & run
47+
48+```bash
49+adb install -r app/build/outputs/apk/debug/app-debug.apk
50+adb shell am start -n soy.iko.opencode/.MainActivity
51+adb logcat --pid=$(adb shell pidof soy.iko.opencode)
52+```
53+
54+## Connect to a server
55+
56+On the dev host:
57+
58+```bash
59+opencode serve --hostname 0.0.0.0 --port 4096
60+```
61+
62+In the app, add a server with base URL `http://<host-lan-ip>:4096`. If the host is reachable only
63+over USB, use `adb reverse tcp:4096 tcp:4096` and point the app at `http://127.0.0.1:4096`.
64+
65+**Auth** is optional: opencode has no auth by default. If the server runs with
66+`OPENCODE_SERVER_PASSWORD=...`, enter username `opencode` (the default) and that password — the app
67+sends HTTP Basic auth. The app permits cleartext HTTP (opencode serves plain `http://`).
app/build.gradle.ktsnew file+82
@@ -0,0 +1,82 @@
1+plugins {
2+ alias(libs.plugins.android.application)
3+ alias(libs.plugins.kotlin.android)
4+ alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.compose)
6+}
7+
8+android {
9+ namespace = "soy.iko.opencode"
10+ compileSdk = 35
11+
12+ defaultConfig {
13+ applicationId = "soy.iko.opencode"
14+ minSdk = 26
15+ targetSdk = 35
16+ versionCode = 1
17+ versionName = "0.1.0"
18+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
19+ }
20+
21+ buildTypes {
22+ release {
23+ isMinifyEnabled = false
24+ proguardFiles(
25+ getDefaultProguardFile("proguard-android-optimize.txt"),
26+ "proguard-rules.pro",
27+ )
28+ }
29+ }
30+
31+ compileOptions {
32+ sourceCompatibility = JavaVersion.VERSION_17
33+ targetCompatibility = JavaVersion.VERSION_17
34+ }
35+
36+ kotlinOptions {
37+ jvmTarget = "17"
38+ }
39+
40+ buildFeatures {
41+ compose = true
42+ }
43+
44+ packaging {
45+ resources {
46+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
47+ }
48+ }
49+}
50+
51+dependencies {
52+ implementation(libs.androidx.core.ktx)
53+ implementation(libs.androidx.activity.compose)
54+ implementation(libs.androidx.lifecycle.viewmodel.compose)
55+ implementation(libs.androidx.lifecycle.runtime.compose)
56+ implementation(libs.androidx.navigation.compose)
57+
58+ implementation(platform(libs.compose.bom))
59+ implementation(libs.compose.ui)
60+ implementation(libs.compose.ui.graphics)
61+ implementation(libs.compose.ui.tooling.preview)
62+ implementation(libs.compose.material3)
63+ implementation(libs.compose.material.icons.extended)
64+ debugImplementation(libs.compose.ui.tooling)
65+
66+ implementation(libs.ktor.client.core)
67+ implementation(libs.ktor.client.okhttp)
68+ implementation(libs.ktor.client.content.negotiation)
69+ implementation(libs.ktor.serialization.kotlinx.json)
70+ implementation(libs.ktor.client.auth)
71+ implementation(libs.ktor.client.logging)
72+
73+ implementation(libs.kotlinx.serialization.json)
74+ implementation(libs.kotlinx.coroutines.android)
75+
76+ implementation(libs.androidx.datastore.preferences)
77+ implementation(libs.androidx.security.crypto)
78+ implementation(libs.coil.compose)
79+
80+ testImplementation(libs.junit)
81+ testImplementation(libs.kotlinx.coroutines.test)
82+}
app/proguard-rules.pronew file+9
@@ -0,0 +1,9 @@
1+# kotlinx.serialization keeps generated serializers via the plugin; standard rules below.
2+-keepattributes *Annotation*, InnerClasses
3+-dontnote kotlinx.serialization.**
4+-keepclassmembers class soy.iko.opencode.** {
5+ *** Companion;
6+}
7+-keepclasseswithmembers class soy.iko.opencode.** {
8+ kotlinx.serialization.KSerializer serializer(...);
9+}
app/src/main/AndroidManifest.xmlnew file+27
@@ -0,0 +1,27 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+ <uses-permission android:name="android.permission.INTERNET" />
5+
6+ <application
7+ android:name=".OpencodeApp"
8+ android:allowBackup="true"
9+ android:label="@string/app_name"
10+ android:networkSecurityConfig="@xml/network_security_config"
11+ android:supportsRtl="true"
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
14+
15+ <activity
16+ android:name=".MainActivity"
17+ android:exported="true"
18+ android:label="@string/app_name"
19+ android:theme="@style/Theme.OpencodeAndroid">
20+ <intent-filter>
21+ <action android:name="android.intent.action.MAIN" />
22+ <category android:name="android.intent.category.LAUNCHER" />
23+ </intent-filter>
24+ </activity>
25+ </application>
26+
27+</manifest>
app/src/main/java/soy/iko/opencode/MainActivity.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode
2+
3+import android.os.Bundle
4+import androidx.activity.ComponentActivity
5+import androidx.activity.compose.setContent
6+import androidx.activity.enableEdgeToEdge
7+import androidx.compose.foundation.isSystemInDarkTheme
8+import androidx.compose.runtime.getValue
9+import androidx.lifecycle.compose.collectAsStateWithLifecycle
10+import soy.iko.opencode.data.repo.ThemeMode
11+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
12+import soy.iko.opencode.ui.theme.OpencodeTheme
13+
14+class MainActivity : ComponentActivity() {
15+ override fun onCreate(savedInstanceState: Bundle?) {
16+ super.onCreate(savedInstanceState)
17+ enableEdgeToEdge()
18+ val container = (application as OpencodeApp).container
19+ setContent {
20+ val themeMode by container.settingsStore.themeMode
21+ .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
22+ val dark = when (themeMode) {
23+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
24+ ThemeMode.LIGHT -> false
25+ ThemeMode.DARK -> true
26+ }
27+ OpencodeTheme(darkTheme = dark) {
28+ OpencodeAppUi(container = container)
29+ }
30+ }
31+ }
32+}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+14
@@ -0,0 +1,14 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import soy.iko.opencode.di.AppContainer
5+
6+class OpencodeApp : Application() {
7+ lateinit var container: AppContainer
8+ private set
9+
10+ override fun onCreate() {
11+ super.onCreate()
12+ container = AppContainer(this)
13+ }
14+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8+ * Discriminated on `type` (the global discriminator). Unknown events decode to
9+ * [UnknownEvent] so the stream never dies on a new event type.
10+ *
11+ * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12+ * drift across opencode versions; the reducer only reads the fields it needs.
13+ */
14+@Serializable
15+sealed interface BusEvent
16+
17+@Serializable
18+@SerialName("server.connected")
19+data object ServerConnected : BusEvent
20+
21+@Serializable
22+@SerialName("message.updated")
23+data class MessageUpdated(val properties: Props) : BusEvent {
24+ @Serializable
25+ data class Props(val info: MessageInfo)
26+}
27+
28+@Serializable
29+@SerialName("message.part.updated")
30+data class MessagePartUpdated(val properties: Props) : BusEvent {
31+ @Serializable
32+ data class Props(
33+ val part: Part,
34+ val sessionID: String? = null,
35+ val messageID: String? = null,
36+ )
37+}
38+
39+@Serializable
40+@SerialName("message.part.removed")
41+data class MessagePartRemoved(val properties: Props) : BusEvent {
42+ @Serializable
43+ data class Props(
44+ val sessionID: String? = null,
45+ val messageID: String? = null,
46+ val partID: String? = null,
47+ )
48+}
49+
50+@Serializable
51+@SerialName("message.removed")
52+data class MessageRemoved(val properties: Props) : BusEvent {
53+ @Serializable
54+ data class Props(
55+ val sessionID: String? = null,
56+ val messageID: String? = null,
57+ )
58+}
59+
60+@Serializable
61+@SerialName("session.idle")
62+data class SessionIdle(val properties: Props) : BusEvent {
63+ @Serializable
64+ data class Props(val sessionID: String? = null)
65+}
66+
67+@Serializable
68+@SerialName("session.error")
69+data class SessionError(val properties: Props) : BusEvent {
70+ @Serializable
71+ data class Props(
72+ val sessionID: String? = null,
73+ val error: kotlinx.serialization.json.JsonElement? = null,
74+ )
75+}
76+
77+@Serializable
78+@SerialName("session.updated")
79+data class SessionUpdated(val properties: Props) : BusEvent {
80+ @Serializable
81+ data class Props(val info: Session)
82+}
83+
84+@Serializable
85+@SerialName("session.deleted")
86+data class SessionDeleted(val properties: Props) : BusEvent {
87+ @Serializable
88+ data class Props(val info: Session? = null, val sessionID: String? = null)
89+}
90+
91+@Serializable
92+@SerialName("permission.updated")
93+data class PermissionUpdated(val properties: Permission) : BusEvent
94+
95+@Serializable
96+@SerialName("permission.replied")
97+data class PermissionReplied(val properties: Props) : BusEvent {
98+ @Serializable
99+ data class Props(
100+ val sessionID: String? = null,
101+ val permissionID: String? = null,
102+ val response: String? = null,
103+ )
104+}
105+
106+/** Fallback for any event type we don't model. */
107+@Serializable
108+@SerialName("__unknown")
109+data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktnew file+24
@@ -0,0 +1,24 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+@Serializable
6+data class TimeInfo(
7+ val created: Long? = null,
8+ val updated: Long? = null,
9+ val completed: Long? = null,
10+)
11+
12+@Serializable
13+data class Tokens(
14+ val input: Long = 0,
15+ val output: Long = 0,
16+ val reasoning: Long = 0,
17+ val cache: Cache = Cache(),
18+) {
19+ @Serializable
20+ data class Cache(
21+ val read: Long = 0,
22+ val write: Long = 0,
23+ )
24+}
app/src/main/java/soy/iko/opencode/data/model/Files.ktnew file+34
@@ -0,0 +1,34 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Entry from `GET /file?path=` (directory listing). */
6+@Serializable
7+data class FileNode(
8+ val name: String = "",
9+ val path: String = "",
10+ val type: String? = null,
11+) {
12+ val isDirectory: Boolean get() = type == "directory"
13+}
14+
15+/** Response of `GET /file/content?path=`. */
16+@Serializable
17+data class FileContent(
18+ val type: String? = null, // "text" | "binary"
19+ val content: String = "",
20+ val diff: String? = null,
21+ val mimeType: String? = null,
22+ val encoding: String? = null,
23+) {
24+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
25+}
26+
27+/** Entry from `GET /file/status` (VCS status). */
28+@Serializable
29+data class FileStatusEntry(
30+ val path: String = "",
31+ val added: Int = 0,
32+ val removed: Int = 0,
33+ val status: String? = null, // "added" | "deleted" | "modified"
34+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonClassDiscriminator
7+
8+/**
9+ * The `info` object of a message. Discriminated on `role`, which differs from the
10+ * global `type` discriminator used by [Part], so this hierarchy overrides it.
11+ */
12+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
13+@Serializable
14+@JsonClassDiscriminator("role")
15+sealed interface MessageInfo {
16+ val id: String
17+ val sessionID: String
18+ val time: TimeInfo?
19+}
20+
21+@Serializable
22+@SerialName("user")
23+data class UserMessage(
24+ override val id: String,
25+ override val sessionID: String,
26+ override val time: TimeInfo? = null,
27+) : MessageInfo
28+
29+@Serializable
30+@SerialName("assistant")
31+data class AssistantMessage(
32+ override val id: String,
33+ override val sessionID: String,
34+ val parentID: String? = null,
35+ val providerID: String? = null,
36+ val modelID: String? = null,
37+ val cost: Double? = null,
38+ val tokens: Tokens? = null,
39+ val error: JsonObject? = null,
40+ override val time: TimeInfo? = null,
41+) : MessageInfo {
42+ /** A run is complete once the server stamps a completion time. */
43+ val isComplete: Boolean get() = time?.completed != null
44+}
45+
46+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
47+@Serializable
48+@SerialName("__unknown")
49+data class UnknownMessage(
50+ override val id: String = "",
51+ override val sessionID: String = "",
52+ override val time: TimeInfo? = null,
53+) : MessageInfo
54+
55+/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
56+@Serializable
57+data class MessageWithParts(
58+ val info: MessageInfo,
59+ val parts: List<Part> = emptyList(),
60+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+91
@@ -0,0 +1,91 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * A message part — a discriminated union on `type` (the global class discriminator).
8+ * Unknown types decode to [UnknownPart] via the registered polymorphic default,
9+ * so the stream survives server additions.
10+ *
11+ * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
12+ * uses to attach streamed parts to the right message.
13+ */
14+@Serializable
15+sealed interface Part {
16+ val id: String
17+ val messageID: String?
18+ val sessionID: String?
19+}
20+
21+@Serializable
22+@SerialName("text")
23+data class TextPart(
24+ override val id: String = "",
25+ override val messageID: String? = null,
26+ override val sessionID: String? = null,
27+ val text: String = "",
28+ val synthetic: Boolean = false,
29+ val ignored: Boolean = false,
30+ val time: TimeInfo? = null,
31+) : Part
32+
33+@Serializable
34+@SerialName("reasoning")
35+data class ReasoningPart(
36+ override val id: String = "",
37+ override val messageID: String? = null,
38+ override val sessionID: String? = null,
39+ val text: String = "",
40+ val time: TimeInfo? = null,
41+) : Part
42+
43+@Serializable
44+@SerialName("tool")
45+data class ToolPart(
46+ override val id: String = "",
47+ override val messageID: String? = null,
48+ override val sessionID: String? = null,
49+ val callID: String = "",
50+ val tool: String = "",
51+ val state: ToolState = ToolPending,
52+) : Part
53+
54+@Serializable
55+@SerialName("file")
56+data class FilePart(
57+ override val id: String = "",
58+ override val messageID: String? = null,
59+ override val sessionID: String? = null,
60+ val mime: String? = null,
61+ val filename: String? = null,
62+ val url: String? = null,
63+ val source: String? = null,
64+) : Part
65+
66+@Serializable
67+@SerialName("step-start")
68+data class StepStartPart(
69+ override val id: String = "",
70+ override val messageID: String? = null,
71+ override val sessionID: String? = null,
72+) : Part
73+
74+@Serializable
75+@SerialName("step-finish")
76+data class StepFinishPart(
77+ override val id: String = "",
78+ override val messageID: String? = null,
79+ override val sessionID: String? = null,
80+ val cost: Double? = null,
81+ val tokens: Tokens? = null,
82+) : Part
83+
84+/** Fallback for an unrecognized `type`. */
85+@Serializable
86+@SerialName("__unknown")
87+data class UnknownPart(
88+ override val id: String = "",
89+ override val messageID: String? = null,
90+ override val sessionID: String? = null,
91+) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktnew file+43
@@ -0,0 +1,43 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+import kotlinx.serialization.json.JsonElement
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonPrimitive
7+import kotlinx.serialization.json.jsonArray
8+
9+/**
10+ * A permission request from `permission.updated`. The tool detail lives in [type] +
11+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12+ * it's kept as a [JsonElement] and flattened for display.
13+ */
14+@Serializable
15+data class Permission(
16+ val id: String,
17+ val sessionID: String = "",
18+ val type: String? = null,
19+ val pattern: JsonElement? = null,
20+ val messageID: String? = null,
21+ val callID: String? = null,
22+ val title: String? = null,
23+ val metadata: JsonObject? = null,
24+ val time: TimeInfo? = null,
25+) {
26+ val patternText: String?
27+ get() = when (val p = pattern) {
28+ null -> null
29+ is JsonPrimitive -> p.content
30+ else -> runCatching { p.jsonArray.joinToString(", ") { (it as JsonPrimitive).content } }.getOrNull()
31+ }
32+}
33+
34+/** Allowed values for the permission response body. */
35+enum class PermissionResponse(val wire: String) {
36+ ONCE("once"),
37+ ALWAYS("always"),
38+ REJECT("reject"),
39+}
40+
41+/** Body for `POST /session/:id/permissions/:permissionID`. */
42+@Serializable
43+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktnew file+18
@@ -0,0 +1,18 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Body for `POST /session/:id/message`. */
6+@Serializable
7+data class PromptRequest(
8+ val parts: List<PromptPart>,
9+ val model: ModelRef? = null,
10+ val agent: String? = null,
11+)
12+
13+/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14+@Serializable
15+data class PromptPart(
16+ val type: String = "text",
17+ val text: String,
18+)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Response of `GET /config/providers`. */
6+@Serializable
7+data class ProvidersResponse(
8+ val providers: List<Provider> = emptyList(),
9+ val default: Map<String, String> = emptyMap(),
10+)
11+
12+@Serializable
13+data class Provider(
14+ val id: String,
15+ val name: String? = null,
16+ val models: Map<String, ModelInfo> = emptyMap(),
17+) {
18+ val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
19+}
20+
21+@Serializable
22+data class ModelInfo(
23+ val id: String? = null,
24+ val name: String? = null,
25+) {
26+ fun displayName(modelKey: String): String =
27+ name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
28+}
29+
30+/** A flattened provider/model selection used by the prompt body and the model picker. */
31+@Serializable
32+data class ModelRef(
33+ val providerID: String,
34+ val modelID: String,
35+)
36+
37+/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
38+data class ModelOption(
39+ val providerID: String,
40+ val modelID: String,
41+ val providerLabel: String,
42+ val modelLabel: String,
43+) {
44+ val ref: ModelRef get() = ModelRef(providerID, modelID)
45+}
46+
47+/** Flatten providers into a sorted list of pickable options. */
48+fun ProvidersResponse.toOptions(): List<ModelOption> =
49+ providers.flatMap { provider ->
50+ provider.models.map { (modelKey, info) ->
51+ ModelOption(
52+ providerID = provider.id,
53+ modelID = info.id ?: modelKey,
54+ providerLabel = provider.displayName,
55+ modelLabel = info.displayName(modelKey),
56+ )
57+ }
58+ }.sortedWith(compareBy({ it.providerLabel.lowercase() }, { it.modelLabel.lowercase() }))
59+
60+/** The default option to preselect: the server's default for some provider, else the first model. */
61+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
62+ default.entries.firstOrNull()?.let { (providerID, modelID) ->
63+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
64+ }
65+ return options.firstOrNull()
66+}
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktnew file+18
@@ -0,0 +1,18 @@
1+package soy.iko.opencode.data.model
2+
3+/**
4+ * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
5+ * separately from EncryptedSharedPreferences and is null when not loaded/needed.
6+ */
7+data class ServerProfile(
8+ val id: String,
9+ val label: String,
10+ val baseUrl: String,
11+ val username: String? = null,
12+ val password: String? = null,
13+ val lastUsed: Long = 0,
14+) {
15+ val hasAuth: Boolean get() = !username.isNullOrBlank()
16+
17+ val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
18+}
app/src/main/java/soy/iko/opencode/data/model/Session.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** A session as returned by `GET /session` and `POST /session`. */
6+@Serializable
7+data class Session(
8+ val id: String,
9+ val parentID: String? = null,
10+ val title: String? = null,
11+ val version: String? = null,
12+ val time: TimeInfo? = null,
13+) {
14+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
15+}
16+
17+/** Body for `POST /session`. */
18+@Serializable
19+data class CreateSessionRequest(
20+ val parentID: String? = null,
21+ val title: String? = null,
22+)
23+
24+/** Body for `PATCH /session/:id`. */
25+@Serializable
26+data class UpdateSessionRequest(
27+ val title: String? = null,
28+)
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonClassDiscriminator
7+
8+/**
9+ * The state machine of a tool call, discriminated on `status`.
10+ * pending -> running -> completed | error
11+ */
12+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
13+@Serializable
14+@JsonClassDiscriminator("status")
15+sealed interface ToolState
16+
17+@Serializable
18+@SerialName("pending")
19+data object ToolPending : ToolState
20+
21+@Serializable
22+@SerialName("running")
23+data class ToolRunning(
24+ val input: JsonElement? = null,
25+ val title: String? = null,
26+ val time: TimeInfo? = null,
27+) : ToolState
28+
29+@Serializable
30+@SerialName("completed")
31+data class ToolCompleted(
32+ val input: JsonElement? = null,
33+ val output: String? = null,
34+ val title: String? = null,
35+ val time: TimeInfo? = null,
36+) : ToolState
37+
38+@Serializable
39+@SerialName("error")
40+data class ToolError(
41+ val input: JsonElement? = null,
42+ val error: String? = null,
43+ val time: TimeInfo? = null,
44+) : ToolState
45+
46+/** Fallback for an unrecognized `status`. */
47+@Serializable
48+@SerialName("__unknown")
49+data object ToolUnknown : ToolState
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.ServerConnected
5+import io.ktor.client.HttpClient
6+import io.ktor.client.plugins.sse.sse
7+import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.delay
9+import kotlinx.coroutines.flow.Flow
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.SharedFlow
12+import kotlinx.coroutines.flow.StateFlow
13+import kotlinx.coroutines.flow.SharingStarted
14+import kotlinx.coroutines.flow.asStateFlow
15+import kotlinx.coroutines.flow.channelFlow
16+import kotlinx.coroutines.flow.shareIn
17+import kotlinx.coroutines.isActive
18+
19+/**
20+ * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
21+ * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
22+ * subscribes (WhileSubscribed) and reconnects with backoff on drop.
23+ *
24+ * Ordering contract: callers that send a prompt must already be collecting [events]
25+ * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
26+ */
27+class EventStreamClient(
28+ private val client: HttpClient,
29+ scope: CoroutineScope,
30+) {
31+ enum class ConnectionState { Disconnected, Connecting, Connected }
32+
33+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
34+ val state: StateFlow<ConnectionState> = _state.asStateFlow()
35+
36+ val events: SharedFlow<BusEvent> = stream()
37+ .shareIn(
38+ scope = scope,
39+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
40+ replay = 0,
41+ )
42+
43+ private fun stream(): Flow<BusEvent> = channelFlow {
44+ var backoffMs = INITIAL_BACKOFF_MS
45+ while (isActive) {
46+ _state.value = ConnectionState.Connecting
47+ try {
48+ client.sse("event") {
49+ backoffMs = INITIAL_BACKOFF_MS
50+ incoming.collect { sse ->
51+ val data = sse.data ?: return@collect
52+ val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
53+ .getOrNull() ?: return@collect
54+ if (event is ServerConnected) {
55+ _state.value = ConnectionState.Connected
56+ }
57+ send(event)
58+ }
59+ }
60+ } catch (t: Throwable) {
61+ if (!isActive) throw t
62+ // fall through to reconnect
63+ }
64+ _state.value = ConnectionState.Disconnected
65+ if (!isActive) break
66+ delay(backoffMs)
67+ backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
68+ }
69+ }
70+
71+ private companion object {
72+ const val INITIAL_BACKOFF_MS = 500L
73+ const val MAX_BACKOFF_MS = 10_000L
74+ }
75+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import io.ktor.client.HttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.auth.Auth
8+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9+import io.ktor.client.plugins.auth.providers.basic
10+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+import io.ktor.client.plugins.defaultRequest
12+import io.ktor.client.plugins.sse.SSE
13+import io.ktor.http.URLBuilder
14+import io.ktor.http.takeFrom
15+import io.ktor.serialization.kotlinx.json.json
16+import java.util.concurrent.TimeUnit
17+
18+/**
19+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
20+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
21+ * SSE plugin used for `GET /event`.
22+ */
23+object HttpClientFactory {
24+
25+ fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
26+ expectSuccess = true
27+
28+ engine {
29+ config {
30+ // The SSE stream is long-lived; never time it out on read.
31+ readTimeout(0, TimeUnit.MILLISECONDS)
32+ connectTimeout(30, TimeUnit.SECONDS)
33+ retryOnConnectionFailure(true)
34+ pingInterval(20, TimeUnit.SECONDS)
35+ }
36+ }
37+
38+ install(ContentNegotiation) {
39+ json(OpencodeJson)
40+ }
41+
42+ install(SSE)
43+
44+ install(HttpTimeout) {
45+ connectTimeoutMillis = 30_000
46+ requestTimeoutMillis = 60_000
47+ }
48+
49+ if (profile.hasAuth) {
50+ install(Auth) {
51+ basic {
52+ credentials {
53+ BasicAuthCredentials(
54+ username = profile.username.orEmpty(),
55+ password = profile.password.orEmpty(),
56+ )
57+ }
58+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
59+ sendWithoutRequest { true }
60+ }
61+ }
62+ }
63+
64+ defaultRequest {
65+ url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
66+ }
67+ }
68+
69+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly. */
70+ fun normalizeBaseUrl(raw: String): String {
71+ val trimmed = raw.trim()
72+ val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
73+ trimmed
74+ } else {
75+ "http://$trimmed"
76+ }
77+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
78+ }
79+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+110
@@ -0,0 +1,110 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.CreateSessionRequest
4+import soy.iko.opencode.data.model.FileContent
5+import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.FileStatusEntry
7+import soy.iko.opencode.data.model.MessageWithParts
8+import soy.iko.opencode.data.model.ModelRef
9+import soy.iko.opencode.data.model.PermissionReplyBody
10+import soy.iko.opencode.data.model.PermissionResponse
11+import soy.iko.opencode.data.model.PromptPart
12+import soy.iko.opencode.data.model.PromptRequest
13+import soy.iko.opencode.data.model.ProvidersResponse
14+import soy.iko.opencode.data.model.Session
15+import soy.iko.opencode.data.model.UpdateSessionRequest
16+import io.ktor.client.HttpClient
17+import io.ktor.client.call.body
18+import io.ktor.client.request.delete
19+import io.ktor.client.request.get
20+import io.ktor.client.request.parameter
21+import io.ktor.client.request.patch
22+import io.ktor.client.request.post
23+import io.ktor.client.request.setBody
24+import io.ktor.http.ContentType
25+import io.ktor.http.contentType
26+
27+/**
28+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
29+ * SSE stream lives in [EventStreamClient]; everything else is here.
30+ */
31+class OpencodeApiClient(private val client: HttpClient) {
32+
33+ /** Lightweight connectivity check used by the connect screen. */
34+ suspend fun ping(): Boolean {
35+ client.get("app")
36+ return true
37+ }
38+
39+ suspend fun listSessions(): List<Session> =
40+ client.get("session").body()
41+
42+ suspend fun createSession(title: String? = null): Session =
43+ client.post("session") {
44+ contentType(ContentType.Application.Json)
45+ setBody(CreateSessionRequest(title = title))
46+ }.body()
47+
48+ suspend fun updateSession(id: String, title: String): Session =
49+ client.patch("session/$id") {
50+ contentType(ContentType.Application.Json)
51+ setBody(UpdateSessionRequest(title = title))
52+ }.body()
53+
54+ suspend fun deleteSession(id: String) {
55+ client.delete("session/$id")
56+ }
57+
58+ suspend fun listMessages(sessionId: String): List<MessageWithParts> =
59+ client.get("session/$sessionId/message").body()
60+
61+ suspend fun sendPrompt(
62+ sessionId: String,
63+ text: String,
64+ model: ModelRef? = null,
65+ agent: String? = null,
66+ ): MessageWithParts =
67+ client.post("session/$sessionId/message") {
68+ contentType(ContentType.Application.Json)
69+ setBody(
70+ PromptRequest(
71+ parts = listOf(PromptPart(text = text)),
72+ model = model,
73+ agent = agent,
74+ ),
75+ )
76+ }.body()
77+
78+ suspend fun abort(sessionId: String) {
79+ client.post("session/$sessionId/abort")
80+ }
81+
82+ suspend fun providers(): ProvidersResponse =
83+ client.get("config/providers").body()
84+
85+ /** Respond to a permission request so a paused tool run can proceed. */
86+ suspend fun respondPermission(
87+ sessionId: String,
88+ permissionId: String,
89+ response: PermissionResponse,
90+ ) {
91+ client.post("session/$sessionId/permissions/$permissionId") {
92+ contentType(ContentType.Application.Json)
93+ setBody(PermissionReplyBody(response.wire))
94+ }
95+ }
96+
97+ // --- Files ---
98+
99+ suspend fun findFiles(query: String): List<String> =
100+ client.get("find/file") { parameter("query", query) }.body()
101+
102+ suspend fun listDirectory(path: String): List<FileNode> =
103+ client.get("file") { parameter("path", path) }.body()
104+
105+ suspend fun readFile(path: String): FileContent =
106+ client.get("file/content") { parameter("path", path) }.body()
107+
108+ suspend fun fileStatus(): List<FileStatusEntry> =
109+ client.get("file/status").body()
110+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.Part
6+import soy.iko.opencode.data.model.ToolState
7+import soy.iko.opencode.data.model.UnknownEvent
8+import soy.iko.opencode.data.model.UnknownMessage
9+import soy.iko.opencode.data.model.UnknownPart
10+import soy.iko.opencode.data.model.ToolUnknown
11+import kotlinx.serialization.ExperimentalSerializationApi
12+import kotlinx.serialization.json.Json
13+import kotlinx.serialization.modules.SerializersModule
14+
15+/**
16+ * Single shared Json for the whole app.
17+ *
18+ * The polymorphic default deserializers are the resilience layer: when the server
19+ * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20+ * Unknown variant instead of throwing — so a new opencode release can't break the
21+ * event stream or message decoding.
22+ */
23+@OptIn(ExperimentalSerializationApi::class)
24+val OpencodeJson: Json = Json {
25+ ignoreUnknownKeys = true
26+ isLenient = true
27+ coerceInputValues = true
28+ explicitNulls = false
29+ classDiscriminator = "type"
30+ serializersModule = SerializersModule {
31+ polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
32+ polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
33+ polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
34+ polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
35+ }
36+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+118
@@ -0,0 +1,118 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.content.SharedPreferences
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import androidx.security.crypto.EncryptedSharedPreferences
9+import androidx.security.crypto.MasterKey
10+import soy.iko.opencode.data.model.ServerProfile
11+import soy.iko.opencode.data.network.OpencodeJson
12+import kotlinx.coroutines.flow.Flow
13+import kotlinx.coroutines.flow.map
14+import kotlinx.serialization.Serializable
15+import kotlinx.serialization.builtins.ListSerializer
16+
17+private val Context.dataStore by preferencesDataStore(name = "server_profiles")
18+
19+/**
20+ * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
21+ * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
22+ * by profile id, and merged back in on read.
23+ */
24+class ProfileStore(context: Context) {
25+
26+ private val appContext = context.applicationContext
27+ private val profilesKey = stringPreferencesKey("profiles_json")
28+
29+ private val securePrefs: SharedPreferences by lazy {
30+ val masterKey = MasterKey.Builder(appContext)
31+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
32+ .build()
33+ EncryptedSharedPreferences.create(
34+ appContext,
35+ "server_secrets",
36+ masterKey,
37+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
38+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
39+ )
40+ }
41+
42+ /** Stored shape on DataStore (everything except the secret password). */
43+ @Serializable
44+ private data class StoredProfile(
45+ val id: String,
46+ val label: String,
47+ val baseUrl: String,
48+ val username: String? = null,
49+ val lastUsed: Long = 0,
50+ )
51+
52+ val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
53+ val json = prefs[profilesKey] ?: return@map emptyList()
54+ runCatching {
55+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
56+ }.getOrDefault(emptyList())
57+ .sortedByDescending { it.lastUsed }
58+ .map { it.toProfile() }
59+ }
60+
61+ private fun StoredProfile.toProfile() = ServerProfile(
62+ id = id,
63+ label = label,
64+ baseUrl = baseUrl,
65+ username = username,
66+ password = username?.let { passwordFor(id) },
67+ lastUsed = lastUsed,
68+ )
69+
70+ /** Resolve a single profile with its password loaded, for opening a connection. */
71+ suspend fun resolve(profile: ServerProfile): ServerProfile =
72+ profile.copy(password = if (profile.hasAuth) passwordFor(profile.id) else null)
73+
74+ fun passwordFor(id: String): String? = securePrefs.getString(passwordKey(id), null)
75+
76+ suspend fun save(profile: ServerProfile) {
77+ appContext.dataStore.edit { prefs ->
78+ val current = prefs[profilesKey]?.let {
79+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
80+ .getOrDefault(emptyList())
81+ } ?: emptyList()
82+ val stored = StoredProfile(
83+ id = profile.id,
84+ label = profile.label,
85+ baseUrl = profile.baseUrl,
86+ username = profile.username?.takeIf { it.isNotBlank() },
87+ lastUsed = profile.lastUsed,
88+ )
89+ val updated = current.filterNot { it.id == profile.id } + stored
90+ prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
91+ }
92+ // Secret goes to encrypted storage (or is cleared when auth removed).
93+ securePrefs.edit().apply {
94+ val pw = profile.password
95+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
96+ putString(passwordKey(profile.id), pw)
97+ } else {
98+ remove(passwordKey(profile.id))
99+ }
100+ }.apply()
101+ }
102+
103+ suspend fun delete(id: String) {
104+ appContext.dataStore.edit { prefs ->
105+ val current = prefs[profilesKey]?.let {
106+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
107+ .getOrDefault(emptyList())
108+ } ?: emptyList()
109+ prefs[profilesKey] = OpencodeJson.encodeToString(
110+ ListSerializer(StoredProfile.serializer()),
111+ current.filterNot { it.id == id },
112+ )
113+ }
114+ securePrefs.edit().remove(passwordKey(id)).apply()
115+ }
116+
117+ private fun passwordKey(id: String) = "pw_$id"
118+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktnew file+154
@@ -0,0 +1,154 @@
1+package soy.iko.opencode.data.repo
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.MessagePartRemoved
6+import soy.iko.opencode.data.model.MessagePartUpdated
7+import soy.iko.opencode.data.model.MessageRemoved
8+import soy.iko.opencode.data.model.MessageUpdated
9+import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ModelRef
11+import soy.iko.opencode.data.model.Part
12+import soy.iko.opencode.data.model.SessionError
13+import soy.iko.opencode.data.model.SessionIdle
14+import soy.iko.opencode.data.network.EventStreamClient
15+import soy.iko.opencode.data.network.OpencodeApiClient
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.channelFlow
18+import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
21+
22+/**
23+ * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
24+ * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
25+ * each keyed by message id with parts keyed by part id (idempotent upserts).
26+ */
27+class SessionRepository(
28+ private val api: OpencodeApiClient,
29+ private val eventStream: EventStreamClient,
30+) {
31+ suspend fun listSessions() = api.listSessions()
32+ suspend fun createSession(title: String? = null) = api.createSession(title)
33+ suspend fun deleteSession(id: String) = api.deleteSession(id)
34+ suspend fun abort(sessionId: String) = api.abort(sessionId)
35+
36+ suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?) =
37+ api.sendPrompt(sessionId, text, model)
38+
39+ /**
40+ * A live, ordered view of [sessionId]'s messages. Begins collecting the event
41+ * stream before the initial REST load so no streamed part is missed, then reduces
42+ * events into the in-memory state.
43+ */
44+ fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
45+ val store = MessageStore()
46+ val lock = Mutex()
47+
48+ suspend fun publish() = send(store.snapshot())
49+
50+ // Subscribe to events first so we don't miss early deltas during the initial load.
51+ val job = launch {
52+ eventStream.events.collect { event ->
53+ lock.withLock {
54+ if (store.reduce(sessionId, event)) publish()
55+ }
56+ }
57+ }
58+
59+ val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
60+ lock.withLock {
61+ store.seed(initial)
62+ publish()
63+ }
64+
65+ // Keep the flow alive until the collector cancels; the launched job is torn down with it.
66+ job.join()
67+ }
68+
69+ /** In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex. */
70+ private class MessageStore {
71+ // messageId -> (info + parts), insertion-ordered.
72+ private val messages = LinkedHashMap<String, MessageWithParts>()
73+
74+ fun snapshot(): List<MessageWithParts> = messages.values.toList()
75+
76+ fun seed(initial: List<MessageWithParts>) {
77+ for (m in initial) messages[m.info.id] = m
78+ }
79+
80+ /** Returns true if the state changed (and a new snapshot should be published). */
81+ fun reduce(sessionId: String, event: BusEvent): Boolean {
82+ return when (event) {
83+ is MessageUpdated -> {
84+ val info = event.properties.info
85+ if (info.sessionID != sessionId) {
86+ false
87+ } else {
88+ val existing = messages[info.id]
89+ messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
90+ true
91+ }
92+ }
93+
94+ is MessagePartUpdated -> {
95+ val part = event.properties.part
96+ val messageId = part.messageID ?: event.properties.messageID
97+ val partSession = part.sessionID ?: event.properties.sessionID
98+ if (messageId == null || (partSession != null && partSession != sessionId)) {
99+ false
100+ } else {
101+ upsertPart(messageId, part)
102+ }
103+ }
104+
105+ is MessagePartRemoved -> {
106+ val messageId = event.properties.messageID
107+ val partId = event.properties.partID
108+ if (messageId == null || partId == null) false else removePart(messageId, partId)
109+ }
110+
111+ is MessageRemoved -> {
112+ val id = event.properties.messageID
113+ if (id == null) false else messages.remove(id) != null
114+ }
115+
116+ else -> false
117+ }
118+ }
119+
120+ private fun upsertPart(messageId: String, part: Part): Boolean {
121+ val current = messages[messageId]
122+ val parts = current?.parts.orEmpty()
123+ val idx = parts.indexOfFirst { it.id == part.id }
124+ val newParts = if (idx >= 0) {
125+ parts.toMutableList().also { it[idx] = part }
126+ } else {
127+ parts + part
128+ }
129+ messages[messageId] = current?.copy(parts = newParts)
130+ ?: MessageWithParts(
131+ info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
132+ parts = newParts,
133+ )
134+ return true
135+ }
136+
137+ private fun removePart(messageId: String, partId: String): Boolean {
138+ val current = messages[messageId] ?: return false
139+ val newParts = current.parts.filterNot { it.id == partId }
140+ if (newParts.size == current.parts.size) return false
141+ messages[messageId] = current.copy(parts = newParts)
142+ return true
143+ }
144+ }
145+
146+ companion object {
147+ /** Convenience: is this event a run-completion signal for [sessionId]? */
148+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
149+ event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
150+
151+ fun isError(event: BusEvent, sessionId: String): Boolean =
152+ event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
153+ }
154+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.edit
5+import androidx.datastore.preferences.core.stringPreferencesKey
6+import androidx.datastore.preferences.preferencesDataStore
7+import kotlinx.coroutines.flow.Flow
8+import kotlinx.coroutines.flow.map
9+
10+enum class ThemeMode { SYSTEM, LIGHT, DARK }
11+
12+private val Context.settingsDataStore by preferencesDataStore(name = "settings")
13+
14+/** Small app-preferences store (theme mode for now; default agent etc. can join later). */
15+class SettingsStore(context: Context) {
16+
17+ private val appContext = context.applicationContext
18+ private val themeKey = stringPreferencesKey("theme_mode")
19+
20+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
21+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
22+ .getOrDefault(ThemeMode.SYSTEM)
23+ }
24+
25+ suspend fun setThemeMode(mode: ThemeMode) {
26+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
27+ }
28+}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+39
@@ -0,0 +1,39 @@
1+package soy.iko.opencode.di
2+
3+import android.content.Context
4+import soy.iko.opencode.data.model.ServerProfile
5+import soy.iko.opencode.data.repo.ProfileStore
6+import soy.iko.opencode.data.repo.SettingsStore
7+import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.Dispatchers
9+import kotlinx.coroutines.SupervisorJob
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+
14+/**
15+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
16+ * process-wide singletons and the currently active [OpencodeConnection].
17+ */
18+class AppContainer(context: Context) {
19+
20+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
21+ val profileStore = ProfileStore(context)
22+ val settingsStore = SettingsStore(context)
23+
24+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
25+ val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
26+
27+ /** Open (or re-open) a connection to [profile], replacing any current one. */
28+ suspend fun connect(profile: ServerProfile): OpencodeConnection {
29+ disconnect()
30+ val resolved = profileStore.resolve(profile).copy(lastUsed = System.currentTimeMillis())
31+ profileStore.save(resolved)
32+ return OpencodeConnection(resolved).also { _activeConnection.value = it }
33+ }
34+
35+ fun disconnect() {
36+ _activeConnection.value?.close()
37+ _activeConnection.value = null
38+ }
39+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+31
@@ -0,0 +1,31 @@
1+package soy.iko.opencode.di
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import soy.iko.opencode.data.network.EventStreamClient
5+import soy.iko.opencode.data.network.HttpClientFactory
6+import soy.iko.opencode.data.network.OpencodeApiClient
7+import soy.iko.opencode.data.repo.SessionRepository
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.SupervisorJob
11+import kotlinx.coroutines.cancel
12+
13+/**
14+ * An active connection to one opencode server. Bundles the Ktor client, REST API,
15+ * SSE event stream, and the reducing repository, all sharing a connection-scoped
16+ * coroutine scope that is cancelled on [close].
17+ */
18+class OpencodeConnection(val profile: ServerProfile) {
19+
20+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
21+
22+ private val client = HttpClientFactory.create(profile)
23+ val api = OpencodeApiClient(client)
24+ val events = EventStreamClient(client, scope)
25+ val repository = SessionRepository(api, events)
26+
27+ fun close() {
28+ scope.cancel()
29+ client.close()
30+ }
31+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.ktnew file+97
@@ -0,0 +1,97 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.compose.runtime.Composable
4+import androidx.compose.runtime.getValue
5+import androidx.navigation.NavType
6+import androidx.navigation.compose.NavHost
7+import androidx.navigation.compose.composable
8+import androidx.navigation.compose.rememberNavController
9+import androidx.navigation.navArgument
10+import androidx.lifecycle.compose.collectAsStateWithLifecycle
11+import soy.iko.opencode.di.AppContainer
12+import soy.iko.opencode.ui.chat.ChatScreen
13+import soy.iko.opencode.ui.file.FileBrowserScreen
14+import soy.iko.opencode.ui.file.FileViewScreen
15+import soy.iko.opencode.ui.server.ServerEditScreen
16+import soy.iko.opencode.ui.server.ServerListScreen
17+import soy.iko.opencode.ui.session.SessionListScreen
18+import soy.iko.opencode.ui.settings.SettingsScreen
19+
20+@Composable
21+fun OpencodeApp(container: AppContainer) {
22+ val navController = rememberNavController()
23+ val active by container.activeConnection.collectAsStateWithLifecycle()
24+
25+ NavHost(navController = navController, startDestination = Routes.SERVERS) {
26+
27+ composable(Routes.SERVERS) {
28+ ServerListScreen(
29+ container = container,
30+ onConnected = { navController.navigate(Routes.SESSIONS) },
31+ onAddProfile = { navController.navigate(Routes.serverEdit()) },
32+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
33+ )
34+ }
35+
36+ composable(
37+ route = "${Routes.SERVER_EDIT}?id={id}",
38+ arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
39+ ) { entry ->
40+ ServerEditScreen(
41+ container = container,
42+ profileId = entry.arguments?.getString("id"),
43+ onDone = { navController.popBackStack() },
44+ )
45+ }
46+
47+ composable(Routes.SESSIONS) {
48+ SessionListScreen(
49+ container = container,
50+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
51+ onDisconnect = {
52+ container.disconnect()
53+ navController.popBackStack(Routes.SERVERS, inclusive = false)
54+ },
55+ onOpenFiles = { navController.navigate(Routes.FILES) },
56+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
57+ )
58+ }
59+
60+ composable(
61+ route = "${Routes.CHAT}/{sessionId}",
62+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
63+ ) { entry ->
64+ ChatScreen(
65+ container = container,
66+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
67+ onBack = { navController.popBackStack() },
68+ )
69+ }
70+
71+ composable(Routes.FILES) {
72+ FileBrowserScreen(
73+ container = container,
74+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
75+ onBack = { navController.popBackStack() },
76+ )
77+ }
78+
79+ composable(
80+ route = "${Routes.FILE_VIEW}?path={path}",
81+ arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
82+ ) { entry ->
83+ FileViewScreen(
84+ container = container,
85+ path = entry.arguments?.getString("path").orEmpty(),
86+ onBack = { navController.popBackStack() },
87+ )
88+ }
89+
90+ composable(Routes.SETTINGS) {
91+ SettingsScreen(
92+ container = container,
93+ onBack = { navController.popBackStack() },
94+ )
95+ }
96+ }
97+}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui
2+
3+/** String routes for the app's NavHost. Kept simple and centralized. */
4+object Routes {
5+ const val SERVERS = "servers"
6+ const val SESSIONS = "sessions"
7+ const val SETTINGS = "settings"
8+ const val FILES = "files"
9+
10+ const val SERVER_EDIT = "server_edit"
11+ fun serverEdit(id: String? = null) =
12+ if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=$id"
13+
14+ const val CHAT = "chat"
15+ fun chat(sessionId: String) = "$CHAT/$sessionId"
16+
17+ const val FILE_VIEW = "file_view"
18+ fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
19+}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktnew file+16
@@ -0,0 +1,16 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewmodel.CreationExtras
5+import androidx.lifecycle.viewmodel.initializer
6+import androidx.lifecycle.viewmodel.viewModelFactory
7+
8+/**
9+ * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10+ * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11+ */
12+inline fun <reified VM : ViewModel> vmFactory(
13+ crossinline create: (CreationExtras) -> VM,
14+) = viewModelFactory {
15+ initializer { create(this) }
16+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+238
@@ -0,0 +1,238 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.lazy.LazyColumn
14+import androidx.compose.foundation.lazy.items
15+import androidx.compose.foundation.lazy.rememberLazyListState
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.Stop
20+import androidx.compose.material.icons.filled.Tune
21+import androidx.compose.material3.CircularProgressIndicator
22+import androidx.compose.material3.ExperimentalMaterial3Api
23+import androidx.compose.material3.Icon
24+import androidx.compose.material3.IconButton
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.OutlinedTextField
27+import androidx.compose.material3.Scaffold
28+import androidx.compose.material3.SnackbarHost
29+import androidx.compose.material3.SnackbarHostState
30+import androidx.compose.material3.Surface
31+import androidx.compose.material3.Text
32+import androidx.compose.material3.TopAppBar
33+import androidx.compose.runtime.Composable
34+import androidx.compose.runtime.LaunchedEffect
35+import androidx.compose.runtime.getValue
36+import androidx.compose.runtime.mutableStateOf
37+import androidx.compose.runtime.remember
38+import androidx.compose.runtime.setValue
39+import androidx.compose.ui.Alignment
40+import androidx.compose.ui.Modifier
41+import androidx.compose.ui.unit.dp
42+import androidx.lifecycle.compose.collectAsStateWithLifecycle
43+import androidx.lifecycle.viewmodel.compose.viewModel
44+import soy.iko.opencode.di.AppContainer
45+import soy.iko.opencode.ui.vmFactory
46+
47+@OptIn(ExperimentalMaterial3Api::class)
48+@Composable
49+fun ChatScreen(
50+ container: AppContainer,
51+ sessionId: String,
52+ onBack: () -> Unit,
53+) {
54+ val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
55+ val messages by vm.messages.collectAsStateWithLifecycle()
56+ val running by vm.running.collectAsStateWithLifecycle()
57+ val error by vm.error.collectAsStateWithLifecycle()
58+ val models by vm.models.collectAsStateWithLifecycle()
59+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
60+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
61+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
62+
63+ val listState = rememberLazyListState()
64+ val snackbar = remember { SnackbarHostState() }
65+ var input by remember { mutableStateOf("") }
66+ var showModelPicker by remember { mutableStateOf(false) }
67+
68+ // Keep the newest content in view as parts stream in.
69+ LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
70+ if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
71+ }
72+ LaunchedEffect(error) {
73+ error?.let {
74+ snackbar.showSnackbar(it)
75+ vm.clearError()
76+ }
77+ }
78+
79+ Scaffold(
80+ topBar = {
81+ TopAppBar(
82+ title = {
83+ Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
84+ Text("Session")
85+ Text(
86+ selectedModel?.modelLabel ?: "default model",
87+ style = MaterialTheme.typography.labelSmall,
88+ color = MaterialTheme.colorScheme.onSurfaceVariant,
89+ )
90+ }
91+ },
92+ navigationIcon = {
93+ IconButton(onClick = onBack) {
94+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
95+ }
96+ },
97+ actions = {
98+ IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
99+ Icon(Icons.Filled.Tune, contentDescription = "Choose model")
100+ }
101+ },
102+ )
103+ },
104+ snackbarHost = { SnackbarHost(snackbar) },
105+ bottomBar = {
106+ ChatInputBar(
107+ value = input,
108+ onValueChange = { input = it },
109+ running = running,
110+ enabled = vm.connected,
111+ onSend = {
112+ vm.send(input)
113+ input = ""
114+ },
115+ onAbort = { vm.abort() },
116+ )
117+ },
118+ ) { padding ->
119+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
120+ if (!vm.connected) {
121+ Text(
122+ "Not connected.",
123+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
124+ color = MaterialTheme.colorScheme.error,
125+ )
126+ } else {
127+ ConnectionBanner(
128+ state = connectionState,
129+ modifier = Modifier.align(Alignment.TopCenter),
130+ )
131+ LazyColumn(
132+ state = listState,
133+ modifier = Modifier.fillMaxSize(),
134+ contentPadding = PaddingValues(16.dp),
135+ verticalArrangement = Arrangement.spacedBy(16.dp),
136+ ) {
137+ items(messages, key = { it.info.id }) { message ->
138+ MessageBubble(message)
139+ }
140+ if (running) {
141+ item(key = "__typing") {
142+ Row(verticalAlignment = Alignment.CenterVertically) {
143+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
144+ Text(" working…", style = MaterialTheme.typography.labelMedium)
145+ }
146+ }
147+ }
148+ }
149+ }
150+ }
151+ }
152+
153+ if (showModelPicker) {
154+ ModelPickerSheet(
155+ options = models,
156+ selected = selectedModel,
157+ onSelect = { vm.selectModel(it) },
158+ onDismiss = { showModelPicker = false },
159+ )
160+ }
161+
162+ pendingPermission?.let { permission ->
163+ PermissionDialog(
164+ permission = permission,
165+ onRespond = { response -> vm.respondPermission(permission, response) },
166+ )
167+ }
168+}
169+
170+@Composable
171+private fun ConnectionBanner(
172+ state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
173+ modifier: Modifier = Modifier,
174+) {
175+ val text = when (state) {
176+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
177+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
178+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
179+ }
180+ if (text != null) {
181+ Surface(
182+ modifier = modifier.fillMaxWidth(),
183+ color = MaterialTheme.colorScheme.tertiaryContainer,
184+ ) {
185+ Row(
186+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
187+ horizontalArrangement = Arrangement.Center,
188+ verticalAlignment = Alignment.CenterVertically,
189+ ) {
190+ CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
191+ Text(
192+ " $text",
193+ style = MaterialTheme.typography.labelMedium,
194+ color = MaterialTheme.colorScheme.onTertiaryContainer,
195+ )
196+ }
197+ }
198+ }
199+}
200+
201+@Composable
202+private fun ChatInputBar(
203+ value: String,
204+ onValueChange: (String) -> Unit,
205+ running: Boolean,
206+ enabled: Boolean,
207+ onSend: () -> Unit,
208+ onAbort: () -> Unit,
209+) {
210+ Surface(tonalElevation = 3.dp) {
211+ Row(
212+ modifier = Modifier.fillMaxWidth().padding(8.dp),
213+ verticalAlignment = Alignment.Bottom,
214+ ) {
215+ OutlinedTextField(
216+ value = value,
217+ onValueChange = onValueChange,
218+ modifier = Modifier.weight(1f),
219+ placeholder = { Text("Message opencode…") },
220+ enabled = enabled,
221+ maxLines = 6,
222+ )
223+ if (running) {
224+ IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
225+ Icon(Icons.Filled.Stop, contentDescription = "Stop")
226+ }
227+ } else {
228+ IconButton(
229+ onClick = onSend,
230+ enabled = enabled && value.isNotBlank(),
231+ modifier = Modifier.padding(start = 4.dp),
232+ ) {
233+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
234+ }
235+ }
236+ }
237+ }
238+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+128
@@ -0,0 +1,128 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.MessageWithParts
6+import soy.iko.opencode.data.model.ModelOption
7+import soy.iko.opencode.data.model.Permission
8+import soy.iko.opencode.data.model.PermissionReplied
9+import soy.iko.opencode.data.model.PermissionResponse
10+import soy.iko.opencode.data.model.PermissionUpdated
11+import soy.iko.opencode.data.model.defaultOption
12+import soy.iko.opencode.data.model.toOptions
13+import soy.iko.opencode.data.network.EventStreamClient
14+import soy.iko.opencode.data.repo.SessionRepository
15+import soy.iko.opencode.di.AppContainer
16+import kotlinx.coroutines.flow.MutableStateFlow
17+import kotlinx.coroutines.flow.SharingStarted
18+import kotlinx.coroutines.flow.StateFlow
19+import kotlinx.coroutines.flow.asStateFlow
20+import kotlinx.coroutines.flow.flowOf
21+import kotlinx.coroutines.flow.stateIn
22+import kotlinx.coroutines.launch
23+
24+class ChatViewModel(
25+ private val container: AppContainer,
26+ private val sessionId: String,
27+) : ViewModel() {
28+
29+ private val connection = container.activeConnection.value
30+
31+ val connected: Boolean get() = connection != null
32+
33+ val messages: StateFlow<List<MessageWithParts>> =
34+ (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
35+ .stateIn(
36+ scope = viewModelScope,
37+ started = SharingStarted.WhileSubscribed(5_000),
38+ initialValue = emptyList(),
39+ )
40+
41+ private val _running = MutableStateFlow(false)
42+ val running: StateFlow<Boolean> = _running.asStateFlow()
43+
44+ private val _error = MutableStateFlow<String?>(null)
45+ val error: StateFlow<String?> = _error.asStateFlow()
46+
47+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
48+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
49+
50+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
51+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
52+
53+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
54+ connection?.events?.state
55+ ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
56+
57+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
58+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
59+
60+ fun clearError() { _error.value = null }
61+
62+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
63+
64+ init {
65+ // Watch the bus for run completion / errors / permission asks for this session.
66+ val conn = connection
67+ if (conn != null) {
68+ viewModelScope.launch {
69+ conn.events.events.collect { event ->
70+ if (SessionRepository.isIdle(event, sessionId)) _running.value = false
71+ if (SessionRepository.isError(event, sessionId)) {
72+ _running.value = false
73+ _error.value = "The agent reported an error."
74+ }
75+ when (event) {
76+ is PermissionUpdated ->
77+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
78+ is PermissionReplied ->
79+ if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
80+ else -> {}
81+ }
82+ }
83+ }
84+ // Load the model catalog; preselect the server default. Failure is non-fatal —
85+ // sending with no model just uses the server's default agent/model.
86+ viewModelScope.launch {
87+ runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
88+ val options = resp.toOptions()
89+ _models.value = options
90+ _selectedModel.value = resp.defaultOption(options)
91+ }
92+ }
93+ }
94+ }
95+
96+ fun send(text: String) {
97+ val conn = connection ?: return
98+ val trimmed = text.trim()
99+ if (trimmed.isEmpty() || _running.value) return
100+ _running.value = true
101+ _error.value = null
102+ viewModelScope.launch {
103+ runCatching { conn.repository.sendPrompt(sessionId, trimmed, model = _selectedModel.value?.ref) }
104+ .onFailure { _error.value = it.message ?: "Failed to send" }
105+ // session.idle is the authoritative finalizer; clear here too in case the
106+ // POST returns first (idempotent).
107+ _running.value = false
108+ }
109+ }
110+
111+ fun abort() {
112+ val conn = connection ?: return
113+ viewModelScope.launch {
114+ runCatching { conn.repository.abort(sessionId) }
115+ _running.value = false
116+ }
117+ }
118+
119+ fun respondPermission(permission: Permission, response: PermissionResponse) {
120+ val conn = connection ?: return
121+ // Clear optimistically; permission.replied will confirm.
122+ _pendingPermission.value = null
123+ viewModelScope.launch {
124+ runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
125+ .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
126+ }
127+ }
128+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+82
@@ -0,0 +1,82 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.widthIn
10+import androidx.compose.foundation.shape.RoundedCornerShape
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.Text
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.draw.clip
17+import androidx.compose.ui.unit.dp
18+import soy.iko.opencode.data.model.AssistantMessage
19+import soy.iko.opencode.data.model.MessageWithParts
20+import soy.iko.opencode.data.model.UserMessage
21+
22+/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
23+@Composable
24+fun MessageBubble(message: MessageWithParts, modifier: Modifier = Modifier) {
25+ when (message.info) {
26+ is UserMessage -> UserBubble(message, modifier)
27+ else -> AssistantBlock(message, modifier)
28+ }
29+}
30+
31+@Composable
32+private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
33+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
34+ Column(
35+ modifier = Modifier
36+ .widthIn(max = 320.dp)
37+ .clip(RoundedCornerShape(16.dp))
38+ .background(MaterialTheme.colorScheme.primaryContainer)
39+ .padding(12.dp),
40+ verticalArrangement = Arrangement.spacedBy(6.dp),
41+ ) {
42+ for (part in message.parts) PartView(part)
43+ }
44+ }
45+}
46+
47+@Composable
48+private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
49+ Column(
50+ modifier = modifier.fillMaxWidth(),
51+ verticalArrangement = Arrangement.spacedBy(8.dp),
52+ ) {
53+ val info = message.info
54+ if (info is AssistantMessage && info.modelID != null) {
55+ Text(
56+ info.modelID,
57+ style = MaterialTheme.typography.labelSmall,
58+ color = MaterialTheme.colorScheme.onSurfaceVariant,
59+ )
60+ }
61+ for (part in message.parts) {
62+ PartView(part, modifier = Modifier.fillMaxWidth())
63+ }
64+ if (info is AssistantMessage) {
65+ val cost = info.cost
66+ val tokens = info.tokens
67+ if (info.isComplete && (cost != null || tokens != null)) {
68+ val parts = buildList {
69+ tokens?.let { add("${it.input}→${it.output} tok") }
70+ cost?.takeIf { it > 0 }?.let { add("$%.4f".format(it)) }
71+ }
72+ if (parts.isNotEmpty()) {
73+ Text(
74+ parts.joinToString(" • "),
75+ style = MaterialTheme.typography.labelSmall,
76+ color = MaterialTheme.colorScheme.onSurfaceVariant,
77+ )
78+ }
79+ }
80+ }
81+ }
82+}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+70
@@ -0,0 +1,70 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.ModalBottomSheet
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+import soy.iko.opencode.data.model.ModelOption
18+
19+/** Bottom sheet that lists every provider/model and lets the user pick one. */
20+@OptIn(ExperimentalMaterial3Api::class)
21+@Composable
22+fun ModelPickerSheet(
23+ options: List<ModelOption>,
24+ selected: ModelOption?,
25+ onSelect: (ModelOption) -> Unit,
26+ onDismiss: () -> Unit,
27+) {
28+ ModalBottomSheet(onDismissRequest = onDismiss) {
29+ Text(
30+ "Model",
31+ style = MaterialTheme.typography.titleMedium,
32+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33+ )
34+ if (options.isEmpty()) {
35+ Text(
36+ "No models reported by the server.",
37+ style = MaterialTheme.typography.bodyMedium,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
40+ )
41+ } else {
42+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
43+ items(options, key = { it.providerID + "/" + it.modelID }) { option ->
44+ val isSelected = option.providerID == selected?.providerID &&
45+ option.modelID == selected.modelID
46+ Column(
47+ modifier = Modifier
48+ .fillMaxWidth()
49+ .clickable {
50+ onSelect(option)
51+ onDismiss()
52+ }
53+ .padding(horizontal = 24.dp, vertical = 12.dp),
54+ ) {
55+ Text(
56+ option.modelLabel,
57+ style = MaterialTheme.typography.bodyLarge,
58+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
59+ )
60+ Text(
61+ option.providerLabel + if (isSelected) " ✓" else "",
62+ style = MaterialTheme.typography.bodySmall,
63+ color = MaterialTheme.colorScheme.onSurfaceVariant,
64+ )
65+ }
66+ }
67+ }
68+ }
69+ }
70+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+151
@@ -0,0 +1,151 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.shape.RoundedCornerShape
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Bolt
14+import androidx.compose.material.icons.filled.CheckCircle
15+import androidx.compose.material.icons.filled.Error
16+import androidx.compose.material.icons.filled.Psychology
17+import androidx.compose.material3.CircularProgressIndicator
18+import androidx.compose.material3.Icon
19+import androidx.compose.material3.MaterialTheme
20+import androidx.compose.material3.Text
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.setValue
26+import androidx.compose.ui.Alignment
27+import androidx.compose.ui.Modifier
28+import androidx.compose.ui.draw.clip
29+import androidx.compose.ui.text.font.FontFamily
30+import androidx.compose.ui.unit.dp
31+import soy.iko.opencode.data.model.FilePart
32+import soy.iko.opencode.data.model.Part
33+import soy.iko.opencode.data.model.ReasoningPart
34+import soy.iko.opencode.data.model.StepFinishPart
35+import soy.iko.opencode.data.model.StepStartPart
36+import soy.iko.opencode.data.model.TextPart
37+import soy.iko.opencode.data.model.ToolCompleted
38+import soy.iko.opencode.data.model.ToolError
39+import soy.iko.opencode.data.model.ToolPart
40+import soy.iko.opencode.data.model.ToolPending
41+import soy.iko.opencode.data.model.ToolRunning
42+import soy.iko.opencode.data.model.ToolState
43+import soy.iko.opencode.data.model.ToolUnknown
44+import soy.iko.opencode.data.model.UnknownPart
45+
46+/**
47+ * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
48+ * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
49+ * (Markdown rendering is deferred — text is shown as-is for the scaffold.)
50+ */
51+@Composable
52+fun PartView(part: Part, modifier: Modifier = Modifier) {
53+ when (part) {
54+ is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
55+ Text(part.text, modifier = modifier, style = MaterialTheme.typography.bodyLarge)
56+ }
57+ is ReasoningPart -> ReasoningBlock(part.text, modifier)
58+ is ToolPart -> ToolCallView(part, modifier)
59+ is FilePart -> FileChip(part, modifier)
60+ is StepStartPart -> {} // boundary marker — nothing to draw
61+ is StepFinishPart -> {} // metrics handled at message level
62+ is UnknownPart -> {} // forward-compat: unknown part types are ignored
63+ }
64+}
65+
66+@Composable
67+private fun ReasoningBlock(text: String, modifier: Modifier) {
68+ if (text.isBlank()) return
69+ var expanded by remember { mutableStateOf(false) }
70+ Column(modifier = modifier.fillMaxWidth()) {
71+ Row(
72+ modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
73+ verticalAlignment = Alignment.CenterVertically,
74+ ) {
75+ Icon(
76+ Icons.Filled.Psychology,
77+ contentDescription = null,
78+ modifier = Modifier.size(16.dp),
79+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
80+ )
81+ Text(
82+ if (expanded) " Thinking" else " Thinking…",
83+ style = MaterialTheme.typography.labelMedium,
84+ color = MaterialTheme.colorScheme.onSurfaceVariant,
85+ )
86+ }
87+ AnimatedVisibility(visible = expanded) {
88+ Text(
89+ text,
90+ style = MaterialTheme.typography.bodySmall,
91+ color = MaterialTheme.colorScheme.onSurfaceVariant,
92+ )
93+ }
94+ }
95+}
96+
97+@Composable
98+private fun ToolCallView(part: ToolPart, modifier: Modifier) {
99+ Column(
100+ modifier = modifier
101+ .fillMaxWidth()
102+ .clip(RoundedCornerShape(8.dp))
103+ .background(MaterialTheme.colorScheme.surfaceVariant)
104+ .padding(10.dp),
105+ ) {
106+ Row(verticalAlignment = Alignment.CenterVertically) {
107+ ToolStatusIcon(part.state)
108+ Text(
109+ " ${part.tool}",
110+ style = MaterialTheme.typography.labelLarge,
111+ fontFamily = FontFamily.Monospace,
112+ )
113+ }
114+ val detail = when (val s = part.state) {
115+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
116+ is ToolError -> s.error ?: "error"
117+ else -> null
118+ }
119+ if (detail != null) {
120+ Text(
121+ detail.take(2000),
122+ style = MaterialTheme.typography.bodySmall,
123+ fontFamily = FontFamily.Monospace,
124+ modifier = Modifier.padding(top = 6.dp),
125+ )
126+ }
127+ }
128+}
129+
130+@Composable
131+private fun ToolStatusIcon(state: ToolState) {
132+ when (state) {
133+ is ToolPending, is ToolRunning ->
134+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
135+ is ToolCompleted ->
136+ Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
137+ is ToolError ->
138+ Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
139+ is ToolUnknown ->
140+ Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
141+ }
142+}
143+
144+@Composable
145+private fun FileChip(part: FilePart, modifier: Modifier) {
146+ Text(
147+ "📎 ${part.filename ?: part.url ?: "file"}",
148+ modifier = modifier.padding(vertical = 2.dp),
149+ style = MaterialTheme.typography.bodyMedium,
150+ )
151+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+55
@@ -0,0 +1,55 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.material3.AlertDialog
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.Text
8+import androidx.compose.material3.TextButton
9+import androidx.compose.runtime.Composable
10+import androidx.compose.ui.Modifier
11+import androidx.compose.ui.text.font.FontFamily
12+import soy.iko.opencode.data.model.Permission
13+import soy.iko.opencode.data.model.PermissionResponse
14+
15+/**
16+ * Modal asking the user to approve a tool the agent wants to run. The agent run is
17+ * paused until one of these responses is sent. Non-dismissable by tap-outside so the
18+ * decision is explicit.
19+ */
20+@Composable
21+fun PermissionDialog(
22+ permission: Permission,
23+ onRespond: (PermissionResponse) -> Unit,
24+) {
25+ AlertDialog(
26+ onDismissRequest = { /* require an explicit choice */ },
27+ title = { Text(permission.title ?: "Permission requested") },
28+ text = {
29+ Column {
30+ permission.type?.let {
31+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
32+ }
33+ permission.patternText?.let {
34+ Text(
35+ it,
36+ style = MaterialTheme.typography.bodySmall,
37+ fontFamily = FontFamily.Monospace,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ )
40+ }
41+ }
42+ },
43+ confirmButton = {
44+ Column(modifier = Modifier.fillMaxWidth()) {
45+ TextButton(onClick = { onRespond(PermissionResponse.ONCE) }) { Text("Allow once") }
46+ TextButton(onClick = { onRespond(PermissionResponse.ALWAYS) }) { Text("Always allow") }
47+ }
48+ },
49+ dismissButton = {
50+ TextButton(onClick = { onRespond(PermissionResponse.REJECT) }) {
51+ Text("Reject", color = MaterialTheme.colorScheme.error)
52+ }
53+ },
54+ )
55+}
app/src/main/java/soy/iko/opencode/ui/components/StubScreen.ktnew file+41
@@ -0,0 +1,41 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.padding
5+import androidx.compose.material.icons.Icons
6+import androidx.compose.material.icons.automirrored.filled.ArrowBack
7+import androidx.compose.material3.ExperimentalMaterial3Api
8+import androidx.compose.material3.Icon
9+import androidx.compose.material3.IconButton
10+import androidx.compose.material3.Scaffold
11+import androidx.compose.material3.Text
12+import androidx.compose.material3.TopAppBar
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+
18+/** Shared placeholder for not-yet-implemented destinations. */
19+@OptIn(ExperimentalMaterial3Api::class)
20+@Composable
21+fun StubScreen(title: String, message: String, onBack: () -> Unit) {
22+ Scaffold(
23+ topBar = {
24+ TopAppBar(
25+ title = { Text(title) },
26+ navigationIcon = {
27+ IconButton(onClick = onBack) {
28+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
29+ }
30+ },
31+ )
32+ },
33+ ) { padding ->
34+ Box(
35+ modifier = Modifier.padding(padding).padding(24.dp),
36+ contentAlignment = Alignment.Center,
37+ ) {
38+ Text(message)
39+ }
40+ }
41+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.items
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.automirrored.filled.ArrowBack
15+import androidx.compose.material.icons.filled.Description
16+import androidx.compose.material.icons.filled.Folder
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.HorizontalDivider
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Scaffold
26+import androidx.compose.material3.Text
27+import androidx.compose.material3.TopAppBar
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.ui.Alignment
31+import androidx.compose.ui.Modifier
32+import androidx.compose.ui.text.style.TextOverflow
33+import androidx.compose.ui.unit.dp
34+import androidx.lifecycle.compose.collectAsStateWithLifecycle
35+import androidx.lifecycle.viewmodel.compose.viewModel
36+import soy.iko.opencode.di.AppContainer
37+import soy.iko.opencode.ui.vmFactory
38+
39+@OptIn(ExperimentalMaterial3Api::class)
40+@Composable
41+fun FileBrowserScreen(
42+ container: AppContainer,
43+ onOpenFile: (String) -> Unit,
44+ onBack: () -> Unit,
45+) {
46+ val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
47+ val state by vm.state.collectAsStateWithLifecycle()
48+
49+ Scaffold(
50+ topBar = {
51+ TopAppBar(
52+ title = { Text(if (state.path.isBlank()) "Files" else state.path, maxLines = 1, overflow = TextOverflow.Ellipsis) },
53+ navigationIcon = {
54+ IconButton(onClick = onBack) {
55+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
56+ }
57+ },
58+ )
59+ },
60+ ) { padding ->
61+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
62+ OutlinedTextField(
63+ value = state.query,
64+ onValueChange = vm::setQuery,
65+ modifier = Modifier.fillMaxWidth().padding(12.dp),
66+ placeholder = { Text("Search files…") },
67+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
68+ singleLine = true,
69+ )
70+
71+ Box(modifier = Modifier.fillMaxSize()) {
72+ when {
73+ state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
74+ state.error != null && !state.isSearching -> Text(
75+ state.error!!,
76+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
77+ color = MaterialTheme.colorScheme.error,
78+ )
79+ state.isSearching -> SearchResults(state.results, onOpenFile)
80+ else -> DirectoryListing(
81+ state = state,
82+ onOpenDir = vm::open,
83+ onUp = vm::up,
84+ onOpenFile = onOpenFile,
85+ )
86+ }
87+ }
88+ }
89+ }
90+}
91+
92+@Composable
93+private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
94+ if (results.isEmpty()) {
95+ Text("No matches.", modifier = Modifier.padding(24.dp))
96+ return
97+ }
98+ LazyColumn(modifier = Modifier.fillMaxSize()) {
99+ items(results, key = { it }) { path ->
100+ FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
101+ HorizontalDivider()
102+ }
103+ }
104+}
105+
106+@Composable
107+private fun DirectoryListing(
108+ state: FileBrowserState,
109+ onOpenDir: (String) -> Unit,
110+ onUp: () -> Unit,
111+ onOpenFile: (String) -> Unit,
112+) {
113+ LazyColumn(modifier = Modifier.fillMaxSize()) {
114+ if (state.path.isNotBlank()) {
115+ item(key = "__up") {
116+ FileRow(icon = true, label = "..", onClick = onUp)
117+ HorizontalDivider()
118+ }
119+ }
120+ items(state.entries, key = { it.path }) { node ->
121+ FileRow(
122+ icon = node.isDirectory,
123+ label = node.name,
124+ onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
125+ )
126+ HorizontalDivider()
127+ }
128+ }
129+}
130+
131+@Composable
132+private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
133+ Row(
134+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
135+ verticalAlignment = Alignment.CenterVertically,
136+ ) {
137+ Icon(
138+ if (icon) Icons.Filled.Folder else Icons.Filled.Description,
139+ contentDescription = null,
140+ modifier = Modifier.size(20.dp),
141+ tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
142+ )
143+ Text(
144+ " $label",
145+ style = MaterialTheme.typography.bodyLarge,
146+ maxLines = 1,
147+ overflow = TextOverflow.Ellipsis,
148+ )
149+ }
150+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.Job
8+import kotlinx.coroutines.delay
9+import kotlinx.coroutines.flow.MutableStateFlow
10+import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.launch
13+
14+data class FileBrowserState(
15+ val path: String = "",
16+ val entries: List<FileNode> = emptyList(),
17+ val query: String = "",
18+ val results: List<String> = emptyList(),
19+ val searching: Boolean = false,
20+ val loading: Boolean = true,
21+ val error: String? = null,
22+) {
23+ val isSearching: Boolean get() = query.isNotBlank()
24+}
25+
26+class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
27+
28+ private val api get() = container.activeConnection.value?.api
29+
30+ private val _state = MutableStateFlow(FileBrowserState())
31+ val state: StateFlow<FileBrowserState> = _state.asStateFlow()
32+
33+ private var searchJob: Job? = null
34+
35+ init { open("") }
36+
37+ fun open(path: String) {
38+ val client = api ?: run {
39+ _state.value = _state.value.copy(loading = false, error = "Not connected")
40+ return
41+ }
42+ _state.value = _state.value.copy(path = path, loading = true, error = null)
43+ viewModelScope.launch {
44+ runCatching { client.listDirectory(path) }
45+ .onSuccess { entries ->
46+ _state.value = _state.value.copy(
47+ entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
48+ loading = false,
49+ )
50+ }
51+ .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Failed to list $path") }
52+ }
53+ }
54+
55+ /** Navigate up one path segment. */
56+ fun up() {
57+ val current = _state.value.path
58+ if (current.isBlank()) return
59+ val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
60+ open(parent)
61+ }
62+
63+ fun setQuery(query: String) {
64+ _state.value = _state.value.copy(query = query)
65+ searchJob?.cancel()
66+ if (query.isBlank()) {
67+ _state.value = _state.value.copy(results = emptyList(), searching = false)
68+ return
69+ }
70+ val client = api ?: return
71+ searchJob = viewModelScope.launch {
72+ delay(250) // debounce
73+ _state.value = _state.value.copy(searching = true)
74+ runCatching { client.findFiles(query) }
75+ .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
76+ .onFailure { _state.value = _state.value.copy(searching = false, error = it.message) }
77+ }
78+ }
79+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+38
@@ -0,0 +1,38 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.FileContent
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.StateFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.launch
11+
12+data class FileViewState(
13+ val loading: Boolean = true,
14+ val content: FileContent? = null,
15+ val error: String? = null,
16+)
17+
18+class FileViewModel(
19+ private val container: AppContainer,
20+ private val path: String,
21+) : ViewModel() {
22+
23+ private val _state = MutableStateFlow(FileViewState())
24+ val state: StateFlow<FileViewState> = _state.asStateFlow()
25+
26+ init {
27+ val api = container.activeConnection.value?.api
28+ if (api == null) {
29+ _state.value = FileViewState(loading = false, error = "Not connected")
30+ } else {
31+ viewModelScope.launch {
32+ runCatching { api.readFile(path) }
33+ .onSuccess { _state.value = FileViewState(loading = false, content = it) }
34+ .onFailure { _state.value = FileViewState(loading = false, error = it.message ?: "Failed to read $path") }
35+ }
36+ }
37+ }
38+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.horizontalScroll
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxSize
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.rememberScrollState
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material3.CircularProgressIndicator
12+import androidx.compose.material3.ExperimentalMaterial3Api
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Scaffold
17+import androidx.compose.material3.Text
18+import androidx.compose.material3.TopAppBar
19+import androidx.compose.runtime.Composable
20+import androidx.compose.runtime.getValue
21+import androidx.compose.ui.Alignment
22+import androidx.compose.ui.Modifier
23+import androidx.compose.ui.text.font.FontFamily
24+import androidx.compose.ui.text.style.TextOverflow
25+import androidx.compose.ui.unit.dp
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+import androidx.lifecycle.viewmodel.compose.viewModel
28+import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.vmFactory
30+
31+@OptIn(ExperimentalMaterial3Api::class)
32+@Composable
33+fun FileViewScreen(
34+ container: AppContainer,
35+ path: String,
36+ onBack: () -> Unit,
37+) {
38+ val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
39+ val state by vm.state.collectAsStateWithLifecycle()
40+
41+ Scaffold(
42+ topBar = {
43+ TopAppBar(
44+ title = { Text(path.substringAfterLast('/'), maxLines = 1, overflow = TextOverflow.Ellipsis) },
45+ navigationIcon = {
46+ IconButton(onClick = onBack) {
47+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
48+ }
49+ },
50+ )
51+ },
52+ ) { padding ->
53+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
54+ when {
55+ state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
56+ state.error != null -> Text(
57+ state.error!!,
58+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
59+ color = MaterialTheme.colorScheme.error,
60+ )
61+ state.content?.isBinary == true -> Text(
62+ "Binary file — preview not supported.",
63+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
64+ color = MaterialTheme.colorScheme.onSurfaceVariant,
65+ )
66+ else -> Text(
67+ text = state.content?.content.orEmpty(),
68+ modifier = Modifier
69+ .fillMaxSize()
70+ .verticalScroll(rememberScrollState())
71+ .horizontalScroll(rememberScrollState())
72+ .padding(12.dp),
73+ style = MaterialTheme.typography.bodySmall,
74+ fontFamily = FontFamily.Monospace,
75+ )
76+ }
77+ }
78+ }
79+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+107
@@ -0,0 +1,107 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.foundation.layout.padding
6+import androidx.compose.foundation.rememberScrollState
7+import androidx.compose.foundation.verticalScroll
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.automirrored.filled.ArrowBack
10+import androidx.compose.material3.Button
11+import androidx.compose.material3.ExperimentalMaterial3Api
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.IconButton
14+import androidx.compose.material3.OutlinedTextField
15+import androidx.compose.material3.Scaffold
16+import androidx.compose.material3.Text
17+import androidx.compose.material3.TopAppBar
18+import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.getValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.text.input.KeyboardType
22+import androidx.compose.ui.text.input.PasswordVisualTransformation
23+import androidx.compose.foundation.text.KeyboardOptions
24+import androidx.compose.foundation.layout.Arrangement
25+import androidx.compose.ui.unit.dp
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+import androidx.lifecycle.viewmodel.compose.viewModel
28+import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.vmFactory
30+
31+@OptIn(ExperimentalMaterial3Api::class)
32+@Composable
33+fun ServerEditScreen(
34+ container: AppContainer,
35+ profileId: String?,
36+ onDone: () -> Unit,
37+) {
38+ val vm: ServerEditViewModel =
39+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
40+ val state by vm.state.collectAsStateWithLifecycle()
41+
42+ Scaffold(
43+ topBar = {
44+ TopAppBar(
45+ title = { Text(if (state.isNew) "Add server" else "Edit server") },
46+ navigationIcon = {
47+ IconButton(onClick = onDone) {
48+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
49+ }
50+ },
51+ )
52+ },
53+ ) { padding ->
54+ Column(
55+ modifier = Modifier
56+ .padding(padding)
57+ .padding(16.dp)
58+ .verticalScroll(rememberScrollState()),
59+ verticalArrangement = Arrangement.spacedBy(12.dp),
60+ ) {
61+ OutlinedTextField(
62+ value = state.label,
63+ onValueChange = { v -> vm.update { it.copy(label = v) } },
64+ label = { Text("Label (optional)") },
65+ singleLine = true,
66+ modifier = Modifier.fillMaxWidth(),
67+ )
68+ OutlinedTextField(
69+ value = state.baseUrl,
70+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
71+ label = { Text("Base URL") },
72+ placeholder = { Text("http://192.168.1.10:4096") },
73+ singleLine = true,
74+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
75+ modifier = Modifier.fillMaxWidth(),
76+ )
77+ Text(
78+ "Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. " +
79+ "Username defaults to \"opencode\".",
80+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
81+ )
82+ OutlinedTextField(
83+ value = state.username,
84+ onValueChange = { v -> vm.update { it.copy(username = v) } },
85+ label = { Text("Username (optional)") },
86+ singleLine = true,
87+ modifier = Modifier.fillMaxWidth(),
88+ )
89+ OutlinedTextField(
90+ value = state.password,
91+ onValueChange = { v -> vm.update { it.copy(password = v) } },
92+ label = { Text("Password (optional)") },
93+ singleLine = true,
94+ visualTransformation = PasswordVisualTransformation(),
95+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
96+ modifier = Modifier.fillMaxWidth(),
97+ )
98+ Button(
99+ onClick = { vm.save(onDone) },
100+ enabled = state.canSave,
101+ modifier = Modifier.fillMaxWidth(),
102+ ) {
103+ Text("Save")
104+ }
105+ }
106+ }
107+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.StateFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.flow.first
11+import kotlinx.coroutines.launch
12+import java.util.UUID
13+
14+data class ServerEditState(
15+ val id: String? = null,
16+ val label: String = "",
17+ val baseUrl: String = "",
18+ val username: String = "",
19+ val password: String = "",
20+ val loaded: Boolean = false,
21+) {
22+ val canSave: Boolean get() = baseUrl.isNotBlank()
23+ val isNew: Boolean get() = id == null
24+}
25+
26+class ServerEditViewModel(
27+ private val container: AppContainer,
28+ private val profileId: String?,
29+) : ViewModel() {
30+
31+ private val _state = MutableStateFlow(ServerEditState(id = profileId))
32+ val state: StateFlow<ServerEditState> = _state.asStateFlow()
33+
34+ init {
35+ viewModelScope.launch {
36+ if (profileId != null) {
37+ val existing = container.profileStore.profiles.first().firstOrNull { it.id == profileId }
38+ if (existing != null) {
39+ _state.value = ServerEditState(
40+ id = existing.id,
41+ label = existing.label,
42+ baseUrl = existing.baseUrl,
43+ username = existing.username.orEmpty(),
44+ password = existing.password.orEmpty(),
45+ loaded = true,
46+ )
47+ return@launch
48+ }
49+ }
50+ _state.value = _state.value.copy(loaded = true)
51+ }
52+ }
53+
54+ fun update(transform: (ServerEditState) -> ServerEditState) {
55+ _state.value = transform(_state.value)
56+ }
57+
58+ fun save(onDone: () -> Unit) {
59+ val s = _state.value
60+ if (!s.canSave) return
61+ viewModelScope.launch {
62+ container.profileStore.save(
63+ ServerProfile(
64+ id = s.id ?: UUID.randomUUID().toString(),
65+ label = s.label.trim(),
66+ baseUrl = s.baseUrl.trim(),
67+ username = s.username.trim().takeIf { it.isNotBlank() },
68+ password = s.password.takeIf { it.isNotEmpty() },
69+ lastUsed = 0,
70+ ),
71+ )
72+ onDone()
73+ }
74+ }
75+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+132
@@ -0,0 +1,132 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.lazy.LazyColumn
13+import androidx.compose.foundation.lazy.items
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Add
16+import androidx.compose.material.icons.filled.Delete
17+import androidx.compose.material.icons.filled.Edit
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.FloatingActionButton
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.Card
25+import androidx.compose.material3.Scaffold
26+import androidx.compose.material3.SnackbarHost
27+import androidx.compose.material3.SnackbarHostState
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TopAppBar
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
32+import androidx.compose.runtime.getValue
33+import androidx.compose.runtime.remember
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.text.style.TextOverflow
37+import androidx.compose.ui.unit.dp
38+import androidx.lifecycle.compose.collectAsStateWithLifecycle
39+import androidx.lifecycle.viewmodel.compose.viewModel
40+import soy.iko.opencode.di.AppContainer
41+import soy.iko.opencode.ui.vmFactory
42+
43+@OptIn(ExperimentalMaterial3Api::class)
44+@Composable
45+fun ServerListScreen(
46+ container: AppContainer,
47+ onConnected: () -> Unit,
48+ onAddProfile: () -> Unit,
49+ onEditProfile: (String) -> Unit,
50+) {
51+ val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
52+ val profiles by vm.profiles.collectAsStateWithLifecycle()
53+ val connectingId by vm.connectingId.collectAsStateWithLifecycle()
54+ val error by vm.error.collectAsStateWithLifecycle()
55+ val snackbar = remember { SnackbarHostState() }
56+
57+ LaunchedEffect(error) {
58+ error?.let {
59+ snackbar.showSnackbar(it)
60+ vm.clearError()
61+ }
62+ }
63+
64+ Scaffold(
65+ topBar = { TopAppBar(title = { Text("opencode servers") }) },
66+ snackbarHost = { SnackbarHost(snackbar) },
67+ floatingActionButton = {
68+ FloatingActionButton(onClick = onAddProfile) {
69+ Icon(Icons.Filled.Add, contentDescription = "Add server")
70+ }
71+ },
72+ ) { padding ->
73+ if (profiles.isEmpty()) {
74+ Box(
75+ modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
76+ contentAlignment = Alignment.Center,
77+ ) {
78+ Text(
79+ "No servers yet.\nTap + to add an opencode serve endpoint.",
80+ style = MaterialTheme.typography.bodyLarge,
81+ )
82+ }
83+ } else {
84+ LazyColumn(
85+ modifier = Modifier.fillMaxSize().padding(padding),
86+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
87+ verticalArrangement = Arrangement.spacedBy(12.dp),
88+ ) {
89+ items(profiles, key = { it.id }) { profile ->
90+ Card(
91+ modifier = Modifier
92+ .fillMaxWidth()
93+ .clickable(enabled = connectingId == null) {
94+ vm.connect(profile, onConnected)
95+ },
96+ ) {
97+ Row(
98+ modifier = Modifier.fillMaxWidth().padding(16.dp),
99+ verticalAlignment = Alignment.CenterVertically,
100+ ) {
101+ Column(modifier = Modifier.weight(1f)) {
102+ Text(
103+ profile.displayLabel,
104+ style = MaterialTheme.typography.titleMedium,
105+ maxLines = 1,
106+ overflow = TextOverflow.Ellipsis,
107+ )
108+ Text(
109+ profile.baseUrl + if (profile.hasAuth) " • auth" else "",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ maxLines = 1,
113+ overflow = TextOverflow.Ellipsis,
114+ )
115+ }
116+ if (connectingId == profile.id) {
117+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
118+ } else {
119+ IconButton(onClick = { onEditProfile(profile.id) }) {
120+ Icon(Icons.Filled.Edit, contentDescription = "Edit")
121+ }
122+ IconButton(onClick = { vm.delete(profile) }) {
123+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
124+ }
125+ }
126+ }
127+ }
128+ }
129+ }
130+ }
131+ }
132+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.SharingStarted
9+import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asStateFlow
11+import kotlinx.coroutines.flow.stateIn
12+import kotlinx.coroutines.launch
13+
14+class ServerListViewModel(private val container: AppContainer) : ViewModel() {
15+
16+ val profiles: StateFlow<List<ServerProfile>> =
17+ container.profileStore.profiles.stateIn(
18+ scope = viewModelScope,
19+ started = SharingStarted.WhileSubscribed(5_000),
20+ initialValue = emptyList(),
21+ )
22+
23+ private val _connecting = MutableStateFlow<String?>(null)
24+ val connectingId: StateFlow<String?> = _connecting.asStateFlow()
25+
26+ private val _error = MutableStateFlow<String?>(null)
27+ val error: StateFlow<String?> = _error.asStateFlow()
28+
29+ fun clearError() { _error.value = null }
30+
31+ fun connect(profile: ServerProfile, onConnected: () -> Unit) {
32+ if (_connecting.value != null) return
33+ _connecting.value = profile.id
34+ _error.value = null
35+ viewModelScope.launch {
36+ val result = runCatching {
37+ val conn = container.connect(profile)
38+ conn.api.ping()
39+ }
40+ _connecting.value = null
41+ result.onSuccess { onConnected() }
42+ .onFailure {
43+ container.disconnect()
44+ _error.value = it.message ?: "Could not reach ${profile.baseUrl}"
45+ }
46+ }
47+ }
48+
49+ fun delete(profile: ServerProfile) {
50+ viewModelScope.launch { container.profileStore.delete(profile.id) }
51+ }
52+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+126
@@ -0,0 +1,126 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.lazy.LazyColumn
13+import androidx.compose.foundation.lazy.items
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.Logout
16+import androidx.compose.material.icons.filled.Add
17+import androidx.compose.material.icons.filled.Delete
18+import androidx.compose.material.icons.filled.Folder
19+import androidx.compose.material.icons.filled.Refresh
20+import androidx.compose.material.icons.filled.Settings
21+import androidx.compose.material3.Card
22+import androidx.compose.material3.CircularProgressIndicator
23+import androidx.compose.material3.ExperimentalMaterial3Api
24+import androidx.compose.material3.ExtendedFloatingActionButton
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.Scaffold
29+import androidx.compose.material3.Text
30+import androidx.compose.material3.TopAppBar
31+import androidx.compose.runtime.Composable
32+import androidx.compose.runtime.getValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.text.style.TextOverflow
36+import androidx.compose.ui.unit.dp
37+import androidx.lifecycle.compose.collectAsStateWithLifecycle
38+import androidx.lifecycle.viewmodel.compose.viewModel
39+import soy.iko.opencode.di.AppContainer
40+import soy.iko.opencode.ui.vmFactory
41+
42+@OptIn(ExperimentalMaterial3Api::class)
43+@Composable
44+fun SessionListScreen(
45+ container: AppContainer,
46+ onOpenSession: (String) -> Unit,
47+ onDisconnect: () -> Unit,
48+ onOpenFiles: () -> Unit,
49+ onOpenSettings: () -> Unit,
50+) {
51+ val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
52+ val state by vm.state.collectAsStateWithLifecycle()
53+
54+ Scaffold(
55+ topBar = {
56+ TopAppBar(
57+ title = { Text(vm.serverLabel) },
58+ actions = {
59+ IconButton(onClick = { vm.refresh() }) {
60+ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
61+ }
62+ IconButton(onClick = onOpenFiles) {
63+ Icon(Icons.Filled.Folder, contentDescription = "Files")
64+ }
65+ IconButton(onClick = onOpenSettings) {
66+ Icon(Icons.Filled.Settings, contentDescription = "Settings")
67+ }
68+ IconButton(onClick = onDisconnect) {
69+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
70+ }
71+ },
72+ )
73+ },
74+ floatingActionButton = {
75+ ExtendedFloatingActionButton(
76+ onClick = { vm.createSession(onCreated = onOpenSession) },
77+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
78+ text = { Text("New session") },
79+ )
80+ },
81+ ) { padding ->
82+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
83+ when {
84+ state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
85+ state.error != null -> Text(
86+ state.error!!,
87+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
88+ color = MaterialTheme.colorScheme.error,
89+ )
90+ state.sessions.isEmpty() -> Text(
91+ "No sessions yet.\nTap “New session” to start.",
92+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
93+ )
94+ else -> LazyColumn(
95+ modifier = Modifier.fillMaxSize(),
96+ contentPadding = PaddingValues(16.dp),
97+ verticalArrangement = Arrangement.spacedBy(12.dp),
98+ ) {
99+ items(state.sessions, key = { it.id }) { session ->
100+ Card(
101+ modifier = Modifier
102+ .fillMaxWidth()
103+ .clickable { onOpenSession(session.id) },
104+ ) {
105+ Row(
106+ modifier = Modifier.fillMaxWidth().padding(16.dp),
107+ verticalAlignment = Alignment.CenterVertically,
108+ ) {
109+ Text(
110+ session.displayTitle,
111+ style = MaterialTheme.typography.titleMedium,
112+ maxLines = 2,
113+ overflow = TextOverflow.Ellipsis,
114+ modifier = Modifier.weight(1f),
115+ )
116+ IconButton(onClick = { vm.deleteSession(session) }) {
117+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
118+ }
119+ }
120+ }
121+ }
122+ }
123+ }
124+ }
125+ }
126+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+64
@@ -0,0 +1,64 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Session
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.StateFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.launch
11+
12+data class SessionListState(
13+ val sessions: List<Session> = emptyList(),
14+ val loading: Boolean = true,
15+ val error: String? = null,
16+)
17+
18+class SessionListViewModel(private val container: AppContainer) : ViewModel() {
19+
20+ val serverLabel: String =
21+ container.activeConnection.value?.profile?.displayLabel ?: "opencode"
22+
23+ private val _state = MutableStateFlow(SessionListState())
24+ val state: StateFlow<SessionListState> = _state.asStateFlow()
25+
26+ init { refresh() }
27+
28+ fun refresh() {
29+ val conn = container.activeConnection.value
30+ if (conn == null) {
31+ _state.value = SessionListState(loading = false, error = "Not connected")
32+ return
33+ }
34+ _state.value = _state.value.copy(loading = true, error = null)
35+ viewModelScope.launch {
36+ runCatching { conn.repository.listSessions() }
37+ .onSuccess { list ->
38+ _state.value = SessionListState(
39+ sessions = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 },
40+ loading = false,
41+ )
42+ }
43+ .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
44+ }
45+ }
46+
47+ fun createSession(onCreated: (String) -> Unit) {
48+ val conn = container.activeConnection.value ?: return
49+ viewModelScope.launch {
50+ runCatching { conn.repository.createSession() }
51+ .onSuccess { onCreated(it.id); refresh() }
52+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to create session") }
53+ }
54+ }
55+
56+ fun deleteSession(session: Session) {
57+ val conn = container.activeConnection.value ?: return
58+ viewModelScope.launch {
59+ runCatching { conn.repository.deleteSession(session.id) }
60+ .onSuccess { refresh() }
61+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
62+ }
63+ }
64+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+96
@@ -0,0 +1,96 @@
1+package soy.iko.opencode.ui.settings
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.selection.selectable
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.automirrored.filled.ArrowBack
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.HorizontalDivider
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.IconButton
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.RadioButton
16+import androidx.compose.material3.Scaffold
17+import androidx.compose.material3.Text
18+import androidx.compose.material3.TopAppBar
19+import androidx.compose.runtime.Composable
20+import androidx.compose.runtime.getValue
21+import androidx.compose.runtime.rememberCoroutineScope
22+import androidx.compose.ui.Alignment
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.unit.dp
25+import androidx.lifecycle.compose.collectAsStateWithLifecycle
26+import kotlinx.coroutines.launch
27+import soy.iko.opencode.data.repo.ThemeMode
28+import soy.iko.opencode.di.AppContainer
29+
30+@OptIn(ExperimentalMaterial3Api::class)
31+@Composable
32+fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
33+ val scope = rememberCoroutineScope()
34+ val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
35+ val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
36+
37+ Scaffold(
38+ topBar = {
39+ TopAppBar(
40+ title = { Text("Settings") },
41+ navigationIcon = {
42+ IconButton(onClick = onBack) {
43+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
44+ }
45+ },
46+ )
47+ },
48+ ) { padding ->
49+ Column(modifier = Modifier.padding(padding).padding(16.dp)) {
50+ Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
51+ ThemeMode.entries.forEach { mode ->
52+ Row(
53+ mode = mode,
54+ selected = themeMode == mode,
55+ onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
56+ )
57+ }
58+
59+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
60+
61+ Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
62+ if (activeProfile != null) {
63+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
64+ Text(
65+ activeProfile.baseUrl + if (activeProfile.hasAuth) " • Basic auth" else "",
66+ style = MaterialTheme.typography.bodySmall,
67+ color = MaterialTheme.colorScheme.onSurfaceVariant,
68+ )
69+ } else {
70+ Text(
71+ "Not connected.",
72+ modifier = Modifier.padding(top = 8.dp),
73+ color = MaterialTheme.colorScheme.onSurfaceVariant,
74+ )
75+ }
76+ }
77+ }
78+}
79+
80+@Composable
81+private fun Row(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
82+ androidx.compose.foundation.layout.Row(
83+ modifier = Modifier
84+ .fillMaxWidth()
85+ .selectable(selected = selected, onClick = onSelect)
86+ .padding(vertical = 8.dp),
87+ horizontalArrangement = Arrangement.spacedBy(8.dp),
88+ verticalAlignment = Alignment.CenterVertically,
89+ ) {
90+ RadioButton(selected = selected, onClick = onSelect)
91+ Text(
92+ mode.name.lowercase().replaceFirstChar { it.uppercase() },
93+ style = MaterialTheme.typography.bodyLarge,
94+ )
95+ }
96+}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.ktnew file+47
@@ -0,0 +1,47 @@
1+package soy.iko.opencode.ui.theme
2+
3+import android.app.Activity
4+import androidx.compose.foundation.isSystemInDarkTheme
5+import androidx.compose.material3.MaterialTheme
6+import androidx.compose.material3.darkColorScheme
7+import androidx.compose.material3.lightColorScheme
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.SideEffect
10+import androidx.compose.ui.graphics.Color
11+import androidx.compose.ui.graphics.toArgb
12+import androidx.compose.ui.platform.LocalView
13+import androidx.core.view.WindowCompat
14+
15+private val DarkColors = darkColorScheme(
16+ primary = Color(0xFF7AA2F7),
17+ secondary = Color(0xFF9ECE6A),
18+ tertiary = Color(0xFFBB9AF7),
19+ background = Color(0xFF1A1B26),
20+ surface = Color(0xFF1F2335),
21+)
22+
23+private val LightColors = lightColorScheme(
24+ primary = Color(0xFF34548A),
25+ secondary = Color(0xFF485E30),
26+ tertiary = Color(0xFF5A4A78),
27+)
28+
29+@Composable
30+fun OpencodeTheme(
31+ darkTheme: Boolean = isSystemInDarkTheme(),
32+ content: @Composable () -> Unit,
33+) {
34+ val colorScheme = if (darkTheme) DarkColors else LightColors
35+ val view = LocalView.current
36+ if (!view.isInEditMode) {
37+ SideEffect {
38+ val window = (view.context as Activity).window
39+ window.statusBarColor = colorScheme.background.toArgb()
40+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
41+ }
42+ }
43+ MaterialTheme(
44+ colorScheme = colorScheme,
45+ content = content,
46+ )
47+}
app/src/main/res/values/strings.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <string name="app_name">opencode</string>
4+</resources>
app/src/main/res/values/themes.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
4+</resources>
app/src/main/res/xml/network_security_config.xmlnew file+13
@@ -0,0 +1,13 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!--
3+ opencode serve speaks plain HTTP. Android blocks cleartext by default on API 28+,
4+ so we permit it. This is a developer tool talking to a self-hosted server on a
5+ trusted network; cleartext is permitted broadly here.
6+-->
7+<network-security-config>
8+ <base-config cleartextTrafficPermitted="true">
9+ <trust-anchors>
10+ <certificates src="system" />
11+ </trust-anchors>
12+ </base-config>
13+</network-security-config>
app/src/test/java/soy/iko/opencode/M5DecodingTest.ktnew file+107
@@ -0,0 +1,107 @@
1+package soy.iko.opencode
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.FileContent
5+import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.PermissionReplied
7+import soy.iko.opencode.data.model.PermissionUpdated
8+import soy.iko.opencode.data.model.ProvidersResponse
9+import soy.iko.opencode.data.model.defaultOption
10+import soy.iko.opencode.data.model.toOptions
11+import soy.iko.opencode.data.network.OpencodeJson
12+import kotlinx.serialization.builtins.ListSerializer
13+import kotlinx.serialization.builtins.serializer
14+import org.junit.Assert.assertEquals
15+import org.junit.Assert.assertNull
16+import org.junit.Assert.assertTrue
17+import org.junit.Test
18+
19+class M5DecodingTest {
20+
21+ private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
22+ OpencodeJson.decodeFromString(s, json)
23+
24+ @Test
25+ fun decodesPermissionUpdatedWithArrayPattern() {
26+ val json = """
27+ { "type": "permission.updated", "properties": {
28+ "id": "perm_1", "sessionID": "ses_1", "type": "bash",
29+ "pattern": ["rm -rf *", "git push"], "title": "Run bash",
30+ "metadata": { "cwd": "/tmp" }, "time": { "created": 123 } } }
31+ """.trimIndent()
32+ val event = decode(BusEvent.serializer(), json) as PermissionUpdated
33+ assertEquals("perm_1", event.properties.id)
34+ assertEquals("bash", event.properties.type)
35+ assertEquals("rm -rf *, git push", event.properties.patternText)
36+ }
37+
38+ @Test
39+ fun decodesPermissionUpdatedWithStringPattern() {
40+ val json = """
41+ { "type": "permission.updated", "properties": {
42+ "id": "perm_2", "sessionID": "ses_1", "type": "edit", "pattern": "src/Main.kt" } }
43+ """.trimIndent()
44+ val event = decode(BusEvent.serializer(), json) as PermissionUpdated
45+ assertEquals("src/Main.kt", event.properties.patternText)
46+ }
47+
48+ @Test
49+ fun decodesPermissionReplied() {
50+ val json = """{"type":"permission.replied","properties":{"sessionID":"s","permissionID":"perm_1","response":"once"}}"""
51+ val event = decode(BusEvent.serializer(), json) as PermissionReplied
52+ assertEquals("perm_1", event.properties.permissionID)
53+ assertEquals("once", event.properties.response)
54+ }
55+
56+ @Test
57+ fun decodesRichProvidersAndFlattens() {
58+ // Mirrors the real (richer) /config/providers shape; unknown fields must be ignored.
59+ val json = """
60+ {
61+ "providers": [
62+ { "id": "anthropic", "name": "Anthropic", "source": "env", "env": ["ANTHROPIC_API_KEY"],
63+ "options": {},
64+ "models": {
65+ "claude-sonnet": { "id": "claude-sonnet", "providerID": "anthropic", "name": "Claude Sonnet",
66+ "capabilities": { "reasoning": true }, "cost": { "input": 3, "output": 15 },
67+ "limit": { "context": 200000, "output": 8192 }, "status": "active" }
68+ } }
69+ ],
70+ "default": { "anthropic": "claude-sonnet" }
71+ }
72+ """.trimIndent()
73+ val resp = decode(ProvidersResponse.serializer(), json)
74+ val options = resp.toOptions()
75+ assertEquals(1, options.size)
76+ assertEquals("Claude Sonnet", options.single().modelLabel)
77+ assertEquals("claude-sonnet", resp.defaultOption(options)?.modelID)
78+ }
79+
80+ @Test
81+ fun decodesFileContentAndDirectoryListing() {
82+ val content = decode(FileContent.serializer(), """{"type":"text","content":"hello\nworld"}""")
83+ assertEquals("hello\nworld", content.content)
84+ assertTrue(!content.isBinary)
85+
86+ val nodes = decode(
87+ ListSerializer(FileNode.serializer()),
88+ """[{"name":"src","path":"src","type":"directory"},{"name":"a.kt","path":"src/a.kt","type":"file"}]""",
89+ )
90+ assertEquals(2, nodes.size)
91+ assertTrue(nodes[0].isDirectory)
92+ assertTrue(!nodes[1].isDirectory)
93+ }
94+
95+ @Test
96+ fun decodesFindFilesAsStringArray() {
97+ val paths = decode(ListSerializer(String.serializer()), """["src/a.kt","src/b.kt"]""")
98+ assertEquals(listOf("src/a.kt", "src/b.kt"), paths)
99+ }
100+
101+ @Test
102+ fun binaryFileContentIsFlagged() {
103+ val content = decode(FileContent.serializer(), """{"type":"binary","content":"AAAA","encoding":"base64","mimeType":"image/png"}""")
104+ assertTrue(content.isBinary)
105+ assertNull(content.diff)
106+ }
107+}
app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktnew file+88
@@ -0,0 +1,88 @@
1+package soy.iko.opencode
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.MessagePartUpdated
6+import soy.iko.opencode.data.model.MessageWithParts
7+import soy.iko.opencode.data.model.Part
8+import soy.iko.opencode.data.model.SessionIdle
9+import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.model.ToolCompleted
11+import soy.iko.opencode.data.model.ToolPart
12+import soy.iko.opencode.data.model.UnknownEvent
13+import soy.iko.opencode.data.model.UnknownPart
14+import soy.iko.opencode.data.network.OpencodeJson
15+import org.junit.Assert.assertEquals
16+import org.junit.Assert.assertTrue
17+import org.junit.Test
18+
19+class OpencodeJsonTest {
20+
21+ private fun <T> decode(serializer: kotlinx.serialization.KSerializer<T>, json: String): T =
22+ OpencodeJson.decodeFromString(serializer, json)
23+
24+ @Test
25+ fun decodesPromptResponseWithPartsAndTokens() {
26+ val json = """
27+ {
28+ "info": {
29+ "role": "assistant", "id": "msg_1", "sessionID": "ses_1",
30+ "modelID": "claude-sonnet", "providerID": "anthropic",
31+ "cost": 0.012,
32+ "tokens": { "input": 10, "output": 20, "reasoning": 0, "cache": { "read": 1, "write": 2 } },
33+ "time": { "created": 100, "completed": 200 }
34+ },
35+ "parts": [
36+ { "type": "text", "id": "prt_1", "messageID": "msg_1", "sessionID": "ses_1", "text": "Hello" }
37+ ]
38+ }
39+ """.trimIndent()
40+
41+ val msg = decode(MessageWithParts.serializer(), json)
42+ val info = msg.info as AssistantMessage
43+ assertEquals("anthropic", info.providerID)
44+ assertEquals(20L, info.tokens?.output)
45+ assertTrue(info.isComplete)
46+ assertEquals("Hello", (msg.parts.single() as TextPart).text)
47+ }
48+
49+ @Test
50+ fun decodesToolPartStateMachine() {
51+ val json = """
52+ { "type": "tool", "id": "prt_2", "messageID": "msg_1", "callID": "c1", "tool": "bash",
53+ "state": { "status": "completed", "output": "done", "title": "ran bash" } }
54+ """.trimIndent()
55+ val part = decode(Part.serializer(), json) as ToolPart
56+ assertEquals("bash", part.tool)
57+ assertEquals("done", (part.state as ToolCompleted).output)
58+ }
59+
60+ @Test
61+ fun decodesPartUpdatedEvent() {
62+ val json = """
63+ { "type": "message.part.updated",
64+ "properties": { "part": { "type": "text", "id": "prt_1", "messageID": "msg_1", "text": "Hel" } } }
65+ """.trimIndent()
66+ val event = decode(BusEvent.serializer(), json) as MessagePartUpdated
67+ assertEquals("Hel", (event.properties.part as TextPart).text)
68+ }
69+
70+ @Test
71+ fun decodesSessionIdleEvent() {
72+ val event = decode(BusEvent.serializer(), """{"type":"session.idle","properties":{"sessionID":"ses_1"}}""")
73+ assertTrue(event is SessionIdle)
74+ assertEquals("ses_1", (event as SessionIdle).properties.sessionID)
75+ }
76+
77+ @Test
78+ fun unknownEventTypeFallsBackInsteadOfThrowing() {
79+ val event = decode(BusEvent.serializer(), """{"type":"some.future.event","properties":{"x":1}}""")
80+ assertEquals(UnknownEvent, event)
81+ }
82+
83+ @Test
84+ fun unknownPartTypeFallsBackInsteadOfThrowing() {
85+ val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
86+ assertTrue(part is UnknownPart)
87+ }
88+}
build.gradle.ktsnew file+6
@@ -0,0 +1,6 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.android) apply false
4+ alias(libs.plugins.kotlin.serialization) apply false
5+ alias(libs.plugins.kotlin.compose) apply false
6+}
flake.locknew file+61
@@ -0,0 +1,61 @@
1+{
2+ "nodes": {
3+ "flake-utils": {
4+ "inputs": {
5+ "systems": "systems"
6+ },
7+ "locked": {
8+ "lastModified": 1731533236,
9+ "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10+ "owner": "numtide",
11+ "repo": "flake-utils",
12+ "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13+ "type": "github"
14+ },
15+ "original": {
16+ "owner": "numtide",
17+ "repo": "flake-utils",
18+ "type": "github"
19+ }
20+ },
21+ "nixpkgs": {
22+ "locked": {
23+ "lastModified": 1782467914,
24+ "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
25+ "owner": "NixOS",
26+ "repo": "nixpkgs",
27+ "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
28+ "type": "github"
29+ },
30+ "original": {
31+ "owner": "NixOS",
32+ "ref": "nixos-unstable",
33+ "repo": "nixpkgs",
34+ "type": "github"
35+ }
36+ },
37+ "root": {
38+ "inputs": {
39+ "flake-utils": "flake-utils",
40+ "nixpkgs": "nixpkgs"
41+ }
42+ },
43+ "systems": {
44+ "locked": {
45+ "lastModified": 1681028828,
46+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
47+ "owner": "nix-systems",
48+ "repo": "default",
49+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
50+ "type": "github"
51+ },
52+ "original": {
53+ "owner": "nix-systems",
54+ "repo": "default",
55+ "type": "github"
56+ }
57+ }
58+ },
59+ "root": "root",
60+ "version": 7
61+}
flake.nixnew file+71
@@ -0,0 +1,71 @@
1+{
2+ description = "Dev environment for opencode-android (Kotlin / Jetpack Compose client for the opencode serve API)";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+ flake-utils.url = "github:numtide/flake-utils";
7+ };
8+
9+ outputs = { self, nixpkgs, flake-utils }:
10+ flake-utils.lib.eachDefaultSystem (system:
11+ let
12+ # Versions kept in sync with the Gradle project (compileSdk / build-tools).
13+ platformVersion = "35";
14+ buildToolsVersion = "35.0.0";
15+
16+ pkgs = import nixpkgs {
17+ inherit system;
18+ config = {
19+ allowUnfree = true; # Android SDK is unfree
20+ android_sdk.accept_license = true;
21+ };
22+ };
23+
24+ android = pkgs.androidenv.composeAndroidPackages {
25+ platformVersions = [ platformVersion ];
26+ buildToolsVersions = [ buildToolsVersion ];
27+ cmdLineToolsVersion = "latest";
28+
29+ # Flip these on if the host has /dev/kvm and you want an AVD.
30+ includeEmulator = false;
31+ includeSystemImages = false;
32+ systemImageTypes = [ "google_apis" ];
33+ abiVersions = [ "x86_64" ];
34+
35+ includeNDK = false;
36+ };
37+
38+ sdk = android.androidsdk;
39+ sdkRoot = "${sdk}/libexec/android-sdk";
40+
41+ # Gradle's bundled aapt2 binary is dynamically linked against a loader
42+ # path that does not exist on NixOS, so it segfaults. Point Gradle and
43+ # Android Studio at the Nix-built aapt2 instead.
44+ aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45+ in {
46+ devShells.default = pkgs.mkShell {
47+ buildInputs = [
48+ pkgs.jdk17
49+ pkgs.gradle
50+ sdk
51+ ];
52+
53+ ANDROID_HOME = sdkRoot;
54+ ANDROID_SDK_ROOT = sdkRoot;
55+ JAVA_HOME = "${pkgs.jdk17}";
56+
57+ # Override Gradle's bundled aapt2 (see note above).
58+ GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
59+
60+ shellHook = ''
61+ echo "opencode-android dev shell"
62+ echo " JDK: $(java -version 2>&1 | head -1)"
63+ echo " ANDROID_HOME: $ANDROID_HOME"
64+ echo " aapt2: ${aapt2Path}"
65+ echo ""
66+ echo "Build: ./gradlew assembleDebug"
67+ echo "Device: adb devices"
68+ '';
69+ };
70+ });
71+}
gradle.propertiesnew file+5
@@ -0,0 +1,5 @@
1+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
2+org.gradle.caching=true
3+android.useAndroidX=true
4+android.nonTransitiveRClass=true
5+kotlin.code.style=official
gradle/libs.versions.tomlnew file+53
@@ -0,0 +1,53 @@
1+[versions]
2+agp = "8.7.3"
3+kotlin = "2.0.21"
4+ktor = "3.0.3"
5+kotlinxSerialization = "1.7.3"
6+kotlinxCoroutines = "1.9.0"
7+composeBom = "2024.12.01"
8+activityCompose = "1.9.3"
9+lifecycle = "2.8.7"
10+navigationCompose = "2.8.5"
11+datastore = "1.1.1"
12+securityCrypto = "1.1.0-alpha06"
13+coil = "2.7.0"
14+coreKtx = "1.13.1"
15+junit = "4.13.2"
16+
17+[libraries]
18+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
19+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
20+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
21+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
22+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
23+
24+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
25+compose-ui = { group = "androidx.compose.ui", name = "ui" }
26+compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
27+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
28+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
29+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
30+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
31+
32+ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
33+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
34+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
35+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
36+ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
37+ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
38+
39+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
40+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
41+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
42+
43+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
44+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
45+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
46+
47+junit = { group = "junit", name = "junit", version.ref = "junit" }
48+
49+[plugins]
50+android-application = { id = "com.android.application", version.ref = "agp" }
51+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
52+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
53+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
gradle/wrapper/gradle-wrapper.jarnew file

Binary file not shown.

gradle/wrapper/gradle-wrapper.propertiesnew file+7
@@ -0,0 +1,7 @@
1+distributionBase=GRADLE_USER_HOME
2+distributionPath=wrapper/dists
3+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
4+networkTimeout=10000
5+validateDistributionUrl=true
6+zipStoreBase=GRADLE_USER_HOME
7+zipStorePath=wrapper/dists
gradlewnew file+251
@@ -0,0 +1,251 @@
1+#!/bin/sh
2+
3+#
4+# Copyright © 2015-2021 the original authors.
5+#
6+# Licensed under the Apache License, Version 2.0 (the "License");
7+# you may not use this file except in compliance with the License.
8+# You may obtain a copy of the License at
9+#
10+# https://www.apache.org/licenses/LICENSE-2.0
11+#
12+# Unless required by applicable law or agreed to in writing, software
13+# distributed under the License is distributed on an "AS IS" BASIS,
14+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+# See the License for the specific language governing permissions and
16+# limitations under the License.
17+#
18+# SPDX-License-Identifier: Apache-2.0
19+#
20+
21+##############################################################################
22+#
23+# Gradle start up script for POSIX generated by Gradle.
24+#
25+# Important for running:
26+#
27+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28+# noncompliant, but you have some other compliant shell such as ksh or
29+# bash, then to run this script, type that shell name before the whole
30+# command line, like:
31+#
32+# ksh Gradle
33+#
34+# Busybox and similar reduced shells will NOT work, because this script
35+# requires all of these POSIX shell features:
36+# * functions;
37+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39+# * compound commands having a testable exit status, especially «case»;
40+# * various built-in commands including «command», «set», and «ulimit».
41+#
42+# Important for patching:
43+#
44+# (2) This script targets any POSIX shell, so it avoids extensions provided
45+# by Bash, Ksh, etc; in particular arrays are avoided.
46+#
47+# The "traditional" practice of packing multiple parameters into a
48+# space-separated string is a well documented source of bugs and security
49+# problems, so this is (mostly) avoided, by progressively accumulating
50+# options in "$@", and eventually passing that to Java.
51+#
52+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54+# see the in-line comments for details.
55+#
56+# There are tweaks for specific operating systems such as AIX, CygWin,
57+# Darwin, MinGW, and NonStop.
58+#
59+# (3) This script is generated from the Groovy template
60+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61+# within the Gradle project.
62+#
63+# You can find Gradle at https://github.com/gradle/gradle/.
64+#
65+##############################################################################
66+
67+# Attempt to set APP_HOME
68+
69+# Resolve links: $0 may be a link
70+app_path=$0
71+
72+# Need this for daisy-chained symlinks.
73+while
74+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75+ [ -h "$app_path" ]
76+do
77+ ls=$( ls -ld "$app_path" )
78+ link=${ls#*' -> '}
79+ case $link in #(
80+ /*) app_path=$link ;; #(
81+ *) app_path=$APP_HOME$link ;;
82+ esac
83+done
84+
85+# This is normally unused
86+# shellcheck disable=SC2034
87+APP_BASE_NAME=${0##*/}
88+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90+
91+# Use the maximum available, or set MAX_FD != -1 to use that value.
92+MAX_FD=maximum
93+
94+warn () {
95+ echo "$*"
96+} >&2
97+
98+die () {
99+ echo
100+ echo "$*"
101+ echo
102+ exit 1
103+} >&2
104+
105+# OS specific support (must be 'true' or 'false').
106+cygwin=false
107+msys=false
108+darwin=false
109+nonstop=false
110+case "$( uname )" in #(
111+ CYGWIN* ) cygwin=true ;; #(
112+ Darwin* ) darwin=true ;; #(
113+ MSYS* | MINGW* ) msys=true ;; #(
114+ NONSTOP* ) nonstop=true ;;
115+esac
116+
117+CLASSPATH="\\\"\\\""
118+
119+
120+# Determine the Java command to use to start the JVM.
121+if [ -n "$JAVA_HOME" ] ; then
122+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123+ # IBM's JDK on AIX uses strange locations for the executables
124+ JAVACMD=$JAVA_HOME/jre/sh/java
125+ else
126+ JAVACMD=$JAVA_HOME/bin/java
127+ fi
128+ if [ ! -x "$JAVACMD" ] ; then
129+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130+
131+Please set the JAVA_HOME variable in your environment to match the
132+location of your Java installation."
133+ fi
134+else
135+ JAVACMD=java
136+ if ! command -v java >/dev/null 2>&1
137+ then
138+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139+
140+Please set the JAVA_HOME variable in your environment to match the
141+location of your Java installation."
142+ fi
143+fi
144+
145+# Increase the maximum file descriptors if we can.
146+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147+ case $MAX_FD in #(
148+ max*)
149+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150+ # shellcheck disable=SC2039,SC3045
151+ MAX_FD=$( ulimit -H -n ) ||
152+ warn "Could not query maximum file descriptor limit"
153+ esac
154+ case $MAX_FD in #(
155+ '' | soft) :;; #(
156+ *)
157+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158+ # shellcheck disable=SC2039,SC3045
159+ ulimit -n "$MAX_FD" ||
160+ warn "Could not set maximum file descriptor limit to $MAX_FD"
161+ esac
162+fi
163+
164+# Collect all arguments for the java command, stacking in reverse order:
165+# * args from the command line
166+# * the main class name
167+# * -classpath
168+# * -D...appname settings
169+# * --module-path (only if needed)
170+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171+
172+# For Cygwin or MSYS, switch paths to Windows format before running java
173+if "$cygwin" || "$msys" ; then
174+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176+
177+ JAVACMD=$( cygpath --unix "$JAVACMD" )
178+
179+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
180+ for arg do
181+ if
182+ case $arg in #(
183+ -*) false ;; # don't mess with options #(
184+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185+ [ -e "$t" ] ;; #(
186+ *) false ;;
187+ esac
188+ then
189+ arg=$( cygpath --path --ignore --mixed "$arg" )
190+ fi
191+ # Roll the args list around exactly as many times as the number of
192+ # args, so each arg winds up back in the position where it started, but
193+ # possibly modified.
194+ #
195+ # NB: a `for` loop captures its iteration list before it begins, so
196+ # changing the positional parameters here affects neither the number of
197+ # iterations, nor the values presented in `arg`.
198+ shift # remove old arg
199+ set -- "$@" "$arg" # push replacement arg
200+ done
201+fi
202+
203+
204+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206+
207+# Collect all arguments for the java command:
208+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209+# and any embedded shellness will be escaped.
210+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211+# treated as '${Hostname}' itself on the command line.
212+
213+set -- \
214+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
215+ -classpath "$CLASSPATH" \
216+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217+ "$@"
218+
219+# Stop when "xargs" is not available.
220+if ! command -v xargs >/dev/null 2>&1
221+then
222+ die "xargs is not available"
223+fi
224+
225+# Use "xargs" to parse quoted args.
226+#
227+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228+#
229+# In Bash we could simply go:
230+#
231+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232+# set -- "${ARGS[@]}" "$@"
233+#
234+# but POSIX shell has neither arrays nor command substitution, so instead we
235+# post-process each arg (as a line of input to sed) to backslash-escape any
236+# character that might be a shell metacharacter, then use eval to reverse
237+# that process (while maintaining the separation between arguments), and wrap
238+# the whole thing up as a single "set" statement.
239+#
240+# This will of course break if any of these variables contains a newline or
241+# an unmatched quote.
242+#
243+
244+eval "set -- $(
245+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246+ xargs -n1 |
247+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248+ tr '\n' ' '
249+ )" '"$@"'
250+
251+exec "$JAVACMD" "$@"
gradlew.batnew file+94
@@ -0,0 +1,94 @@
1+@rem
2+@rem Copyright 2015 the original author or authors.
3+@rem
4+@rem Licensed under the Apache License, Version 2.0 (the "License");
5+@rem you may not use this file except in compliance with the License.
6+@rem You may obtain a copy of the License at
7+@rem
8+@rem https://www.apache.org/licenses/LICENSE-2.0
9+@rem
10+@rem Unless required by applicable law or agreed to in writing, software
11+@rem distributed under the License is distributed on an "AS IS" BASIS,
12+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+@rem See the License for the specific language governing permissions and
14+@rem limitations under the License.
15+@rem
16+@rem SPDX-License-Identifier: Apache-2.0
17+@rem
18+
19+@if "%DEBUG%"=="" @echo off
20+@rem ##########################################################################
21+@rem
22+@rem Gradle startup script for Windows
23+@rem
24+@rem ##########################################################################
25+
26+@rem Set local scope for the variables with windows NT shell
27+if "%OS%"=="Windows_NT" setlocal
28+
29+set DIRNAME=%~dp0
30+if "%DIRNAME%"=="" set DIRNAME=.
31+@rem This is normally unused
32+set APP_BASE_NAME=%~n0
33+set APP_HOME=%DIRNAME%
34+
35+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37+
38+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40+
41+@rem Find java.exe
42+if defined JAVA_HOME goto findJavaFromJavaHome
43+
44+set JAVA_EXE=java.exe
45+%JAVA_EXE% -version >NUL 2>&1
46+if %ERRORLEVEL% equ 0 goto execute
47+
48+echo. 1>&2
49+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50+echo. 1>&2
51+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52+echo location of your Java installation. 1>&2
53+
54+goto fail
55+
56+:findJavaFromJavaHome
57+set JAVA_HOME=%JAVA_HOME:"=%
58+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59+
60+if exist "%JAVA_EXE%" goto execute
61+
62+echo. 1>&2
63+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64+echo. 1>&2
65+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66+echo location of your Java installation. 1>&2
67+
68+goto fail
69+
70+:execute
71+@rem Setup the command line
72+
73+set CLASSPATH=
74+
75+
76+@rem Execute Gradle
77+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78+
79+:end
80+@rem End local scope for the variables with windows NT shell
81+if %ERRORLEVEL% equ 0 goto mainEnd
82+
83+:fail
84+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85+rem the _cmd.exe /c_ return code!
86+set EXIT_CODE=%ERRORLEVEL%
87+if %EXIT_CODE% equ 0 set EXIT_CODE=1
88+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89+exit /b %EXIT_CODE%
90+
91+:mainEnd
92+if "%OS%"=="Windows_NT" endlocal
93+
94+:omega
settings.gradle.ktsnew file+24
@@ -0,0 +1,24 @@
1+pluginManagement {
2+ repositories {
3+ google {
4+ content {
5+ includeGroupByRegex("com\\.android.*")
6+ includeGroupByRegex("com\\.google.*")
7+ includeGroupByRegex("androidx.*")
8+ }
9+ }
10+ mavenCentral()
11+ gradlePluginPortal()
12+ }
13+}
14+
15+dependencyResolutionManagement {
16+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
17+ repositories {
18+ google()
19+ mavenCentral()
20+ }
21+}
22+
23+rootProject.name = "opencode-android"
24+include(":app")