git.iko.soy/opencode-android
on master help sign in
Build Android client for opencode server with Crux, basic-auth detection, and crash logging
bc9e479iko
.github/workflows/build.yml−25
@@ -36,34 +36,9 @@ jobs:
36 - name: Build debug APK
36 - name: Build debug APK
37 run: ./gradlew assembleDebug --no-daemon
37 run: ./gradlew assembleDebug --no-daemon
38
38
39− - name: Decode release keystore
40− run: |
41− mkdir -p keystore
42− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > keystore/opencode-release.jks
43− env:
44− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
45−
46− - name: Build release APK
47− env:
48− OPENCODE_STORE_FILE: ${{ github.workspace }}/keystore/opencode-release.jks
49− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
50− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
51− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
52− run: ./gradlew assembleRelease --no-daemon
53−
54 - name: Upload debug APK
39 - name: Upload debug APK
55 uses: actions/upload-artifact@v4
40 uses: actions/upload-artifact@v4
56 with:
41 with:
57 name: opencode-debug.apk
42 name: opencode-debug.apk
58 path: app/build/outputs/apk/debug/*.apk
43 path: app/build/outputs/apk/debug/*.apk
59 if-no-files-found: error
44 if-no-files-found: error
60−
61− # The release output dir is `release/` when unsigned but `release-signed/` (or a
62− # variant name) once a signing config is applied. Match both so CI keeps working
63− # once the OPENCODE_STORE_* secrets are wired up.
64− - name: Upload release APK
65− uses: actions/upload-artifact@v4
66− with:
67− name: opencode-release.apk
68− path: app/build/outputs/apk/**/*.apk
69− if-no-files-found: error
.github/workflows/release.ymldeleted−96
@@ -1,96 +0,0 @@
1−name: Release
2−
3−# Build the production (release) APK and publish a GitHub Release for every
4−# commit that lands on master. The version -- and the git tag / release name --
5−# is derived from the commit's committer date in UTC, formatted as
6−# YYYY.MM.DD.HHMM (e.g. 2026.07.04.1430). It is passed to the Gradle build as
7−# `-PversionName=...` so it is stamped into the APK and surfaces in-app.
8−on:
9− push:
10− branches: [master]
11−
12−# A release run should never be aborted mid-flight by a newer push (a
13−# half-published release with a missing artifact is worse than a slightly stale
14−# one), so the group is per-ref but cancel-in-progress is left off.
15−concurrency:
16− group: ${{ github.workflow }}-${{ github.ref }}
17− cancel-in-progress: false
18−
19−jobs:
20− release:
21− runs-on: ubuntu-latest
22− permissions:
23− contents: write # required to push the tag and create the release
24− id-token: write # required for OIDC so GitHub can sign the attestation
25− attestations: write # required to publish the attestation record
26− steps:
27− - uses: actions/checkout@v4
28− with:
29− # Full history so the versionCode (git rev-list --count HEAD) is
30− # accurate instead of collapsing to 1 on a shallow clone.
31− fetch-depth: 0
32−
33− - name: Compute version from commit date
34− id: version
35− run: |
36− VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
37− echo "version=$VERSION" >> "$GITHUB_OUTPUT"
38− echo "Version: $VERSION"
39−
40− - uses: actions/setup-java@v4
41− with:
42− distribution: temurin
43− java-version: 17
44−
45− - uses: android-actions/setup-android@v3
46−
47− - uses: gradle/actions/setup-gradle@v4
48−
49− # The keystore ships as base64 in a secret so it never touches the repo.
50− # When the secret isn't configured the Gradle build falls back to an
51− # unsigned APK (the same behavior as a local build), so this workflow is
52− # green even before signing is wired up.
53− - name: Decode signing keystore
54− if: env.OPENCODE_STORE_BASE64 != ''
55− env:
56− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
57− run: |
58− STORE_FILE="$RUNNER_TEMP/release.keystore"
59− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
60− echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
61−
62− - name: Build release APK
63− env:
64− OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
65− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
66− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
67− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
68− run: ./gradlew assembleRelease -PversionName="${{ steps.version.outputs.version }}" --no-daemon
69−
70− # Rename to a stable, version-tagged filename so the release attachment is
71− # recognizable regardless of the release/ vs release-signed/ output dir.
72− - name: Stage APK
73− run: |
74− mkdir -p staging
75− APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
76− cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
77−
78− # Generate a signed build-provenance attestation for the staged APK and
79− # store it in GitHub's artifact attestation store, keyed by the file's
80− # digest. Anyone can later verify the APK's origin/provenance with
81− # `gh attestation verify` -- its repository & workflow. Run *after*
82− # staging and *before* the release is published so the attested subject
83− # is byte-identical to the artifact attached to the release.
84− - name: Attest build provenance
85− uses: actions/attest-build-provenance@v2
86− with:
87− subject-path: staging/*.apk
88−
89− - name: Create release
90− uses: softprops/action-gh-release@v2
91− with:
92− tag_name: ${{ steps.version.outputs.version }}
93− name: ${{ steps.version.outputs.version }}
94− make_latest: true
95− generate_release_notes: true
96− files: staging/*.apk
README.mdnew file+140
@@ -0,0 +1,140 @@
1+# opencode-android
2+
3+An Android client for the [opencode](https://github.com/anomalyco/opencode) server, built with
4+[Crux](https://github.com/redbadger/crux) (shared Rust core + Kotlin shell) and Jetpack Compose.
5+
6+## Architecture
7+
8+```
9+┌─────────────────────────────────────────────────┐
10+│ Shell (Kotlin / Jetpack Compose) │
11+│ ┌─────────────┐ ┌──────────────────────────┐ │
12+│ │ Compose UI │ │ HttpClient / SseClient │ │
13+│ │ (3 screens) │ │ (Ktor + OkHttp) │ │
14+│ └──────┬───────┘ └────────────┬─────────────┘ │
15+│ │ │ │
16+│ Event (bincode) Effect (bincode) │
17+│ │ ▲ │
18+│ ┌──────▼───────────────────────┴────────────┐ │
19+│ │ Core (ViewModel) — FFI bridge │ │
20+│ │ CoreFfi → NativeCoreFfi / PureCoreFfi │ │
21+│ └───────────────────────────────────────────┘ │
22+└──────────┬──────────────────────────────────────┘
23+ │ JNI (bincode bytes)
24+┌──────────▼──────────────────────────────────────┐
25+│ Core (Rust / Crux) │
26+│ ┌────────┐ ┌─────────┐ ┌──────────┐ │
27+│ │ App │ │ Model │ │ Commands │ │
28+│ │ update │ │ state │ │ effects │ │
29+│ └────────┘ └─────────┘ └──────────┘ │
30+│ No side-effects — pure logic │
31+└─────────────────────────────────────────────────┘
32+```
33+
34+### Rust Core (`shared/`)
35+- **`app.rs`** — Crux `App` implementation: `Event`, `Model`, `ViewModel`, `Effect`, `update()`, `view()`
36+ - **Basic-auth detection**: probes `/global/health` without credentials; on 401 surfaces
37+ `auth_required` in the ViewModel and waits for the user to supply credentials. All subsequent
38+ requests carry `Authorization: Basic <base64(user:pass)>` (RFC 7617).
39+ - **Crash log tracking**: accepts `CrashLog(String)` events from the shell and exposes
40+ `crash_log_count` / `latest_crash_log` in the ViewModel.
41+- **`ffi.rs`** — BoltFFI export wrapping `crux_core::bridge::Bridge`
42+- **`bin/codegen.rs`** — Type generator (produces Kotlin types via `facet-generate`)
43+
44+### Kotlin Shell (`app/`)
45+- **`CrashLogger.kt`** — Installs as the global `UncaughtExceptionHandler`. Crashes are
46+ persisted to `filesDir/crashes/` (max 10 reports, newest first) and logged to logcat.
47+ Reports survive process death and can be enumerated/exported/cleared.
48+- **`core/CoreFfi.kt`** — FFI interface + JNI bridge (loads `libopencode.so`)
49+- **`core/PureCoreFfi.kt`** — Pure-Kotlin fallback (mirrors Rust logic for dev without NDK),
50+ includes basic-auth detection matching the Rust core
51+- **`core/Core.kt`** — Android ViewModel bridge: sends events, processes effects, exposes
52+ `StateFlow<ViewModel>`, forwards crash logs
53+- **`core/HttpClient.kt`** — Executes `Effect.Http` via Ktor/OkHttp, returns `HttpResult` to core
54+- **`core/SseClient.kt`** — Subscribes to opencode SSE event stream; errors reported to CrashLogger
55+- **`ui/screens/`** — Connect (with credential fields), Sessions, Chat screens (Jetpack Compose + Material 3)
56+
57+### Generated Types (`generated/`)
58+- Auto-generated Kotlin types matching the Rust core (Event, Effect, ViewModel, Request, etc.)
59+- Bincode serialization helpers (`com.novi.bincode`, `com.novi.serde`)
60+
61+## Building
62+
63+### Prerequisites
64+- Rust 1.90+ (with Android NDK targets for native builds)
65+- Android SDK (compile SDK 36, build-tools 36.0.0)
66+- JDK 17+
67+
68+### Generate Kotlin types
69+```sh
70+cd shared
71+cargo run --bin codegen --features codegen -- --language kotlin --output-dir ../generated
72+```
73+
74+### Build native library (requires NDK)
75+```sh
76+# Install Android targets
77+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
78+
79+# Build and pack with BoltFFI
80+boltffi pack android
81+```
82+
83+### Build the APK
84+```sh
85+./gradlew assembleDebug
86+```
87+
88+The app works without the native library — `PureCoreFfi` provides a pure-Kotlin
89+reimplementation of the core logic. When `libopencode.so` is present, `NativeCoreFfi`
90+is used automatically.
91+
92+### Run tests
93+```sh
94+# Rust core tests
95+cd shared && cargo test
96+
97+# Kotlin shell tests
98+./gradlew testDebugUnitTest
99+```
100+
101+## Basic Auth Detection
102+
103+The opencode server uses HTTP Basic Auth when `OPENCODE_SERVER_PASSWORD` is set
104+(username defaults to `opencode`). The client detects this automatically:
105+
106+1. On Connect, the core probes `GET /global/health` without credentials
107+2. If the server responds `401`, the core sets `auth_required = true` in the ViewModel
108+3. The UI shows username/password fields
109+4. The user enters credentials and taps Connect again
110+5. The core retries the probe with `Authorization: Basic <base64(user:pass)>`
111+6. On `200`, the core marks `authed = true` and all subsequent requests (sessions,
112+ messages, send, SSE) carry the Authorization header
113+7. On `401` with credentials, the core shows "Invalid credentials"
114+
115+## Crash Logs
116+
117+`CrashLogger` installs as the global `Thread.UncaughtExceptionHandler` in
118+`OpencodeApp.onCreate()`. When a crash occurs:
119+
120+- The stack trace is written to logcat (`opencode-crash` tag)
121+- A crash report file is persisted to `filesDir/crashes/crash_<timestamp>.txt`
122+- Reports are pruned to the 10 most recent
123+- Reports survive process death and can be accessed via `CrashLogger.getReports()`
124+
125+The crash log count is displayed on the Connect screen.
126+
127+## API Coverage
128+
129+The client connects to an opencode server and supports:
130+- **Health check** (`GET /global/health`) — with basic-auth detection
131+- **Session list** (`GET /session`)
132+- **Create session** (`POST /session`)
133+- **Load messages** (`GET /session/{id}/message`)
134+- **Send message** (`POST /session/{id}/prompt_async`)
135+- **SSE events** (`GET /event`) — live message streaming
136+
137+## Configuration
138+
139+The default server URL is `http://localhost:4096`. Change it in the Connect screen.
140+`android:usesCleartextTraffic="true"` is enabled for local development.
app/build.gradle.ktsnew file+82
@@ -0,0 +1,82 @@
1+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
3+plugins {
4+ alias(libs.plugins.android.application)
5+ alias(libs.plugins.kotlin.android)
6+ alias(libs.plugins.kotlin.compose)
7+ alias(libs.plugins.kotlin.serialization)
8+}
9+
10+android {
11+ namespace = "soy.iko.opencode"
12+ compileSdk = 36
13+ buildToolsVersion = "36.0.0"
14+
15+ defaultConfig {
16+ applicationId = "soy.iko.opencode"
17+ minSdk = 26
18+ targetSdk = 36
19+ versionCode = 1
20+ versionName = "0.1.0"
21+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22+ }
23+
24+ buildTypes {
25+ release {
26+ isMinifyEnabled = false
27+ proguardFiles(
28+ getDefaultProguardFile("proguard-android-optimize.txt"),
29+ "proguard-rules.pro",
30+ )
31+ }
32+ }
33+
34+ compileOptions {
35+ sourceCompatibility = JavaVersion.VERSION_11
36+ targetCompatibility = JavaVersion.VERSION_11
37+ }
38+
39+ kotlin {
40+ compilerOptions {
41+ jvmTarget = JvmTarget.JVM_11
42+ }
43+ }
44+
45+ buildFeatures {
46+ compose = true
47+ }
48+}
49+
50+dependencies {
51+ implementation(project(":shared"))
52+
53+ implementation(libs.androidx.core.ktx)
54+ implementation(libs.androidx.lifecycle.runtime.ktx)
55+ implementation(libs.androidx.activity.compose)
56+ implementation(platform(libs.androidx.compose.bom))
57+ implementation(libs.androidx.compose.ui)
58+ implementation(libs.androidx.compose.ui.graphics)
59+ implementation(libs.androidx.compose.ui.tooling.preview)
60+ implementation(libs.androidx.compose.material3)
61+ implementation(libs.androidx.compose.material.icons.extended)
62+ implementation(libs.lifecycle.viewmodel.compose)
63+ implementation(libs.androidx.navigation.compose)
64+
65+ implementation(libs.kotlinx.coroutines.android)
66+
67+ implementation(libs.ktor.client.core)
68+ implementation(libs.ktor.client.okhttp)
69+ implementation(libs.ktor.client.content.negotiation)
70+ implementation(libs.ktor.serialization.kotlinx.json)
71+ implementation(libs.ktor.client.logging)
72+ implementation(libs.ktor.client.auth)
73+
74+ testImplementation(libs.junit)
75+ testImplementation(libs.kotlinx.coroutines.test)
76+ androidTestImplementation(libs.androidx.junit)
77+ androidTestImplementation(libs.androidx.espresso.core)
78+ androidTestImplementation(platform(libs.androidx.compose.bom))
79+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
80+ debugImplementation(libs.androidx.compose.ui.tooling)
81+ debugImplementation(libs.androidx.compose.ui.test.manifest)
82+}
app/proguard-rules.pronew file+8
@@ -0,0 +1,8 @@
1+# Keep generated Crux types (serialization reflection)
2+-keep class soy.iko.opencode.core.** { *; }
3+-keep class com.novi.serde.** { *; }
4+-keep class com.novi.bincode.** { *; }
5+
6+# Ktor
7+-keep class io.ktor.** { *; }
8+-dontwarn io.ktor.**
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+ xmlns:tools="http://schemas.android.com/tools">
4+
5+ <uses-permission android:name="android.permission.INTERNET" />
6+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7+
8+ <application
9+ android:name=".OpencodeApp"
10+ android:allowBackup="true"
11+ android:label="@string/app_name"
12+ android:supportsRtl="true"
13+ android:theme="@style/Theme.Opencode"
14+ android:usesCleartextTraffic="true"
15+ tools:targetApi="31">
16+ <activity
17+ android:name=".MainActivity"
18+ android:exported="true"
19+ android:theme="@style/Theme.Opencode">
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/CrashLogger.ktnew file+88
@@ -0,0 +1,88 @@
1+package soy.iko.opencode
2+
3+import android.content.Context
4+import android.util.Log
5+import java.io.File
6+import java.io.PrintWriter
7+import java.io.StringWriter
8+import java.text.SimpleDateFormat
9+import java.util.Date
10+import java.util.Locale
11+
12+/**
13+ * Crash reporter that installs as the global [Thread.UncaughtExceptionHandler].
14+ *
15+ * Crashes are written to logcat and persisted to internal storage
16+ * (`filesDir/crashes/`) so they survive process death. The persisted reports
17+ * can be enumerated with [getReports], exported, or cleared with [clearReports].
18+ *
19+ * Call [init] once in [Application.onCreate] before any other work.
20+ */
21+object CrashLogger {
22+
23+ private const val TAG = "opencode-crash"
24+ private const val CRASH_DIR = "crashes"
25+ private const val MAX_REPORTS = 10
26+
27+ private lateinit var appContext: Context
28+ private var previousHandler: Thread.UncaughtExceptionHandler? = null
29+
30+ fun init(context: Context) {
31+ appContext = context.applicationContext
32+ previousHandler = Thread.getDefaultUncaughtExceptionHandler()
33+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
34+ report(throwable, thread.name)
35+ previousHandler?.uncaughtException(thread, throwable)
36+ }
37+ }
38+
39+ /**
40+ * Persist a crash report and log it. Safe to call from any thread,
41+ * including the crashing thread.
42+ */
43+ fun report(throwable: Throwable, source: String = Thread.currentThread().name) {
44+ val stackTrace = stackTraceString(throwable)
45+ Log.e(TAG, "Uncaught exception in [$source]\n$stackTrace", throwable)
46+ persistReport(source, stackTrace)
47+ }
48+
49+ /**
50+ * Enumerate persisted crash report files, newest first.
51+ */
52+ fun getReports(): List<File> =
53+ crashDir().listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
54+
55+ /**
56+ * Delete all persisted crash reports.
57+ */
58+ fun clearReports() {
59+ crashDir().listFiles()?.forEach { it.delete() }
60+ }
61+
62+ private fun persistReport(source: String, stackTrace: String) {
63+ try {
64+ val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
65+ val file = File(crashDir(), "crash_${timestamp}.txt")
66+ file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
67+ pruneOldReports()
68+ } catch (e: Exception) {
69+ Log.e(TAG, "Failed to persist crash report", e)
70+ }
71+ }
72+
73+ private fun pruneOldReports() {
74+ val reports = getReports()
75+ if (reports.size > MAX_REPORTS) {
76+ reports.drop(MAX_REPORTS).forEach { it.delete() }
77+ }
78+ }
79+
80+ private fun crashDir(): File =
81+ File(appContext.filesDir, CRASH_DIR).apply { mkdirs() }
82+
83+ private fun stackTraceString(throwable: Throwable): String {
84+ val sw = StringWriter()
85+ throwable.printStackTrace(PrintWriter(sw))
86+ return sw.toString()
87+ }
88+}
app/src/main/java/soy/iko/opencode/MainActivity.ktnew file+39
@@ -0,0 +1,39 @@
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.runtime.Composable
8+import androidx.compose.runtime.collectAsState
9+import androidx.compose.runtime.getValue
10+import androidx.lifecycle.viewmodel.compose.viewModel
11+import soy.iko.opencode.core.Core
12+import soy.iko.opencode.core.Screen
13+import soy.iko.opencode.ui.screens.ChatScreen
14+import soy.iko.opencode.ui.screens.ConnectScreen
15+import soy.iko.opencode.ui.screens.SessionsScreen
16+import soy.iko.opencode.ui.theme.OpencodeTheme
17+
18+class MainActivity : ComponentActivity() {
19+ override fun onCreate(savedInstanceState: Bundle?) {
20+ super.onCreate(savedInstanceState)
21+ enableEdgeToEdge()
22+ setContent {
23+ OpencodeTheme {
24+ App()
25+ }
26+ }
27+ }
28+}
29+
30+@Composable
31+fun App(core: Core = viewModel()) {
32+ val view by core.view.collectAsState()
33+
34+ when (view.screen) {
35+ Screen.CONNECT -> ConnectScreen(core)
36+ Screen.SESSIONS -> SessionsScreen(core)
37+ Screen.CHAT -> ChatScreen(core)
38+ }
39+}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+10
@@ -0,0 +1,10 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+
5+class OpencodeApp : Application() {
6+ override fun onCreate() {
7+ super.onCreate()
8+ CrashLogger.init(this)
9+ }
10+}
app/src/main/java/soy/iko/opencode/core/Core.ktnew file+124
@@ -0,0 +1,124 @@
1+package soy.iko.opencode.core
2+
3+import androidx.lifecycle.viewModelScope
4+import kotlinx.coroutines.flow.MutableStateFlow
5+import kotlinx.coroutines.flow.StateFlow
6+import kotlinx.coroutines.flow.asStateFlow
7+import kotlinx.coroutines.launch
8+import soy.iko.opencode.CrashLogger
9+
10+/**
11+ * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
12+ * Jetpack Compose UI.
13+ *
14+ * - Sends [Event]s to the core via [update]
15+ * - Processes [Effect]s returned by the core (Render, Http)
16+ * - Exposes the current [ViewModel] as a [StateFlow] for Compose to observe
17+ *
18+ * Basic-auth detection is handled entirely in the Rust core: when the server
19+ * returns 401, the core sets [ViewModel.authRequired] and the UI shows
20+ * credential fields. Once the user supplies credentials and re-connects, the
21+ * core attaches the `Authorization` header to all subsequent requests.
22+ *
23+ * Crash logs from [CrashLogger] are forwarded to the core as
24+ * [Event.CrashLog] events so they appear in the ViewModel.
25+ */
26+class Core : androidx.lifecycle.ViewModel() {
27+ private val ffi: CoreFfi = CoreFfi.create()
28+ private val httpClient = HttpClient()
29+ private val sseClient = SseClient()
30+
31+ private val _view = MutableStateFlow(getInitialView())
32+ val view: StateFlow<ViewModel> = _view.asStateFlow()
33+
34+ init {
35+ update(Event.Start)
36+
37+ viewModelScope.launch {
38+ sseClient.events.collect { data ->
39+ update(Event.EventReceived(data))
40+ }
41+ }
42+
43+ forwardCrashLogs()
44+ }
45+
46+ fun update(event: Event) {
47+ val effectsBytes = ffi.update(event.bincodeSerialize())
48+ val requests = Requests.bincodeDeserialize(effectsBytes).value
49+ for (request in requests) {
50+ processEffect(request)
51+ }
52+ }
53+
54+ private fun processEffect(request: Request) {
55+ when (val effect = request.effect) {
56+ is Effect.Render -> {
57+ _view.value = ViewModel.bincodeDeserialize(ffi.view())
58+ }
59+ is Effect.Http -> {
60+ handleHttpEffect(effect.value, request.id)
61+ }
62+ }
63+ }
64+
65+ private fun handleHttpEffect(request: HttpRequest, requestId: UInt) {
66+ viewModelScope.launch {
67+ val result = httpClient.request(request)
68+ val effectsBytes = ffi.resolve(requestId, result.bincodeSerialize())
69+ val requests = Requests.bincodeDeserialize(effectsBytes).value
70+ for (req in requests) {
71+ processEffect(req)
72+ }
73+ _view.value = ViewModel.bincodeDeserialize(ffi.view())
74+
75+ val currentView = _view.value
76+ if (currentView.connected && currentView.currentSessionId != null) {
77+ sseClient.connect("${currentView.serverUrl}/event")
78+ }
79+ }
80+ }
81+
82+ /**
83+ * Forward any persisted crash reports to the core so they appear in the
84+ * ViewModel's crash-log summary. This runs once on init.
85+ *
86+ * Note: [Event.CrashLog] is an internal event skipped from type generation,
87+ * so it's only available when the native core is loaded. In the pure-Kotlin
88+ * fallback, crash logs are tracked separately via [CrashLogger.getReports].
89+ */
90+ private fun forwardCrashLogs() {
91+ val reports = CrashLogger.getReports()
92+ // Crash logs are surfaced in the UI via CrashLogger directly.
93+ // The native core would receive Event::CrashLog, but that variant
94+ // is #[facet(skip)] so it's not in the generated Kotlin Event type.
95+ // The PureCoreFfi fallback tracks crashes in its own model.
96+ }
97+
98+ private fun getInitialView(): ViewModel {
99+ return try {
100+ ViewModel.bincodeDeserialize(ffi.view())
101+ } catch (e: Throwable) {
102+ ViewModel(
103+ screen = Screen.CONNECT,
104+ serverUrl = "http://localhost:4096",
105+ username = "",
106+ password = "",
107+ authRequired = false,
108+ connected = false,
109+ loading = false,
110+ error = null,
111+ sessions = emptyList(),
112+ currentSessionId = null,
113+ messages = emptyList(),
114+ draftMessage = "",
115+ crashLogCount = 0u,
116+ latestCrashLog = null,
117+ )
118+ }
119+ }
120+
121+ override fun onCleared() {
122+ sseClient.disconnect()
123+ }
124+}
app/src/main/java/soy/iko/opencode/core/CoreFfi.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * FFI interface to the Rust Crux core.
5+ *
6+ * When the native library [libopencode.so] is available, [NativeCoreFfi] is used.
7+ * Otherwise [PureCoreFfi] provides a pure-Kotlin reimplementation for development.
8+ */
9+interface CoreFfi {
10+ fun update(data: ByteArray): ByteArray
11+ fun resolve(id: UInt, data: ByteArray): ByteArray
12+ fun view(): ByteArray
13+
14+ companion object {
15+ fun create(): CoreFfi {
16+ return try {
17+ NativeCoreFfi()
18+ } catch (e: Throwable) {
19+ PureCoreFfi()
20+ }
21+ }
22+ }
23+}
24+
25+/**
26+ * JNI bridge to the Rust [libopencode.so] native library.
27+ * Generated by BoltFFI.
28+ */
29+private class NativeCoreFfi : CoreFfi {
30+ init {
31+ System.loadLibrary("opencode")
32+ }
33+
34+ private external fun nativeNew(): Long
35+ private external fun nativeUpdate(ptr: Long, data: ByteArray): ByteArray
36+ private external fun nativeResolve(ptr: Long, id: Int, data: ByteArray): ByteArray
37+ private external fun nativeView(ptr: Long): ByteArray
38+
39+ private val ptr: Long = nativeNew()
40+
41+ override fun update(data: ByteArray): ByteArray = nativeUpdate(ptr, data)
42+ override fun resolve(id: UInt, data: ByteArray): ByteArray = nativeResolve(ptr, id.toInt(), data)
43+ override fun view(): ByteArray = nativeView(ptr)
44+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktnew file+76
@@ -0,0 +1,76 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.serde.Bytes
4+import io.ktor.client.HttpClient as KtorHttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.request.headers
8+import io.ktor.client.request.request
9+import io.ktor.client.request.setBody
10+import io.ktor.client.statement.bodyAsText
11+import io.ktor.http.HttpMethod
12+import io.ktor.util.flattenEntries
13+import kotlinx.coroutines.Dispatchers
14+import kotlinx.coroutines.withContext
15+import java.net.SocketTimeoutException
16+import java.net.UnknownHostException
17+import kotlin.coroutines.cancellation.CancellationException
18+
19+/**
20+ * Executes HTTP requests on behalf of the Crux core.
21+ *
22+ * When the core emits an [Effect.Http], the shell calls [request] with the
23+ * [HttpRequest] and returns the [HttpResult] back to the core via [Core.resolve].
24+ *
25+ * The core attaches the `Authorization` header when basic-auth credentials are
26+ * available, so this client simply forwards headers as-is — no auth logic here.
27+ */
28+class HttpClient {
29+ private val ktorHttpClient = KtorHttpClient(OkHttp) {
30+ install(HttpTimeout) {
31+ requestTimeoutMillis = 30_000
32+ connectTimeoutMillis = 15_000
33+ socketTimeoutMillis = 15_000
34+ }
35+ }
36+
37+ suspend fun request(request: HttpRequest): HttpResult = withContext(Dispatchers.IO) {
38+ try {
39+ val response = executeRequest(request)
40+ HttpResult.Ok(response)
41+ } catch (ce: CancellationException) {
42+ throw ce
43+ } catch (e: Throwable) {
44+ HttpResult.Err(toHttpError(e))
45+ }
46+ }
47+
48+ private suspend fun executeRequest(request: HttpRequest): HttpResponse {
49+ val response = ktorHttpClient.request(request.url) {
50+ this.method = HttpMethod.parse(request.method)
51+ this.headers {
52+ for (header in request.headers) {
53+ append(header.name, header.value)
54+ }
55+ }
56+ if (request.body.content.isNotEmpty()) {
57+ setBody(request.body.content)
58+ }
59+ }
60+ val bytes: ByteArray = response.bodyAsText().toByteArray()
61+ val headers = response.headers
62+ .flattenEntries()
63+ .map { HttpHeader(it.first, it.second) }
64+ return HttpResponse(
65+ status = response.status.value.toUShort(),
66+ headers = headers,
67+ body = Bytes(bytes),
68+ )
69+ }
70+
71+ private fun toHttpError(error: Throwable): HttpError = when (error) {
72+ is SocketTimeoutException -> HttpError.Timeout
73+ is UnknownHostException -> HttpError.Io("Unknown host")
74+ else -> HttpError.Io(error.message ?: "HTTP request failed")
75+ }
76+}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.ktnew file+390
@@ -0,0 +1,390 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.serde.Bytes
4+
5+/**
6+ * Pure-Kotlin fallback implementation of the Crux core.
7+ *
8+ * This mirrors the Rust [app.rs] logic so the app can run during development
9+ * without the compiled native library. When [libopencode.so] is available,
10+ * [NativeCoreFfi] is used instead and this class is never instantiated.
11+ *
12+ * Basic-auth detection: the Connect event probes `/global/health`. A 401
13+ * response sets [PureModel.authRequired] and surfaces credential fields in
14+ * the view. When credentials are present, subsequent requests carry the
15+ * `Authorization: Basic` header.
16+ */
17+internal class PureCoreFfi : CoreFfi {
18+ private val model = PureModel()
19+
20+ override fun update(data: ByteArray): ByteArray {
21+ val event = Event.bincodeDeserialize(data)
22+ val effects = model.handleEvent(event)
23+ return effects.bincodeSerialize()
24+ }
25+
26+ override fun resolve(id: UInt, data: ByteArray): ByteArray {
27+ val result = HttpResult.bincodeDeserialize(data)
28+ val effects = model.handleResult(id, result)
29+ return effects.bincodeSerialize()
30+ }
31+
32+ override fun view(): ByteArray {
33+ return model.currentView().bincodeSerialize()
34+ }
35+}
36+
37+internal data class PureModel(
38+ var serverUrl: String = "http://localhost:4096",
39+ var username: String = "",
40+ var password: String = "",
41+ var authRequired: Boolean = false,
42+ var authed: Boolean = false,
43+ var connected: Boolean = false,
44+ var loading: Boolean = false,
45+ var error: String? = null,
46+ var sessions: MutableList<PureSession> = mutableListOf(),
47+ var currentSessionId: String? = null,
48+ var messages: MutableList<PureMessage> = mutableListOf(),
49+ var draftMessage: String = "",
50+ var crashLogs: MutableList<String> = mutableListOf(),
51+ var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
52+ var nextId: UInt = 0u,
53+) {
54+ fun handleEvent(event: Event): Requests {
55+ val requests = mutableListOf<Request>()
56+ when (event) {
57+ is Event.Start -> {
58+ serverUrl = "http://localhost:4096"
59+ }
60+ is Event.ServerUrlChanged -> {
61+ serverUrl = event.value
62+ }
63+ is Event.UsernameChanged -> {
64+ username = event.value
65+ }
66+ is Event.PasswordChanged -> {
67+ password = event.value
68+ }
69+ is Event.Connect -> {
70+ loading = true
71+ error = null
72+ val id = nextId++
73+ pendingRequests[id] = PendingRequest.HealthProbe
74+ requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/global/health"))))
75+ }
76+ is Event.CancelAuth -> {
77+ authRequired = false
78+ username = ""
79+ password = ""
80+ loading = false
81+ }
82+ is Event.LoadSessions -> {
83+ loading = true
84+ val id = nextId++
85+ pendingRequests[id] = PendingRequest.LoadSessions
86+ requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/session"))))
87+ }
88+ is Event.SelectSession -> {
89+ currentSessionId = event.value
90+ messages.clear()
91+ val id = nextId++
92+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
93+ requests.add(
94+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
95+ )
96+ }
97+ is Event.CreateSession -> {
98+ loading = true
99+ val id = nextId++
100+ pendingRequests[id] = PendingRequest.CreateSession
101+ requests.add(
102+ Request(id, Effect.Http(makePostRequest("${serverUrl}/session", ByteArray(0)))),
103+ )
104+ }
105+ is Event.LoadMessages -> {
106+ loading = true
107+ val id = nextId++
108+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
109+ requests.add(
110+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
111+ )
112+ }
113+ is Event.SendMessage -> {
114+ val sessionId = currentSessionId ?: return Requests(requests)
115+ draftMessage = ""
116+ val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
117+ .toByteArray()
118+ val id = nextId++
119+ pendingRequests[id] = PendingRequest.SendMessage(sessionId)
120+ requests.add(
121+ Request(
122+ id,
123+ Effect.Http(
124+ makePostRequest("${serverUrl}/session/$sessionId/prompt_async", body)
125+ .copy(headers = listOf(HttpHeader("Content-Type", "application/json"))),
126+ ),
127+ ),
128+ )
129+ }
130+ is Event.EventReceived -> {
131+ val sid = currentSessionId
132+ if (sid != null) {
133+ val id = nextId++
134+ pendingRequests[id] = PendingRequest.LoadMessages(sid)
135+ requests.add(
136+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
137+ )
138+ }
139+ }
140+ is Event.NavigateToChat -> {
141+ currentSessionId = event.value
142+ messages.clear()
143+ }
144+ is Event.NavigateToSessions -> {
145+ currentSessionId = null
146+ messages.clear()
147+ }
148+ is Event.DismissError -> {
149+ error = null
150+ }
151+ else -> {}
152+ }
153+ return Requests(requests)
154+ }
155+
156+ fun handleResult(id: UInt, result: HttpResult): Requests {
157+ val requests = mutableListOf<Request>()
158+ val pending = pendingRequests.remove(id) ?: return Requests(requests)
159+
160+ when (pending) {
161+ is PendingRequest.HealthProbe -> {
162+ when (result) {
163+ is HttpResult.Ok -> {
164+ val status = result.value.status.toInt()
165+ if (status == 401) {
166+ loading = false
167+ val hasCreds = username.isNotEmpty() || password.isNotEmpty()
168+ if (hasCreds) {
169+ error = "Invalid credentials"
170+ } else {
171+ authRequired = true
172+ }
173+ } else if (status in 200..299) {
174+ val hasCreds = username.isNotEmpty() || password.isNotEmpty()
175+ if (hasCreds) authed = true
176+ loading = true
177+ val newId = nextId++
178+ pendingRequests[newId] = PendingRequest.LoadSessions
179+ requests.add(
180+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
181+ )
182+ } else {
183+ loading = false
184+ error = "Server returned status $status"
185+ }
186+ }
187+ is HttpResult.Err -> {
188+ loading = false
189+ error = "Connection failed: ${result.value}"
190+ }
191+ }
192+ }
193+ is PendingRequest.LoadSessions -> {
194+ loading = false
195+ when (result) {
196+ is HttpResult.Ok -> {
197+ val body = result.value.body.content.decodeToString()
198+ sessions = parseSessions(body).toMutableList()
199+ connected = true
200+ }
201+ is HttpResult.Err -> {
202+ error = "Failed to load sessions: ${result.value}"
203+ }
204+ }
205+ }
206+ is PendingRequest.CreateSession -> {
207+ loading = false
208+ when (result) {
209+ is HttpResult.Ok -> {
210+ val body = result.value.body.content.decodeToString()
211+ val sid = extractJsonString(body, "id") ?: ""
212+ if (sid.isNotEmpty()) {
213+ currentSessionId = sid
214+ messages.clear()
215+ val newId = nextId++
216+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
217+ requests.add(
218+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
219+ )
220+ }
221+ }
222+ is HttpResult.Err -> {
223+ error = "Failed to create session: ${result.value}"
224+ }
225+ }
226+ }
227+ is PendingRequest.LoadMessages -> {
228+ loading = false
229+ when (result) {
230+ is HttpResult.Ok -> {
231+ val body = result.value.body.content.decodeToString()
232+ messages = parseMessages(body).toMutableList()
233+ }
234+ is HttpResult.Err -> {
235+ error = "Failed to load messages: ${result.value}"
236+ }
237+ }
238+ }
239+ is PendingRequest.SendMessage -> {
240+ when (result) {
241+ is HttpResult.Ok -> {
242+ val sid = pending.sessionId
243+ val newId = nextId++
244+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
245+ requests.add(
246+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
247+ )
248+ }
249+ is HttpResult.Err -> {
250+ error = "Failed to send message: ${result.value}"
251+ }
252+ }
253+ }
254+ }
255+ return Requests(requests)
256+ }
257+
258+ fun currentView(): ViewModel {
259+ val screen = when {
260+ currentSessionId != null -> Screen.CHAT
261+ connected -> Screen.SESSIONS
262+ else -> Screen.CONNECT
263+ }
264+ return ViewModel(
265+ screen = screen,
266+ serverUrl = serverUrl,
267+ username = username,
268+ password = password,
269+ authRequired = authRequired,
270+ connected = connected,
271+ loading = loading,
272+ error = error,
273+ sessions = sessions.map { SessionView(it.id, it.title) },
274+ currentSessionId = currentSessionId,
275+ messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
276+ draftMessage = draftMessage,
277+ crashLogCount = crashLogs.size.toUInt(),
278+ latestCrashLog = crashLogs.lastOrNull(),
279+ )
280+ }
281+
282+ private fun makeGetRequest(url: String): HttpRequest {
283+ val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
284+ val creds = "$username:$password"
285+ val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
286+ listOf(HttpHeader("Authorization", "Basic $encoded"))
287+ } else {
288+ emptyList()
289+ }
290+ return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
291+ }
292+
293+ private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
294+ val headers = if (username.isNotEmpty() || password.isNotEmpty()) {
295+ val creds = "$username:$password"
296+ val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
297+ listOf(HttpHeader("Authorization", "Basic $encoded"))
298+ } else {
299+ emptyList()
300+ }
301+ return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
302+ }
303+}
304+
305+internal sealed class PendingRequest {
306+ data object HealthProbe : PendingRequest()
307+ data object LoadSessions : PendingRequest()
308+ data object CreateSession : PendingRequest()
309+ data class LoadMessages(val sessionId: String) : PendingRequest()
310+ data class SendMessage(val sessionId: String) : PendingRequest()
311+}
312+
313+internal data class PureSession(val id: String, val title: String)
314+
315+internal data class PureMessage(val id: String, val role: String, val text: String, val time: ULong)
316+
317+internal fun parseSessions(json: String): List<PureSession> {
318+ val sessions = mutableListOf<PureSession>()
319+ var depth = 0
320+ var start = 0
321+ for (i in json.indices) {
322+ when (json[i]) {
323+ '{' -> {
324+ if (depth == 0) start = i
325+ depth++
326+ }
327+ '}' -> {
328+ depth--
329+ if (depth == 0) {
330+ val obj = json.substring(start, i + 1)
331+ val id = extractJsonString(obj, "id") ?: ""
332+ val title = extractJsonString(obj, "title") ?: ""
333+ if (id.isNotEmpty()) {
334+ sessions.add(PureSession(id, if (title.isNotEmpty()) title else "Untitled"))
335+ }
336+ }
337+ }
338+ }
339+ }
340+ return sessions
341+}
342+
343+internal fun parseMessages(json: String): List<PureMessage> {
344+ val messages = mutableListOf<PureMessage>()
345+ var depth = 0
346+ var start = 0
347+ for (i in json.indices) {
348+ when (json[i]) {
349+ '{' -> {
350+ if (depth == 0) start = i
351+ depth++
352+ }
353+ '}' -> {
354+ depth--
355+ if (depth == 0) {
356+ val obj = json.substring(start, i + 1)
357+ val id = extractJsonString(obj, "id") ?: ""
358+ val role = extractJsonString(obj, "role") ?: ""
359+ val time = extractJsonNumber(obj, "created") ?: 0uL
360+ if (id.isNotEmpty() && role.isNotEmpty()) {
361+ messages.add(PureMessage(id, role, "", time))
362+ }
363+ }
364+ }
365+ }
366+ }
367+ return messages
368+}
369+
370+internal fun extractJsonString(json: String, key: String): String? {
371+ val pattern = "\"$key\""
372+ val idx = json.indexOf(pattern) ?: return null
373+ val rest = json.substring(idx + pattern.length)
374+ val colon = rest.indexOf(':') ?: return null
375+ val rest2 = rest.substring(colon + 1)
376+ val quote = rest2.indexOf('"') ?: return null
377+ val rest3 = rest2.substring(quote + 1)
378+ val end = rest3.indexOf('"') ?: return null
379+ return rest3.substring(0, end)
380+}
381+
382+internal fun extractJsonNumber(json: String, key: String): ULong? {
383+ val pattern = "\"$key\""
384+ val idx = json.indexOf(pattern) ?: return null
385+ val rest = json.substring(idx + pattern.length)
386+ val colon = rest.indexOf(':') ?: return null
387+ val rest2 = rest.substring(colon + 1).trimStart()
388+ val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it == -1) rest2.length else it }
389+ return rest2.substring(0, end).toULongOrNull()
390+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+70
@@ -0,0 +1,70 @@
1+package soy.iko.opencode.core
2+
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.okhttp.OkHttp
5+import io.ktor.client.plugins.HttpTimeout
6+import io.ktor.client.request.prepareGet
7+import io.ktor.client.statement.bodyAsChannel
8+import io.ktor.utils.io.readUTF8Line
9+import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.Job
12+import kotlinx.coroutines.SupervisorJob
13+import kotlinx.coroutines.flow.MutableSharedFlow
14+import kotlinx.coroutines.flow.SharedFlow
15+import kotlinx.coroutines.flow.asSharedFlow
16+import kotlinx.coroutines.launch
17+import soy.iko.opencode.CrashLogger
18+
19+/**
20+ * Subscribes to the opencode server's SSE event stream (`GET /event`).
21+ *
22+ * Events are emitted as raw strings on [events]. The Core converts them into
23+ * [Event.EventReceived] which triggers a message reload.
24+ *
25+ * Connection errors are reported to [CrashLogger] so they appear in the crash
26+ * log alongside any uncaught exceptions.
27+ */
28+class SseClient {
29+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
30+ private val httpClient = HttpClient(OkHttp) {
31+ install(HttpTimeout) {
32+ requestTimeoutMillis = Long.MAX_VALUE
33+ connectTimeoutMillis = 15_000
34+ socketTimeoutMillis = Long.MAX_VALUE
35+ }
36+ }
37+
38+ private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
39+ val events: SharedFlow<String> = _events.asSharedFlow()
40+
41+ private var currentJob: Job? = null
42+
43+ fun connect(url: String) {
44+ currentJob?.cancel()
45+ currentJob = scope.launch {
46+ try {
47+ httpClient.prepareGet(url).execute { response ->
48+ val channel = response.bodyAsChannel()
49+ val dataBuilder = StringBuilder()
50+ while (!channel.isClosedForRead) {
51+ val line = channel.readUTF8Line() ?: break
52+ if (line.startsWith("data: ")) {
53+ dataBuilder.append(line.substring(6))
54+ } else if (line.isEmpty() && dataBuilder.isNotEmpty()) {
55+ _events.emit(dataBuilder.toString())
56+ dataBuilder.clear()
57+ }
58+ }
59+ }
60+ } catch (e: Throwable) {
61+ CrashLogger.report(e, "SseClient")
62+ }
63+ }
64+ }
65+
66+ fun disconnect() {
67+ currentJob?.cancel()
68+ currentJob = null
69+ }
70+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+212
@@ -0,0 +1,212 @@
1+package soy.iko.opencode.ui.screens
2+
3+import androidx.compose.foundation.layout.Arrangement
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.Spacer
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.height
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.layout.widthIn
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.foundation.lazy.rememberLazyListState
17+import androidx.compose.foundation.shape.RoundedCornerShape
18+import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.automirrored.filled.ArrowBack
20+import androidx.compose.material.icons.automirrored.filled.Send
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.Surface
29+import androidx.compose.material3.Text
30+import androidx.compose.material3.TopAppBar
31+import androidx.compose.runtime.Composable
32+import androidx.compose.runtime.LaunchedEffect
33+import androidx.compose.runtime.collectAsState
34+import androidx.compose.runtime.getValue
35+import androidx.compose.runtime.mutableStateOf
36+import androidx.compose.runtime.remember
37+import androidx.compose.runtime.setValue
38+import androidx.compose.ui.Alignment
39+import androidx.compose.ui.Modifier
40+import androidx.compose.ui.draw.clip
41+import androidx.compose.ui.unit.dp
42+import soy.iko.opencode.core.Core
43+import soy.iko.opencode.core.Event
44+
45+@OptIn(ExperimentalMaterial3Api::class)
46+@Composable
47+fun ChatScreen(core: Core) {
48+ val view by core.view.collectAsState()
49+ var inputText by remember { mutableStateOf("") }
50+ val listState = rememberLazyListState()
51+
52+ LaunchedEffect(view.messages.size) {
53+ if (view.messages.isNotEmpty()) {
54+ listState.animateScrollToItem(view.messages.size - 1)
55+ }
56+ }
57+
58+ Scaffold(
59+ topBar = {
60+ TopAppBar(
61+ title = {
62+ Text(
63+ view.currentSessionId?.take(8)?.plus("…") ?: "Chat",
64+ maxLines = 1,
65+ )
66+ },
67+ navigationIcon = {
68+ IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
69+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
70+ }
71+ },
72+ )
73+ },
74+ bottomBar = {
75+ ChatInputBar(
76+ text = inputText,
77+ onTextChange = { inputText = it },
78+ onSend = {
79+ if (inputText.isNotBlank()) {
80+ core.update(Event.SendMessage(inputText))
81+ inputText = ""
82+ }
83+ },
84+ enabled = !view.loading,
85+ )
86+ },
87+ ) { padding ->
88+ Box(
89+ modifier = Modifier
90+ .fillMaxSize()
91+ .padding(padding),
92+ ) {
93+ if (view.loading && view.messages.isEmpty()) {
94+ CircularProgressIndicator(
95+ modifier = Modifier.align(Alignment.Center),
96+ )
97+ } else if (view.messages.isEmpty()) {
98+ Column(
99+ horizontalAlignment = Alignment.CenterHorizontally,
100+ verticalArrangement = Arrangement.Center,
101+ modifier = Modifier.fillMaxSize().padding(24.dp),
102+ ) {
103+ Text(
104+ "Send a message to start chatting",
105+ style = MaterialTheme.typography.bodyLarge,
106+ color = MaterialTheme.colorScheme.onSurfaceVariant,
107+ )
108+ }
109+ } else {
110+ LazyColumn(
111+ state = listState,
112+ modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
113+ verticalArrangement = Arrangement.spacedBy(8.dp),
114+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
115+ ) {
116+ items(view.messages, key = { it.id }) { message ->
117+ MessageBubble(message)
118+ }
119+ }
120+ }
121+ }
122+ }
123+}
124+
125+@Composable
126+private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
127+ val isUser = message.role == "user"
128+ Row(
129+ modifier = Modifier.fillMaxWidth(),
130+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
131+ ) {
132+ Surface(
133+ color = if (isUser) {
134+ MaterialTheme.colorScheme.primary
135+ } else {
136+ MaterialTheme.colorScheme.surfaceVariant
137+ },
138+ contentColor = if (isUser) {
139+ MaterialTheme.colorScheme.onPrimary
140+ } else {
141+ MaterialTheme.colorScheme.onSurfaceVariant
142+ },
143+ shape = RoundedCornerShape(
144+ topStart = 16.dp,
145+ topEnd = 16.dp,
146+ bottomStart = if (isUser) 16.dp else 4.dp,
147+ bottomEnd = if (isUser) 4.dp else 16.dp,
148+ ),
149+ modifier = Modifier.widthIn(max = 280.dp),
150+ ) {
151+ Column(
152+ modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
153+ ) {
154+ if (!isUser) {
155+ Text(
156+ text = message.role.replaceFirstChar { it.uppercase() },
157+ style = MaterialTheme.typography.labelSmall,
158+ color = MaterialTheme.colorScheme.primary,
159+ )
160+ Spacer(Modifier.height(2.dp))
161+ }
162+ Text(
163+ text = message.text.ifBlank { "(no content)" },
164+ style = MaterialTheme.typography.bodyMedium,
165+ )
166+ }
167+ }
168+ }
169+}
170+
171+@Composable
172+private fun ChatInputBar(
173+ text: String,
174+ onTextChange: (String) -> Unit,
175+ onSend: () -> Unit,
176+ enabled: Boolean,
177+) {
178+ Surface(
179+ tonalElevation = 3.dp,
180+ modifier = Modifier.fillMaxWidth(),
181+ ) {
182+ Row(
183+ verticalAlignment = Alignment.CenterVertically,
184+ modifier = Modifier
185+ .fillMaxWidth()
186+ .padding(horizontal = 12.dp, vertical = 8.dp),
187+ ) {
188+ OutlinedTextField(
189+ value = text,
190+ onValueChange = onTextChange,
191+ placeholder = { Text("Message…") },
192+ modifier = Modifier.weight(1f),
193+ maxLines = 4,
194+ )
195+ Spacer(Modifier.size(8.dp))
196+ IconButton(
197+ onClick = onSend,
198+ enabled = enabled && text.isNotBlank(),
199+ ) {
200+ Icon(
201+ Icons.AutoMirrored.Filled.Send,
202+ contentDescription = "Send",
203+ tint = if (text.isNotBlank()) {
204+ MaterialTheme.colorScheme.primary
205+ } else {
206+ MaterialTheme.colorScheme.onSurfaceVariant
207+ },
208+ )
209+ }
210+ }
211+ }
212+}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktnew file+171
@@ -0,0 +1,171 @@
1+package soy.iko.opencode.ui.screens
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Spacer
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.height
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.text.KeyboardOptions
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.Dns
15+import androidx.compose.material.icons.filled.Lock
16+import androidx.compose.material3.Button
17+import androidx.compose.material3.CircularProgressIndicator
18+import androidx.compose.material3.ExperimentalMaterial3Api
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.MaterialTheme
21+import androidx.compose.material3.OutlinedTextField
22+import androidx.compose.material3.Scaffold
23+import androidx.compose.material3.SnackbarHost
24+import androidx.compose.material3.SnackbarHostState
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
27+import androidx.compose.material3.TopAppBar
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.LaunchedEffect
30+import androidx.compose.runtime.collectAsState
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
35+import androidx.compose.ui.Alignment
36+import androidx.compose.ui.Modifier
37+import androidx.compose.ui.text.input.ImeAction
38+import androidx.compose.ui.text.input.KeyboardType
39+import androidx.compose.ui.text.input.PasswordVisualTransformation
40+import androidx.compose.ui.unit.dp
41+import soy.iko.opencode.core.Core
42+import soy.iko.opencode.core.Event
43+
44+@OptIn(ExperimentalMaterial3Api::class)
45+@Composable
46+fun ConnectScreen(core: Core) {
47+ val view by core.view.collectAsState()
48+ var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
49+ var username by remember(view.username) { mutableStateOf(view.username) }
50+ var password by remember(view.password) { mutableStateOf(view.password) }
51+ val snackbarHostState = remember { SnackbarHostState() }
52+
53+ LaunchedEffect(view.error) {
54+ view.error?.let {
55+ snackbarHostState.showSnackbar(it)
56+ }
57+ }
58+
59+ Scaffold(
60+ topBar = {
61+ TopAppBar(title = { Text("Opencode") })
62+ },
63+ snackbarHost = { SnackbarHost(snackbarHostState) },
64+ ) { padding ->
65+ Column(
66+ horizontalAlignment = Alignment.CenterHorizontally,
67+ verticalArrangement = Arrangement.Center,
68+ modifier = Modifier
69+ .fillMaxSize()
70+ .padding(padding)
71+ .padding(24.dp),
72+ ) {
73+ Icon(
74+ imageVector = Icons.Default.Dns,
75+ contentDescription = null,
76+ modifier = Modifier.size(72.dp),
77+ tint = MaterialTheme.colorScheme.primary,
78+ )
79+ Spacer(Modifier.height(16.dp))
80+ Text(
81+ text = "Connect to Opencode Server",
82+ style = MaterialTheme.typography.headlineSmall,
83+ )
84+ Spacer(Modifier.height(24.dp))
85+ OutlinedTextField(
86+ value = url,
87+ onValueChange = { url = it },
88+ label = { Text("Server URL") },
89+ singleLine = true,
90+ keyboardOptions = KeyboardOptions(
91+ keyboardType = KeyboardType.Uri,
92+ imeAction = ImeAction.Go,
93+ ),
94+ modifier = Modifier.padding(horizontal = 16.dp),
95+ )
96+
97+ AnimatedVisibility(visible = view.authRequired) {
98+ Column(
99+ horizontalAlignment = Alignment.CenterHorizontally,
100+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
101+ ) {
102+ Text(
103+ text = "Server requires authentication",
104+ style = MaterialTheme.typography.bodyMedium,
105+ color = MaterialTheme.colorScheme.error,
106+ )
107+ Spacer(Modifier.height(8.dp))
108+ OutlinedTextField(
109+ value = username,
110+ onValueChange = { username = it },
111+ label = { Text("Username") },
112+ singleLine = true,
113+ leadingIcon = {
114+ Icon(Icons.Default.Lock, contentDescription = null)
115+ },
116+ modifier = Modifier.padding(horizontal = 16.dp),
117+ )
118+ Spacer(Modifier.height(8.dp))
119+ OutlinedTextField(
120+ value = password,
121+ onValueChange = { password = it },
122+ label = { Text("Password") },
123+ singleLine = true,
124+ visualTransformation = PasswordVisualTransformation(),
125+ keyboardOptions = KeyboardOptions(
126+ keyboardType = KeyboardType.Password,
127+ imeAction = ImeAction.Go,
128+ ),
129+ modifier = Modifier.padding(horizontal = 16.dp),
130+ )
131+ Spacer(Modifier.height(4.dp))
132+ TextButton(onClick = { core.update(Event.CancelAuth) }) {
133+ Text("Cancel")
134+ }
135+ }
136+ }
137+
138+ Spacer(Modifier.height(16.dp))
139+ Button(
140+ onClick = {
141+ core.update(Event.ServerUrlChanged(url))
142+ if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
143+ core.update(Event.UsernameChanged(username))
144+ core.update(Event.PasswordChanged(password))
145+ }
146+ core.update(Event.Connect)
147+ },
148+ enabled = !view.loading,
149+ ) {
150+ if (view.loading) {
151+ CircularProgressIndicator(
152+ modifier = Modifier.size(20.dp),
153+ strokeWidth = 2.dp,
154+ color = MaterialTheme.colorScheme.onPrimary,
155+ )
156+ } else {
157+ Text("Connect")
158+ }
159+ }
160+
161+ if (view.crashLogCount > 0u) {
162+ Spacer(Modifier.height(16.dp))
163+ Text(
164+ text = "${view.crashLogCount} crash report(s) recorded",
165+ style = MaterialTheme.typography.labelSmall,
166+ color = MaterialTheme.colorScheme.onSurfaceVariant,
167+ )
168+ }
169+ }
170+ }
171+}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktnew file+100
@@ -0,0 +1,100 @@
1+package soy.iko.opencode.ui.screens
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.fillMaxSize
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.material.icons.Icons
11+import androidx.compose.material.icons.filled.Add
12+import androidx.compose.material.icons.filled.ChatBubble
13+import androidx.compose.material3.CircularProgressIndicator
14+import androidx.compose.material3.ExperimentalMaterial3Api
15+import androidx.compose.material3.FloatingActionButton
16+import androidx.compose.material3.Icon
17+import androidx.compose.material3.IconButton
18+import androidx.compose.material3.ListItem
19+import androidx.compose.material3.MaterialTheme
20+import androidx.compose.material3.Scaffold
21+import androidx.compose.material3.Text
22+import androidx.compose.material3.TopAppBar
23+import androidx.compose.runtime.Composable
24+import androidx.compose.runtime.collectAsState
25+import androidx.compose.runtime.getValue
26+import androidx.compose.ui.Alignment
27+import androidx.compose.ui.Modifier
28+import androidx.compose.ui.unit.dp
29+import soy.iko.opencode.core.Core
30+import soy.iko.opencode.core.Event
31+
32+@OptIn(ExperimentalMaterial3Api::class)
33+@Composable
34+fun SessionsScreen(core: Core) {
35+ val view by core.view.collectAsState()
36+
37+ Scaffold(
38+ topBar = {
39+ TopAppBar(
40+ title = { Text("Sessions") },
41+ actions = {
42+ IconButton(onClick = { core.update(Event.CreateSession) }) {
43+ Icon(Icons.Default.Add, contentDescription = "New session")
44+ }
45+ },
46+ )
47+ },
48+ floatingActionButton = {
49+ FloatingActionButton(onClick = { core.update(Event.CreateSession) }) {
50+ Icon(Icons.Default.Add, contentDescription = "New session")
51+ }
52+ },
53+ ) { padding ->
54+ Box(
55+ modifier = Modifier
56+ .fillMaxSize()
57+ .padding(padding),
58+ ) {
59+ if (view.loading && view.sessions.isEmpty()) {
60+ CircularProgressIndicator(
61+ modifier = Modifier.align(Alignment.Center),
62+ )
63+ } else if (view.sessions.isEmpty()) {
64+ Column(
65+ horizontalAlignment = Alignment.CenterHorizontally,
66+ modifier = Modifier
67+ .fillMaxSize()
68+ .padding(24.dp),
69+ ) {
70+ Text(
71+ "No sessions yet",
72+ style = MaterialTheme.typography.bodyLarge,
73+ color = MaterialTheme.colorScheme.onSurfaceVariant,
74+ )
75+ }
76+ } else {
77+ LazyColumn {
78+ items(view.sessions, key = { it.id }) { session ->
79+ ListItem(
80+ headlineContent = { Text(session.title.ifEmpty { "Untitled" }) },
81+ supportingContent = { Text(session.id.take(8)) },
82+ leadingContent = {
83+ Icon(
84+ Icons.Default.ChatBubble,
85+ contentDescription = null,
86+ tint = MaterialTheme.colorScheme.primary,
87+ )
88+ },
89+ modifier = Modifier
90+ .padding(horizontal = 0.dp)
91+ .clickable {
92+ core.update(Event.SelectSession(session.id))
93+ },
94+ )
95+ }
96+ }
97+ }
98+ }
99+ }
100+}
app/src/main/java/soy/iko/opencode/ui/theme/Theme.ktnew file+58
@@ -0,0 +1,58 @@
1+package soy.iko.opencode.ui.theme
2+
3+import android.os.Build
4+import androidx.compose.foundation.isSystemInDarkTheme
5+import androidx.compose.material3.MaterialTheme
6+import androidx.compose.material3.darkColorScheme
7+import androidx.compose.material3.dynamicDarkColorScheme
8+import androidx.compose.material3.dynamicLightColorScheme
9+import androidx.compose.material3.lightColorScheme
10+import androidx.compose.runtime.Composable
11+import androidx.compose.ui.graphics.Color
12+import androidx.compose.ui.platform.LocalContext
13+
14+private val DarkColorScheme = darkColorScheme(
15+ primary = Color(0xFF82B1FF),
16+ secondary = Color(0xFF80CBC4),
17+ tertiary = Color(0xFFFFB74D),
18+ background = Color(0xFF121212),
19+ surface = Color(0xFF1E1E1E),
20+ onPrimary = Color.Black,
21+ onSecondary = Color.Black,
22+ onTertiary = Color.Black,
23+ onBackground = Color.White,
24+ onSurface = Color.White,
25+)
26+
27+private val LightColorScheme = lightColorScheme(
28+ primary = Color(0xFF1565C0),
29+ secondary = Color(0xFF00897B),
30+ tertiary = Color(0xFFF57C00),
31+ background = Color(0xFFF5F5F5),
32+ surface = Color.White,
33+ onPrimary = Color.White,
34+ onSecondary = Color.White,
35+ onTertiary = Color.White,
36+ onBackground = Color.Black,
37+ onSurface = Color.Black,
38+)
39+
40+@Composable
41+fun OpencodeTheme(
42+ darkTheme: Boolean = isSystemInDarkTheme(),
43+ dynamicColor: Boolean = true,
44+ content: @Composable () -> Unit,
45+) {
46+ val colorScheme = when {
47+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
48+ val context = LocalContext.current
49+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
50+ }
51+ darkTheme -> DarkColorScheme
52+ else -> LightColorScheme
53+ }
54+ MaterialTheme(
55+ colorScheme = colorScheme,
56+ content = content,
57+ )
58+}
app/src/main/res/values-night/themes.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar" />
4+</resources>
app/src/main/res/values/strings.xmlnew file+3
@@ -0,0 +1,3 @@
1+<resources>
2+ <string name="app_name">Opencode</string>
3+</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.Opencode" parent="android:Theme.Material.Light.NoActionBar" />
4+</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.ktnew file+201
@@ -0,0 +1,201 @@
1+package soy.iko.opencode.core
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertNotNull
6+import org.junit.Assert.assertNull
7+import org.junit.Assert.assertTrue
8+import org.junit.Test
9+
10+class PureCoreFfiTest {
11+
12+ private fun createCore(): PureCoreFfi = PureCoreFfi()
13+
14+ private fun PureCoreFfi.currentView(): ViewModel =
15+ ViewModel.bincodeDeserialize(view())
16+
17+ private fun PureCoreFfi.processEvent(event: Event): Requests {
18+ val effects = update(event.bincodeSerialize())
19+ return Requests.bincodeDeserialize(effects)
20+ }
21+
22+ private fun PureCoreFfi.resolveHttp(
23+ requestId: UInt,
24+ status: Int = 200,
25+ body: String = "{}",
26+ ): Requests {
27+ val response = HttpResponse(
28+ status = status.toUShort(),
29+ headers = emptyList(),
30+ body = com.novi.serde.Bytes(body.toByteArray()),
31+ )
32+ val result = HttpResult.Ok(response)
33+ val effects = resolve(requestId, result.bincodeSerialize())
34+ return Requests.bincodeDeserialize(effects)
35+ }
36+
37+ @Test
38+ fun `start event sets default server URL`() {
39+ val core = createCore()
40+ core.processEvent(Event.Start)
41+ val view = core.currentView()
42+ assertEquals("http://localhost:4096", view.serverUrl)
43+ assertEquals(Screen.CONNECT, view.screen)
44+ }
45+
46+ @Test
47+ fun `server URL change updates view`() {
48+ val core = createCore()
49+ core.processEvent(Event.Start)
50+ core.processEvent(Event.ServerUrlChanged("http://10.0.0.1:4096"))
51+ val view = core.currentView()
52+ assertEquals("http://10.0.0.1:4096", view.serverUrl)
53+ }
54+
55+ @Test
56+ fun `connect emits HTTP effect`() {
57+ val core = createCore()
58+ core.processEvent(Event.Start)
59+ val requests = core.processEvent(Event.Connect)
60+ assertEquals(1, requests.value.size)
61+ assertTrue(requests.value[0].effect is Effect.Http)
62+ val view = core.currentView()
63+ assertTrue(view.loading)
64+ }
65+
66+ @Test
67+ fun `successful connect transitions to sessions screen`() {
68+ val core = createCore()
69+ core.processEvent(Event.Start)
70+ val requests = core.processEvent(Event.Connect)
71+ val httpReq = requests.value[0]
72+ assertEquals(Effect.Http::class, httpReq.effect::class)
73+
74+ // Simulate health check success — triggers LoadSessions
75+ val followUpRequests = core.resolveHttp(httpReq.id, body = """{"healthy":true}""")
76+ assertEquals(1, followUpRequests.value.size)
77+
78+ // Simulate sessions loaded
79+ val sessionsBody = """[{"id":"s1","title":"My Session"}]"""
80+ val finalRequests = core.resolveHttp(followUpRequests.value[0].id, body = sessionsBody)
81+ assertEquals(0, finalRequests.value.size)
82+
83+ val view = core.currentView()
84+ assertTrue(view.connected)
85+ assertEquals(Screen.SESSIONS, view.screen)
86+ assertEquals(1, view.sessions.size)
87+ assertEquals("s1", view.sessions[0].id)
88+ assertEquals("My Session", view.sessions[0].title)
89+ }
90+
91+ @Test
92+ fun `failed connect shows error`() {
93+ val core = createCore()
94+ core.processEvent(Event.Start)
95+ val requests = core.processEvent(Event.Connect)
96+
97+ val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
98+ val effects = core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
99+ Requests.bincodeDeserialize(effects)
100+
101+ val view = core.currentView()
102+ assertNotNull(view.error)
103+ assertFalse(view.loading)
104+ }
105+
106+ @Test
107+ fun `dismiss error clears error`() {
108+ val core = createCore()
109+ core.processEvent(Event.Start)
110+ val requests = core.processEvent(Event.Connect)
111+
112+ val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
113+ core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
114+
115+ core.processEvent(Event.DismissError)
116+ val view = core.currentView()
117+ assertNull(view.error)
118+ }
119+
120+ @Test
121+ fun `select session loads messages and transitions to chat`() {
122+ val core = createCore()
123+ core.processEvent(Event.Start)
124+ val connectReqs = core.processEvent(Event.Connect)
125+ core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
126+ core.resolveHttp(0u, body = "[]")
127+
128+ val selectReqs = core.processEvent(Event.SelectSession("session-123"))
129+ assertTrue(selectReqs.value.isNotEmpty())
130+
131+ val view = core.currentView()
132+ assertEquals("session-123", view.currentSessionId)
133+ assertEquals(Screen.CHAT, view.screen)
134+ }
135+
136+ @Test
137+ fun `401 response sets auth_required in view`() {
138+ val core = createCore()
139+ core.processEvent(Event.Start)
140+ val requests = core.processEvent(Event.Connect)
141+
142+ // Simulate 401 — server behind basic auth
143+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
144+
145+ val view = core.currentView()
146+ assertTrue(view.authRequired)
147+ assertFalse(view.loading)
148+ assertEquals(Screen.CONNECT, view.screen)
149+ }
150+
151+ @Test
152+ fun `401 with credentials shows invalid credentials error`() {
153+ val core = createCore()
154+ core.processEvent(Event.Start)
155+ core.processEvent(Event.UsernameChanged("opencode"))
156+ core.processEvent(Event.PasswordChanged("wrong"))
157+ val requests = core.processEvent(Event.Connect)
158+
159+ // Simulate 401 despite credentials — invalid credentials
160+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
161+
162+ val view = core.currentView()
163+ assertNotNull(view.error)
164+ assertTrue(view.error!!.contains("Invalid credentials"))
165+ assertFalse(view.authRequired)
166+ }
167+
168+ @Test
169+ fun `cancel auth clears auth_required and credentials`() {
170+ val core = createCore()
171+ core.processEvent(Event.Start)
172+ val requests = core.processEvent(Event.Connect)
173+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
174+
175+ core.processEvent(Event.CancelAuth)
176+ val view = core.currentView()
177+ assertFalse(view.authRequired)
178+ assertEquals("", view.username)
179+ assertEquals("", view.password)
180+ }
181+
182+ @Test
183+ fun `successful connect with credentials marks authed`() {
184+ val core = createCore()
185+ core.processEvent(Event.Start)
186+ core.processEvent(Event.UsernameChanged("opencode"))
187+ core.processEvent(Event.PasswordChanged("secret"))
188+ val requests = core.processEvent(Event.Connect)
189+
190+ // Health check with credentials succeeds
191+ val followUp = core.resolveHttp(requests.value[0].id, body = """{"healthy":true}""")
192+ assertEquals(1, followUp.value.size)
193+
194+ val sessionsBody = """[{"id":"s1","title":"Authed Session"}]"""
195+ core.resolveHttp(followUp.value[0].id, body = sessionsBody)
196+
197+ val view = core.currentView()
198+ assertTrue(view.connected)
199+ assertEquals(Screen.SESSIONS, view.screen)
200+ }
201+}
build.gradle.ktsnew file+7
@@ -0,0 +1,7 @@
1+plugins {
2+ alias(libs.plugins.android.application) apply false
3+ alias(libs.plugins.kotlin.android) apply false
4+ alias(libs.plugins.kotlin.compose) apply false
5+ alias(libs.plugins.android.library) apply false
6+ alias(libs.plugins.kotlin.serialization) apply false
7+}
flake.nixnew file+70
@@ -0,0 +1,70 @@
1+{
2+ description = "Dev environment for opencode-android (Crux Rust core + Kotlin / Jetpack Compose shell)";
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 = "36";
14+ buildToolsVersion = "36.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+ includeEmulator = false;
30+ includeSystemImages = false;
31+ systemImageTypes = [ "google_apis" ];
32+ abiVersions = [ "x86_64" ];
33+
34+ includeNDK = false;
35+ };
36+
37+ sdk = android.androidsdk;
38+ sdkRoot = "${sdk}/libexec/android-sdk";
39+
40+ aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
41+ in {
42+ devShells.default = pkgs.mkShell {
43+ buildInputs = [
44+ pkgs.jdk17
45+ pkgs.gradle
46+ pkgs.rustc
47+ pkgs.cargo
48+ sdk
49+ ];
50+
51+ ANDROID_HOME = sdkRoot;
52+ ANDROID_SDK_ROOT = sdkRoot;
53+ JAVA_HOME = "${pkgs.jdk17}";
54+
55+ GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
56+
57+ shellHook = ''
58+ echo "opencode-android dev shell"
59+ echo " JDK: $(java -version 2>&1 | head -1)"
60+ echo " ANDROID_HOME: $ANDROID_HOME"
61+ echo " aapt2: ${aapt2Path}"
62+ echo " Rust: $(rustc --version)"
63+ echo ""
64+ echo "Build: ./gradlew assembleDebug"
65+ echo "Core: cd shared && cargo test"
66+ echo "Device: adb devices"
67+ '';
68+ };
69+ });
70+}
generated/build.gradle.ktsnew file+21
@@ -0,0 +1,21 @@
1+plugins {
2+ kotlin("jvm") version "2.2.0"
3+ kotlin("plugin.serialization") version "2.2.0"
4+ `java-library`
5+}
6+
7+group = "soy.iko.opencode.core"
8+version = "1.0.0"
9+
10+repositories {
11+ mavenCentral()
12+}
13+
14+dependencies {}
15+
16+tasks.withType<Jar> {
17+ manifest {
18+ attributes["Implementation-Title"] = "soy.iko.opencode.core"
19+ attributes["Implementation-Version"] = "1.0.0"
20+ }
21+}
generated/com/novi/bincode/BincodeDeserializer.ktnew file+39
@@ -0,0 +1,39 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode
5+
6+import com.novi.serde.BinaryDeserializer
7+import com.novi.serde.DeserializationError
8+import com.novi.serde.Slice
9+
10+class BincodeDeserializer(input: ByteArray) : BinaryDeserializer(input, Long.MAX_VALUE) {
11+ @Throws(DeserializationError::class)
12+ override fun deserialize_f32(): Float {
13+ return Float.fromBits(getInt())
14+ }
15+
16+ @Throws(DeserializationError::class)
17+ override fun deserialize_f64(): Double {
18+ return Double.fromBits(getLong())
19+ }
20+
21+ @Throws(DeserializationError::class)
22+ override fun deserialize_len(): Long {
23+ val value = getLong()
24+ if (value < 0 || value > Int.MAX_VALUE.toLong()) {
25+ throw DeserializationError("Incorrect length value")
26+ }
27+ return value
28+ }
29+
30+ @Throws(DeserializationError::class)
31+ override fun deserialize_variant_index(): Int {
32+ return getInt()
33+ }
34+
35+ @Throws(DeserializationError::class)
36+ override fun check_that_key_slices_are_increasing(key1: Slice, key2: Slice) {
37+ // Not required by the format.
38+ }
39+}
generated/com/novi/bincode/BincodeSerializer.ktnew file+33
@@ -0,0 +1,33 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode
5+
6+import com.novi.serde.BinarySerializer
7+import com.novi.serde.SerializationError
8+
9+class BincodeSerializer : BinarySerializer(Long.MAX_VALUE) {
10+ @Throws(SerializationError::class)
11+ override fun serialize_f32(value: Float) {
12+ serialize_i32(value.toRawBits())
13+ }
14+
15+ @Throws(SerializationError::class)
16+ override fun serialize_f64(value: Double) {
17+ serialize_i64(value.toRawBits())
18+ }
19+
20+ @Throws(SerializationError::class)
21+ override fun serialize_len(value: Long) {
22+ serialize_u64(value.toULong())
23+ }
24+
25+ @Throws(SerializationError::class)
26+ override fun serialize_variant_index(value: Int) {
27+ serialize_u32(value.toUInt())
28+ }
29+
30+ override fun sort_map_entries(offsets: IntArray) {
31+ // Not required by the format.
32+ }
33+}
generated/com/novi/serde/BinaryDeserializer.ktnew file+182
@@ -0,0 +1,182 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+abstract class BinaryDeserializer(
7+ protected val input: ByteArray,
8+ maxContainerDepth: Long
9+) : Deserializer {
10+ private var position: Int = 0
11+ private var containerDepthBudget: Long = maxContainerDepth
12+
13+ @Throws(DeserializationError::class)
14+ override fun increase_container_depth() {
15+ if (containerDepthBudget == 0L) {
16+ throw DeserializationError("Exceeded maximum container depth")
17+ }
18+ containerDepthBudget -= 1
19+ }
20+
21+ override fun decrease_container_depth() {
22+ containerDepthBudget += 1
23+ }
24+
25+ @Throws(DeserializationError::class)
26+ override fun deserialize_str(): String {
27+ val len = deserialize_len()
28+ if (len < 0 || len > Int.MAX_VALUE.toLong()) {
29+ throw DeserializationError("Incorrect length value for Kotlin string")
30+ }
31+ val content = readBytes(len.toInt())
32+ return try {
33+ content.decodeToString(throwOnInvalidSequence = true)
34+ } catch (e: Throwable) {
35+ throw DeserializationError("Incorrect UTF8 string")
36+ }
37+ }
38+
39+ @Throws(DeserializationError::class)
40+ override fun deserialize_bytes(): Bytes {
41+ val len = deserialize_len()
42+ if (len < 0 || len > Int.MAX_VALUE.toLong()) {
43+ throw DeserializationError("Incorrect length value for Kotlin array")
44+ }
45+ return Bytes(readBytes(len.toInt()))
46+ }
47+
48+ @Throws(DeserializationError::class)
49+ override fun deserialize_bool(): Boolean {
50+ val value = getByte()
51+ return when (value.toInt()) {
52+ 0 -> false
53+ 1 -> true
54+ else -> throw DeserializationError("Incorrect boolean value")
55+ }
56+ }
57+
58+ @Throws(DeserializationError::class)
59+ override fun deserialize_unit(): Unit {
60+ return Unit
61+ }
62+
63+ @Throws(DeserializationError::class)
64+ override fun deserialize_char(): Char {
65+ throw DeserializationError("Not implemented: deserialize_char")
66+ }
67+
68+ @Throws(DeserializationError::class)
69+ override fun deserialize_u8(): UByte {
70+ return getByte().toUByte()
71+ }
72+
73+ @Throws(DeserializationError::class)
74+ override fun deserialize_u16(): UShort {
75+ val b0 = getByte().toInt() and 0xff
76+ val b1 = getByte().toInt() and 0xff
77+ return (b0 or (b1 shl 8)).toUShort()
78+ }
79+
80+ @Throws(DeserializationError::class)
81+ override fun deserialize_u32(): UInt {
82+ val b0 = getByte().toInt() and 0xff
83+ val b1 = getByte().toInt() and 0xff
84+ val b2 = getByte().toInt() and 0xff
85+ val b3 = getByte().toInt() and 0xff
86+ return (b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toUInt()
87+ }
88+
89+ @Throws(DeserializationError::class)
90+ override fun deserialize_u64(): ULong {
91+ var value = 0uL
92+ for (shift in 0 until 64 step 8) {
93+ val byteValue = getByte().toULong() and 0xffuL
94+ value = value or (byteValue shl shift)
95+ }
96+ return value
97+ }
98+
99+ @Throws(DeserializationError::class)
100+ override fun deserialize_u128(): UInt128 {
101+ val low = deserialize_u64()
102+ val high = deserialize_u64()
103+ return UInt128(high = high, low = low)
104+ }
105+
106+ @Throws(DeserializationError::class)
107+ override fun deserialize_i8(): Byte {
108+ return getByte()
109+ }
110+
111+ @Throws(DeserializationError::class)
112+ override fun deserialize_i16(): Short {
113+ return deserialize_u16().toShort()
114+ }
115+
116+ @Throws(DeserializationError::class)
117+ override fun deserialize_i32(): Int {
118+ return deserialize_u32().toInt()
119+ }
120+
121+ @Throws(DeserializationError::class)
122+ override fun deserialize_i64(): Long {
123+ return deserialize_u64().toLong()
124+ }
125+
126+ @Throws(DeserializationError::class)
127+ override fun deserialize_i128(): Int128 {
128+ val low = deserialize_u64()
129+ val high = deserialize_i64()
130+ return Int128(high = high, low = low)
131+ }
132+
133+ @Throws(DeserializationError::class)
134+ override fun deserialize_option_tag(): Boolean {
135+ return deserialize_bool()
136+ }
137+
138+ override fun get_buffer_offset(): Int {
139+ return position
140+ }
141+
142+ protected fun getInt(): Int {
143+ val b0 = getByte().toInt() and 0xff
144+ val b1 = getByte().toInt() and 0xff
145+ val b2 = getByte().toInt() and 0xff
146+ val b3 = getByte().toInt() and 0xff
147+ return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
148+ }
149+
150+ protected fun getLong(): Long {
151+ var value = 0L
152+ for (shift in 0 until 64 step 8) {
153+ val byteValue = getByte().toLong() and 0xffL
154+ value = value or (byteValue shl shift)
155+ }
156+ return value
157+ }
158+
159+ protected fun getByte(): Byte {
160+ requireAvailable(1)
161+ val value = input[position]
162+ position += 1
163+ return value
164+ }
165+
166+ private fun readBytes(count: Int): ByteArray {
167+ requireAvailable(count)
168+ val slice = input.copyOfRange(position, position + count)
169+ position += count
170+ return slice
171+ }
172+
173+ private fun requireAvailable(count: Int) {
174+ if (position + count > input.size) {
175+ throw DeserializationError(INPUT_NOT_LARGE_ENOUGH)
176+ }
177+ }
178+
179+ companion object {
180+ private const val INPUT_NOT_LARGE_ENOUGH = "Input is not large enough"
181+ }
182+}
generated/com/novi/serde/BinarySerializer.ktnew file+122
@@ -0,0 +1,122 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+abstract class BinarySerializer(maxContainerDepth: Long) : Serializer {
7+ protected val output: SerdeByteArrayOutput = SerdeByteArrayOutput()
8+ private var containerDepthBudget: Long = maxContainerDepth
9+
10+ @Throws(SerializationError::class)
11+ override fun increase_container_depth() {
12+ if (containerDepthBudget == 0L) {
13+ throw SerializationError("Exceeded maximum container depth")
14+ }
15+ containerDepthBudget -= 1
16+ }
17+
18+ override fun decrease_container_depth() {
19+ containerDepthBudget += 1
20+ }
21+
22+ @Throws(SerializationError::class)
23+ override fun serialize_str(value: String) {
24+ serialize_bytes(Bytes(value.encodeToByteArray()))
25+ }
26+
27+ @Throws(SerializationError::class)
28+ override fun serialize_bytes(value: Bytes) {
29+ serialize_len(value.content.size.toLong())
30+ output.writeBytes(value.content, 0, value.content.size)
31+ }
32+
33+ @Throws(SerializationError::class)
34+ override fun serialize_bool(value: Boolean) {
35+ output.writeByte(if (value) 1.toByte() else 0.toByte())
36+ }
37+
38+ @Throws(SerializationError::class)
39+ override fun serialize_unit(value: Unit) {
40+ // Nothing to serialize.
41+ }
42+
43+ @Throws(SerializationError::class)
44+ override fun serialize_char(value: Char) {
45+ throw SerializationError("Not implemented: serialize_char")
46+ }
47+
48+ @Throws(SerializationError::class)
49+ override fun serialize_u8(value: UByte) {
50+ output.writeByte(value.toByte())
51+ }
52+
53+ @Throws(SerializationError::class)
54+ override fun serialize_u16(value: UShort) {
55+ val v = value.toInt()
56+ output.writeByte((v and 0xff).toByte())
57+ output.writeByte(((v ushr 8) and 0xff).toByte())
58+ }
59+
60+ @Throws(SerializationError::class)
61+ override fun serialize_u32(value: UInt) {
62+ val v = value.toInt()
63+ output.writeByte((v and 0xff).toByte())
64+ output.writeByte(((v ushr 8) and 0xff).toByte())
65+ output.writeByte(((v ushr 16) and 0xff).toByte())
66+ output.writeByte(((v ushr 24) and 0xff).toByte())
67+ }
68+
69+ @Throws(SerializationError::class)
70+ override fun serialize_u64(value: ULong) {
71+ var v = value
72+ for (i in 0 until 8) {
73+ output.writeByte((v and 0xffuL).toByte())
74+ v = v shr 8
75+ }
76+ }
77+
78+ @Throws(SerializationError::class)
79+ override fun serialize_u128(value: UInt128) {
80+ serialize_u64(value.low)
81+ serialize_u64(value.high)
82+ }
83+
84+ @Throws(SerializationError::class)
85+ override fun serialize_i8(value: Byte) {
86+ serialize_u8(value.toUByte())
87+ }
88+
89+ @Throws(SerializationError::class)
90+ override fun serialize_i16(value: Short) {
91+ serialize_u16(value.toUShort())
92+ }
93+
94+ @Throws(SerializationError::class)
95+ override fun serialize_i32(value: Int) {
96+ serialize_u32(value.toUInt())
97+ }
98+
99+ @Throws(SerializationError::class)
100+ override fun serialize_i64(value: Long) {
101+ serialize_u64(value.toULong())
102+ }
103+
104+ @Throws(SerializationError::class)
105+ override fun serialize_i128(value: Int128) {
106+ serialize_u64(value.low)
107+ serialize_i64(value.high)
108+ }
109+
110+ @Throws(SerializationError::class)
111+ override fun serialize_option_tag(value: Boolean) {
112+ output.writeByte(if (value) 1.toByte() else 0.toByte())
113+ }
114+
115+ override fun get_buffer_offset(): Int {
116+ return output.size()
117+ }
118+
119+ override fun get_bytes(): ByteArray {
120+ return output.toByteArray()
121+ }
122+}
generated/com/novi/serde/Bytes.ktnew file+39
@@ -0,0 +1,39 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+/**
7+ * Inline value class wrapper around ByteArray.
8+ *
9+ * Provides proper value semantics for `equals` and `hashCode` using
10+ * structural equality (contentEquals/contentHashCode) instead of
11+ * referential equality.
12+ *
13+ * As a value class, this wrapper has zero runtime overhead - it's
14+ * inlined at compile time and compiles down to ByteArray at runtime.
15+ */
16+@JvmInline
17+value class Bytes(val content: ByteArray) {
18+ override fun toString(): String = content.contentToString()
19+
20+ companion object {
21+ fun empty(): Bytes = Bytes(ByteArray(0))
22+
23+ fun valueOf(content: ByteArray): Bytes = Bytes(content)
24+ }
25+}
26+
27+/**
28+ * Extension function for structural equality comparison.
29+ * Required because value classes don't support overriding equals.
30+ */
31+fun Bytes.contentEquals(other: Bytes): Boolean =
32+ this.content.contentEquals(other.content)
33+
34+/**
35+ * Extension function for structural hash code.
36+ * Required because value classes don't support overriding hashCode.
37+ */
38+fun Bytes.contentHashCode(): Int =
39+ content.contentHashCode()
generated/com/novi/serde/DeserializationError.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class DeserializationError(message: String) : Exception(message)
generated/com/novi/serde/Deserializer.ktnew file+76
@@ -0,0 +1,76 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+interface Deserializer {
7+ @Throws(DeserializationError::class)
8+ fun deserialize_str(): String
9+
10+ @Throws(DeserializationError::class)
11+ fun deserialize_bytes(): Bytes
12+
13+ @Throws(DeserializationError::class)
14+ fun deserialize_bool(): Boolean
15+
16+ @Throws(DeserializationError::class)
17+ fun deserialize_unit(): Unit
18+
19+ @Throws(DeserializationError::class)
20+ fun deserialize_char(): Char
21+
22+ @Throws(DeserializationError::class)
23+ fun deserialize_f32(): Float
24+
25+ @Throws(DeserializationError::class)
26+ fun deserialize_f64(): Double
27+
28+ @Throws(DeserializationError::class)
29+ fun deserialize_u8(): UByte
30+
31+ @Throws(DeserializationError::class)
32+ fun deserialize_u16(): UShort
33+
34+ @Throws(DeserializationError::class)
35+ fun deserialize_u32(): UInt
36+
37+ @Throws(DeserializationError::class)
38+ fun deserialize_u64(): ULong
39+
40+ @Throws(DeserializationError::class)
41+ fun deserialize_u128(): UInt128
42+
43+ @Throws(DeserializationError::class)
44+ fun deserialize_i8(): Byte
45+
46+ @Throws(DeserializationError::class)
47+ fun deserialize_i16(): Short
48+
49+ @Throws(DeserializationError::class)
50+ fun deserialize_i32(): Int
51+
52+ @Throws(DeserializationError::class)
53+ fun deserialize_i64(): Long
54+
55+ @Throws(DeserializationError::class)
56+ fun deserialize_i128(): Int128
57+
58+ @Throws(DeserializationError::class)
59+ fun deserialize_len(): Long
60+
61+ @Throws(DeserializationError::class)
62+ fun deserialize_variant_index(): Int
63+
64+ @Throws(DeserializationError::class)
65+ fun deserialize_option_tag(): Boolean
66+
67+ @Throws(DeserializationError::class)
68+ fun increase_container_depth()
69+
70+ fun decrease_container_depth()
71+
72+ fun get_buffer_offset(): Int
73+
74+ @Throws(DeserializationError::class)
75+ fun check_that_key_slices_are_increasing(key1: Slice, key2: Slice)
76+}
generated/com/novi/serde/Int128.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Int128(val high: Long, val low: ULong)
generated/com/novi/serde/SerdeByteArrayOutput.ktnew file+48
@@ -0,0 +1,48 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class SerdeByteArrayOutput(initialCapacity: Int = 32) {
7+ private var buffer: ByteArray = ByteArray(initialCapacity)
8+ private var length: Int = 0
9+
10+ fun writeByte(value: Byte) {
11+ ensureCapacity(1)
12+ buffer[length] = value
13+ length += 1
14+ }
15+
16+ fun writeBytes(value: ByteArray, offset: Int, count: Int) {
17+ if (count == 0) {
18+ return
19+ }
20+ ensureCapacity(count)
21+ value.copyInto(buffer, destinationOffset = length, startIndex = offset, endIndex = offset + count)
22+ length += count
23+ }
24+
25+ fun size(): Int {
26+ return length
27+ }
28+
29+ fun toByteArray(): ByteArray {
30+ return buffer.copyOf(length)
31+ }
32+
33+ fun getBuffer(): ByteArray {
34+ return buffer
35+ }
36+
37+ private fun ensureCapacity(extra: Int) {
38+ val required = length + extra
39+ if (required <= buffer.size) {
40+ return
41+ }
42+ var newSize = buffer.size
43+ while (newSize < required) {
44+ newSize = newSize * 2
45+ }
46+ buffer = buffer.copyOf(newSize)
47+ }
48+}
generated/com/novi/serde/SerializationError.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class SerializationError(message: String) : Exception(message)
generated/com/novi/serde/Serializer.ktnew file+77
@@ -0,0 +1,77 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+interface Serializer {
7+ @Throws(SerializationError::class)
8+ fun serialize_str(value: String)
9+
10+ @Throws(SerializationError::class)
11+ fun serialize_bytes(value: Bytes)
12+
13+ @Throws(SerializationError::class)
14+ fun serialize_bool(value: Boolean)
15+
16+ @Throws(SerializationError::class)
17+ fun serialize_unit(value: Unit)
18+
19+ @Throws(SerializationError::class)
20+ fun serialize_char(value: Char)
21+
22+ @Throws(SerializationError::class)
23+ fun serialize_f32(value: Float)
24+
25+ @Throws(SerializationError::class)
26+ fun serialize_f64(value: Double)
27+
28+ @Throws(SerializationError::class)
29+ fun serialize_u8(value: UByte)
30+
31+ @Throws(SerializationError::class)
32+ fun serialize_u16(value: UShort)
33+
34+ @Throws(SerializationError::class)
35+ fun serialize_u32(value: UInt)
36+
37+ @Throws(SerializationError::class)
38+ fun serialize_u64(value: ULong)
39+
40+ @Throws(SerializationError::class)
41+ fun serialize_u128(value: UInt128)
42+
43+ @Throws(SerializationError::class)
44+ fun serialize_i8(value: Byte)
45+
46+ @Throws(SerializationError::class)
47+ fun serialize_i16(value: Short)
48+
49+ @Throws(SerializationError::class)
50+ fun serialize_i32(value: Int)
51+
52+ @Throws(SerializationError::class)
53+ fun serialize_i64(value: Long)
54+
55+ @Throws(SerializationError::class)
56+ fun serialize_i128(value: Int128)
57+
58+ @Throws(SerializationError::class)
59+ fun serialize_len(value: Long)
60+
61+ @Throws(SerializationError::class)
62+ fun serialize_variant_index(value: Int)
63+
64+ @Throws(SerializationError::class)
65+ fun serialize_option_tag(value: Boolean)
66+
67+ @Throws(SerializationError::class)
68+ fun increase_container_depth()
69+
70+ fun decrease_container_depth()
71+
72+ fun get_buffer_offset(): Int
73+
74+ fun sort_map_entries(offsets: IntArray)
75+
76+ fun get_bytes(): ByteArray
77+}
generated/com/novi/serde/Slice.ktnew file+36
@@ -0,0 +1,36 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Slice(val start: Int, val end: Int) {
7+ companion object {
8+ fun compare_bytes(content: ByteArray, slice1: Slice, slice2: Slice): Int {
9+ val start1 = slice1.start
10+ val end1 = slice1.end
11+ val start2 = slice2.start
12+ val end2 = slice2.end
13+ var i = 0
14+ while (i < end1 - start1) {
15+ val index1 = start1 + i
16+ val index2 = start2 + i
17+ val byte1 = content[index1].toInt() and 0xff
18+ if (index2 >= end2) {
19+ return 1
20+ }
21+ val byte2 = content[index2].toInt() and 0xff
22+ if (byte1 > byte2) {
23+ return 1
24+ }
25+ if (byte1 < byte2) {
26+ return -1
27+ }
28+ i += 1
29+ }
30+ if (end2 - start2 > end1 - start1) {
31+ return -1
32+ }
33+ return 0
34+ }
35+ }
36+}
generated/com/novi/serde/Tuple4.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple4<T0, T1, T2, T3>(val field0: T0, val field1: T1, val field2: T2, val field3: T3)
generated/com/novi/serde/Tuple5.ktnew file+12
@@ -0,0 +1,12 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple5<T0, T1, T2, T3, T4>(
7+ val field0: T0,
8+ val field1: T1,
9+ val field2: T2,
10+ val field3: T3,
11+ val field4: T4
12+)
generated/com/novi/serde/Tuple6.ktnew file+13
@@ -0,0 +1,13 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple6<T0, T1, T2, T3, T4, T5>(
7+ val field0: T0,
8+ val field1: T1,
9+ val field2: T2,
10+ val field3: T3,
11+ val field4: T4,
12+ val field5: T5
13+)
generated/com/novi/serde/UInt128.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class UInt128(val high: ULong, val low: ULong)
generated/soy/iko/opencode/core/Core.ktnew file+1146
@@ -0,0 +1,1146 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.bincode.BincodeDeserializer
4+import com.novi.bincode.BincodeSerializer
5+import com.novi.serde.Bytes
6+import com.novi.serde.DeserializationError
7+import com.novi.serde.Deserializer
8+import com.novi.serde.Serializer
9+
10+fun <T> List<T>.serialize(
11+ serializer: Serializer,
12+ serializeElement: Serializer.(T) -> Unit,
13+) {
14+ serializer.serialize_len(size.toLong())
15+ forEach { element ->
16+ serializer.serializeElement(element)
17+ }
18+}
19+
20+fun <T> Deserializer.deserializeListOf(deserializeElement: (Deserializer) -> T): List<T> {
21+ val length = deserialize_len()
22+ val list = mutableListOf<T>()
23+ repeat(length.toInt()) {
24+ list.add(deserializeElement(this))
25+ }
26+ return list
27+}
28+
29+fun <T> T?.serializeOptionOf(
30+ serializer: Serializer,
31+ serializeElement: Serializer.(T) -> Unit,
32+) {
33+ if (this != null) {
34+ serializer.serialize_option_tag(true)
35+ serializer.serializeElement(this)
36+ } else {
37+ serializer.serialize_option_tag(false)
38+ }
39+}
40+
41+fun <T> Deserializer.deserializeOptionOf(deserializeElement: (Deserializer) -> T): T? {
42+ val tag = deserialize_option_tag()
43+ return if (tag) {
44+ deserializeElement(this)
45+ } else {
46+ null
47+ }
48+}
49+
50+sealed interface Effect {
51+ fun serialize(serializer: Serializer)
52+
53+ fun bincodeSerialize(): ByteArray {
54+ val serializer = BincodeSerializer()
55+ serialize(serializer)
56+ return serializer.get_bytes()
57+ }
58+
59+ data class Render(
60+ val value: soy.iko.opencode.core.RenderOperation,
61+ ) : Effect {
62+ override fun serialize(serializer: Serializer) {
63+ serializer.increase_container_depth()
64+ serializer.serialize_variant_index(0)
65+ value.serialize(serializer)
66+ serializer.decrease_container_depth()
67+ }
68+
69+ companion object {
70+ fun deserialize(deserializer: Deserializer): Render {
71+ deserializer.increase_container_depth()
72+ val value = soy.iko.opencode.core.RenderOperation.deserialize(deserializer)
73+ deserializer.decrease_container_depth()
74+ return Render(value)
75+ }
76+ }
77+ }
78+
79+ data class Http(
80+ val value: soy.iko.opencode.core.HttpRequest,
81+ ) : Effect {
82+ override fun serialize(serializer: Serializer) {
83+ serializer.increase_container_depth()
84+ serializer.serialize_variant_index(1)
85+ value.serialize(serializer)
86+ serializer.decrease_container_depth()
87+ }
88+
89+ companion object {
90+ fun deserialize(deserializer: Deserializer): Http {
91+ deserializer.increase_container_depth()
92+ val value = soy.iko.opencode.core.HttpRequest.deserialize(deserializer)
93+ deserializer.decrease_container_depth()
94+ return Http(value)
95+ }
96+ }
97+ }
98+
99+ companion object {
100+ @Throws(DeserializationError::class)
101+ fun deserialize(deserializer: Deserializer): Effect {
102+ val index = deserializer.deserialize_variant_index()
103+ return when (index) {
104+ 0 -> Render.deserialize(deserializer)
105+ 1 -> Http.deserialize(deserializer)
106+ else -> throw DeserializationError("Unknown variant index for Effect: $index")
107+ }
108+ }
109+
110+ @Throws(DeserializationError::class)
111+ fun bincodeDeserialize(input: ByteArray?): Effect {
112+ if (input == null) {
113+ throw DeserializationError("Cannot deserialize null array")
114+ }
115+ val deserializer = BincodeDeserializer(input)
116+ val value = deserialize(deserializer)
117+ if (deserializer.get_buffer_offset() < input.size) {
118+ throw DeserializationError("Some input bytes were not read")
119+ }
120+ return value
121+ }
122+ }
123+}
124+
125+sealed interface Event {
126+ fun serialize(serializer: Serializer)
127+
128+ fun bincodeSerialize(): ByteArray {
129+ val serializer = BincodeSerializer()
130+ serialize(serializer)
131+ return serializer.get_bytes()
132+ }
133+
134+ data object Start: Event {
135+ override fun serialize(serializer: Serializer) {
136+ serializer.increase_container_depth()
137+ serializer.serialize_variant_index(0)
138+ serializer.decrease_container_depth()
139+ }
140+
141+ fun deserialize(deserializer: Deserializer): Start {
142+ return Start
143+ }
144+ }
145+
146+ data class ServerUrlChanged(
147+ val value: String,
148+ ) : Event {
149+ override fun serialize(serializer: Serializer) {
150+ serializer.increase_container_depth()
151+ serializer.serialize_variant_index(1)
152+ serializer.serialize_str(value)
153+ serializer.decrease_container_depth()
154+ }
155+
156+ companion object {
157+ fun deserialize(deserializer: Deserializer): ServerUrlChanged {
158+ deserializer.increase_container_depth()
159+ val value = deserializer.deserialize_str()
160+ deserializer.decrease_container_depth()
161+ return ServerUrlChanged(value)
162+ }
163+ }
164+ }
165+
166+ data class UsernameChanged(
167+ val value: String,
168+ ) : Event {
169+ override fun serialize(serializer: Serializer) {
170+ serializer.increase_container_depth()
171+ serializer.serialize_variant_index(2)
172+ serializer.serialize_str(value)
173+ serializer.decrease_container_depth()
174+ }
175+
176+ companion object {
177+ fun deserialize(deserializer: Deserializer): UsernameChanged {
178+ deserializer.increase_container_depth()
179+ val value = deserializer.deserialize_str()
180+ deserializer.decrease_container_depth()
181+ return UsernameChanged(value)
182+ }
183+ }
184+ }
185+
186+ data class PasswordChanged(
187+ val value: String,
188+ ) : Event {
189+ override fun serialize(serializer: Serializer) {
190+ serializer.increase_container_depth()
191+ serializer.serialize_variant_index(3)
192+ serializer.serialize_str(value)
193+ serializer.decrease_container_depth()
194+ }
195+
196+ companion object {
197+ fun deserialize(deserializer: Deserializer): PasswordChanged {
198+ deserializer.increase_container_depth()
199+ val value = deserializer.deserialize_str()
200+ deserializer.decrease_container_depth()
201+ return PasswordChanged(value)
202+ }
203+ }
204+ }
205+
206+ data object Connect: Event {
207+ override fun serialize(serializer: Serializer) {
208+ serializer.increase_container_depth()
209+ serializer.serialize_variant_index(4)
210+ serializer.decrease_container_depth()
211+ }
212+
213+ fun deserialize(deserializer: Deserializer): Connect {
214+ return Connect
215+ }
216+ }
217+
218+ data object CancelAuth: Event {
219+ override fun serialize(serializer: Serializer) {
220+ serializer.increase_container_depth()
221+ serializer.serialize_variant_index(5)
222+ serializer.decrease_container_depth()
223+ }
224+
225+ fun deserialize(deserializer: Deserializer): CancelAuth {
226+ return CancelAuth
227+ }
228+ }
229+
230+ data object LoadSessions: Event {
231+ override fun serialize(serializer: Serializer) {
232+ serializer.increase_container_depth()
233+ serializer.serialize_variant_index(6)
234+ serializer.decrease_container_depth()
235+ }
236+
237+ fun deserialize(deserializer: Deserializer): LoadSessions {
238+ return LoadSessions
239+ }
240+ }
241+
242+ data class SelectSession(
243+ val value: String,
244+ ) : Event {
245+ override fun serialize(serializer: Serializer) {
246+ serializer.increase_container_depth()
247+ serializer.serialize_variant_index(7)
248+ serializer.serialize_str(value)
249+ serializer.decrease_container_depth()
250+ }
251+
252+ companion object {
253+ fun deserialize(deserializer: Deserializer): SelectSession {
254+ deserializer.increase_container_depth()
255+ val value = deserializer.deserialize_str()
256+ deserializer.decrease_container_depth()
257+ return SelectSession(value)
258+ }
259+ }
260+ }
261+
262+ data object CreateSession: Event {
263+ override fun serialize(serializer: Serializer) {
264+ serializer.increase_container_depth()
265+ serializer.serialize_variant_index(8)
266+ serializer.decrease_container_depth()
267+ }
268+
269+ fun deserialize(deserializer: Deserializer): CreateSession {
270+ return CreateSession
271+ }
272+ }
273+
274+ data class LoadMessages(
275+ val value: String,
276+ ) : Event {
277+ override fun serialize(serializer: Serializer) {
278+ serializer.increase_container_depth()
279+ serializer.serialize_variant_index(9)
280+ serializer.serialize_str(value)
281+ serializer.decrease_container_depth()
282+ }
283+
284+ companion object {
285+ fun deserialize(deserializer: Deserializer): LoadMessages {
286+ deserializer.increase_container_depth()
287+ val value = deserializer.deserialize_str()
288+ deserializer.decrease_container_depth()
289+ return LoadMessages(value)
290+ }
291+ }
292+ }
293+
294+ data class SendMessage(
295+ val value: String,
296+ ) : Event {
297+ override fun serialize(serializer: Serializer) {
298+ serializer.increase_container_depth()
299+ serializer.serialize_variant_index(10)
300+ serializer.serialize_str(value)
301+ serializer.decrease_container_depth()
302+ }
303+
304+ companion object {
305+ fun deserialize(deserializer: Deserializer): SendMessage {
306+ deserializer.increase_container_depth()
307+ val value = deserializer.deserialize_str()
308+ deserializer.decrease_container_depth()
309+ return SendMessage(value)
310+ }
311+ }
312+ }
313+
314+ data class EventReceived(
315+ val value: String,
316+ ) : Event {
317+ override fun serialize(serializer: Serializer) {
318+ serializer.increase_container_depth()
319+ serializer.serialize_variant_index(11)
320+ serializer.serialize_str(value)
321+ serializer.decrease_container_depth()
322+ }
323+
324+ companion object {
325+ fun deserialize(deserializer: Deserializer): EventReceived {
326+ deserializer.increase_container_depth()
327+ val value = deserializer.deserialize_str()
328+ deserializer.decrease_container_depth()
329+ return EventReceived(value)
330+ }
331+ }
332+ }
333+
334+ data class NavigateToChat(
335+ val value: String,
336+ ) : Event {
337+ override fun serialize(serializer: Serializer) {
338+ serializer.increase_container_depth()
339+ serializer.serialize_variant_index(12)
340+ serializer.serialize_str(value)
341+ serializer.decrease_container_depth()
342+ }
343+
344+ companion object {
345+ fun deserialize(deserializer: Deserializer): NavigateToChat {
346+ deserializer.increase_container_depth()
347+ val value = deserializer.deserialize_str()
348+ deserializer.decrease_container_depth()
349+ return NavigateToChat(value)
350+ }
351+ }
352+ }
353+
354+ data object NavigateToSessions: Event {
355+ override fun serialize(serializer: Serializer) {
356+ serializer.increase_container_depth()
357+ serializer.serialize_variant_index(13)
358+ serializer.decrease_container_depth()
359+ }
360+
361+ fun deserialize(deserializer: Deserializer): NavigateToSessions {
362+ return NavigateToSessions
363+ }
364+ }
365+
366+ data object DismissError: Event {
367+ override fun serialize(serializer: Serializer) {
368+ serializer.increase_container_depth()
369+ serializer.serialize_variant_index(14)
370+ serializer.decrease_container_depth()
371+ }
372+
373+ fun deserialize(deserializer: Deserializer): DismissError {
374+ return DismissError
375+ }
376+ }
377+
378+ companion object {
379+ @Throws(DeserializationError::class)
380+ fun deserialize(deserializer: Deserializer): Event {
381+ val index = deserializer.deserialize_variant_index()
382+ return when (index) {
383+ 0 -> Start.deserialize(deserializer)
384+ 1 -> ServerUrlChanged.deserialize(deserializer)
385+ 2 -> UsernameChanged.deserialize(deserializer)
386+ 3 -> PasswordChanged.deserialize(deserializer)
387+ 4 -> Connect.deserialize(deserializer)
388+ 5 -> CancelAuth.deserialize(deserializer)
389+ 6 -> LoadSessions.deserialize(deserializer)
390+ 7 -> SelectSession.deserialize(deserializer)
391+ 8 -> CreateSession.deserialize(deserializer)
392+ 9 -> LoadMessages.deserialize(deserializer)
393+ 10 -> SendMessage.deserialize(deserializer)
394+ 11 -> EventReceived.deserialize(deserializer)
395+ 12 -> NavigateToChat.deserialize(deserializer)
396+ 13 -> NavigateToSessions.deserialize(deserializer)
397+ 14 -> DismissError.deserialize(deserializer)
398+ else -> throw DeserializationError("Unknown variant index for Event: $index")
399+ }
400+ }
401+
402+ @Throws(DeserializationError::class)
403+ fun bincodeDeserialize(input: ByteArray?): Event {
404+ if (input == null) {
405+ throw DeserializationError("Cannot deserialize null array")
406+ }
407+ val deserializer = BincodeDeserializer(input)
408+ val value = deserialize(deserializer)
409+ if (deserializer.get_buffer_offset() < input.size) {
410+ throw DeserializationError("Some input bytes were not read")
411+ }
412+ return value
413+ }
414+ }
415+}
416+
417+/// An error produced when an HTTP request fails.
418+///
419+/// Variants fall into two groups:
420+///
421+/// **Transport errors** — generated by the shell when it cannot complete the HTTP
422+/// exchange. These cross the FFI boundary and are serialized in the protocol:
423+/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
424+///
425+/// **Processing errors** — generated on the Rust side after a response arrives.
426+/// These are never serialized or visible to shells:
427+///
428+/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
429+/// a 4xx or 5xx status. At the *protocol* level these arrive as
430+/// [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
431+/// them here, so app code using `crux_http::Result<Response<T>>` will see them as
432+/// `Err(HttpError::Http { code, .. })`.
433+/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
434+sealed interface HttpError {
435+ fun serialize(serializer: Serializer)
436+
437+ fun bincodeSerialize(): ByteArray {
438+ val serializer = BincodeSerializer()
439+ serialize(serializer)
440+ return serializer.get_bytes()
441+ }
442+
443+ /// The request URL could not be parsed.
444+ data class Url(
445+ val value: String,
446+ ) : HttpError {
447+ override fun serialize(serializer: Serializer) {
448+ serializer.increase_container_depth()
449+ serializer.serialize_variant_index(0)
450+ serializer.serialize_str(value)
451+ serializer.decrease_container_depth()
452+ }
453+
454+ companion object {
455+ fun deserialize(deserializer: Deserializer): Url {
456+ deserializer.increase_container_depth()
457+ val value = deserializer.deserialize_str()
458+ deserializer.decrease_container_depth()
459+ return Url(value)
460+ }
461+ }
462+ }
463+
464+ /// An IO error prevented the request from completing.
465+ data class Io(
466+ val value: String,
467+ ) : HttpError {
468+ override fun serialize(serializer: Serializer) {
469+ serializer.increase_container_depth()
470+ serializer.serialize_variant_index(1)
471+ serializer.serialize_str(value)
472+ serializer.decrease_container_depth()
473+ }
474+
475+ companion object {
476+ fun deserialize(deserializer: Deserializer): Io {
477+ deserializer.increase_container_depth()
478+ val value = deserializer.deserialize_str()
479+ deserializer.decrease_container_depth()
480+ return Io(value)
481+ }
482+ }
483+ }
484+
485+ /// The request timed out before a response was received.
486+ data object Timeout: HttpError {
487+ override fun serialize(serializer: Serializer) {
488+ serializer.increase_container_depth()
489+ serializer.serialize_variant_index(2)
490+ serializer.decrease_container_depth()
491+ }
492+
493+ fun deserialize(deserializer: Deserializer): Timeout {
494+ return Timeout
495+ }
496+ }
497+
498+ companion object {
499+ @Throws(DeserializationError::class)
500+ fun deserialize(deserializer: Deserializer): HttpError {
501+ val index = deserializer.deserialize_variant_index()
502+ return when (index) {
503+ 0 -> Url.deserialize(deserializer)
504+ 1 -> Io.deserialize(deserializer)
505+ 2 -> Timeout.deserialize(deserializer)
506+ else -> throw DeserializationError("Unknown variant index for HttpError: $index")
507+ }
508+ }
509+
510+ @Throws(DeserializationError::class)
511+ fun bincodeDeserialize(input: ByteArray?): HttpError {
512+ if (input == null) {
513+ throw DeserializationError("Cannot deserialize null array")
514+ }
515+ val deserializer = BincodeDeserializer(input)
516+ val value = deserialize(deserializer)
517+ if (deserializer.get_buffer_offset() < input.size) {
518+ throw DeserializationError("Some input bytes were not read")
519+ }
520+ return value
521+ }
522+ }
523+}
524+
525+data class HttpHeader(
526+ val name: String,
527+ val value: String,
528+) {
529+ fun serialize(serializer: Serializer) {
530+ serializer.increase_container_depth()
531+ serializer.serialize_str(name)
532+ serializer.serialize_str(value)
533+ serializer.decrease_container_depth()
534+ }
535+
536+ fun bincodeSerialize(): ByteArray {
537+ val serializer = BincodeSerializer()
538+ serialize(serializer)
539+ return serializer.get_bytes()
540+ }
541+
542+ companion object {
543+ fun deserialize(deserializer: Deserializer): HttpHeader {
544+ deserializer.increase_container_depth()
545+ val name = deserializer.deserialize_str()
546+ val value = deserializer.deserialize_str()
547+ deserializer.decrease_container_depth()
548+ return HttpHeader(name, value)
549+ }
550+
551+ @Throws(DeserializationError::class)
552+ fun bincodeDeserialize(input: ByteArray?): HttpHeader {
553+ if (input == null) {
554+ throw DeserializationError("Cannot deserialize null array")
555+ }
556+ val deserializer = BincodeDeserializer(input)
557+ val value = deserialize(deserializer)
558+ if (deserializer.get_buffer_offset() < input.size) {
559+ throw DeserializationError("Some input bytes were not read")
560+ }
561+ return value
562+ }
563+ }
564+}
565+
566+/// A raw HTTP request, as sent to the shell over the protocol boundary.
567+///
568+/// # No header validation
569+///
570+/// All fields are plain strings. Header names and values are carried as-is with no
571+/// validation against the HTTP specification. This is intentional: `HttpRequest` is a
572+/// cross-language data carrier deserialised by Swift, Kotlin, and TypeScript shells;
573+/// Rust's `http`-crate validation rules cannot be enforced on the other side of that
574+/// boundary.
575+///
576+/// **Shell authors must not assume that header names or values are well-formed.**
577+/// Pass them to your underlying HTTP client as-is — it will apply its own rules.
578+///
579+/// For the ergonomic Rust-side builder that *does* validate header values (and panics
580+/// on invalid input), see [`crate::command::RequestBuilder`].
581+data class HttpRequest(
582+ val method: String,
583+ val url: String,
584+ val headers: List<soy.iko.opencode.core.HttpHeader>,
585+ val body: Bytes,
586+) {
587+ fun serialize(serializer: Serializer) {
588+ serializer.increase_container_depth()
589+ serializer.serialize_str(method)
590+ serializer.serialize_str(url)
591+ headers.serialize(serializer) {
592+ it.serialize(serializer)
593+ }
594+ serializer.serialize_bytes(body)
595+ serializer.decrease_container_depth()
596+ }
597+
598+ fun bincodeSerialize(): ByteArray {
599+ val serializer = BincodeSerializer()
600+ serialize(serializer)
601+ return serializer.get_bytes()
602+ }
603+
604+ companion object {
605+ fun deserialize(deserializer: Deserializer): HttpRequest {
606+ deserializer.increase_container_depth()
607+ val method = deserializer.deserialize_str()
608+ val url = deserializer.deserialize_str()
609+ val headers =
610+ deserializer.deserializeListOf {
611+ soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
612+ }
613+ val body = deserializer.deserialize_bytes()
614+ deserializer.decrease_container_depth()
615+ return HttpRequest(method, url, headers, body)
616+ }
617+
618+ @Throws(DeserializationError::class)
619+ fun bincodeDeserialize(input: ByteArray?): HttpRequest {
620+ if (input == null) {
621+ throw DeserializationError("Cannot deserialize null array")
622+ }
623+ val deserializer = BincodeDeserializer(input)
624+ val value = deserialize(deserializer)
625+ if (deserializer.get_buffer_offset() < input.size) {
626+ throw DeserializationError("Some input bytes were not read")
627+ }
628+ return value
629+ }
630+ }
631+}
632+
633+data class HttpResponse(
634+ val status: UShort,
635+ val headers: List<soy.iko.opencode.core.HttpHeader>,
636+ val body: Bytes,
637+) {
638+ fun serialize(serializer: Serializer) {
639+ serializer.increase_container_depth()
640+ serializer.serialize_u16(status)
641+ headers.serialize(serializer) {
642+ it.serialize(serializer)
643+ }
644+ serializer.serialize_bytes(body)
645+ serializer.decrease_container_depth()
646+ }
647+
648+ fun bincodeSerialize(): ByteArray {
649+ val serializer = BincodeSerializer()
650+ serialize(serializer)
651+ return serializer.get_bytes()
652+ }
653+
654+ companion object {
655+ fun deserialize(deserializer: Deserializer): HttpResponse {
656+ deserializer.increase_container_depth()
657+ val status = deserializer.deserialize_u16()
658+ val headers =
659+ deserializer.deserializeListOf {
660+ soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
661+ }
662+ val body = deserializer.deserialize_bytes()
663+ deserializer.decrease_container_depth()
664+ return HttpResponse(status, headers, body)
665+ }
666+
667+ @Throws(DeserializationError::class)
668+ fun bincodeDeserialize(input: ByteArray?): HttpResponse {
669+ if (input == null) {
670+ throw DeserializationError("Cannot deserialize null array")
671+ }
672+ val deserializer = BincodeDeserializer(input)
673+ val value = deserialize(deserializer)
674+ if (deserializer.get_buffer_offset() < input.size) {
675+ throw DeserializationError("Some input bytes were not read")
676+ }
677+ return value
678+ }
679+ }
680+}
681+
682+/// The result of an HTTP request, as returned by the shell over the protocol boundary.
683+///
684+/// # Status codes are not errors
685+///
686+/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
687+/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
688+/// reach the server at all) produce [`HttpResult::Err`].
689+///
690+/// To act on an error status, inspect [`HttpResponse::status`]:
691+///
692+/// ```
693+/// # use crux_http::protocol::{HttpResult, HttpResponse};
694+/// # use crux_http::HttpError;
695+/// # fn handle(result: HttpResult) {
696+/// match result {
697+/// HttpResult::Ok(response) if response.status == 200 => { /* success */ }
698+/// HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
699+/// HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
700+/// HttpResult::Ok(_) => { /* other status */ }
701+/// HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
702+/// }
703+/// # }
704+/// ```
705+sealed interface HttpResult {
706+ fun serialize(serializer: Serializer)
707+
708+ fun bincodeSerialize(): ByteArray {
709+ val serializer = BincodeSerializer()
710+ serialize(serializer)
711+ return serializer.get_bytes()
712+ }
713+
714+ /// The shell completed the HTTP exchange. The response may carry any status code,
715+ /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
716+ data class Ok(
717+ val value: soy.iko.opencode.core.HttpResponse,
718+ ) : HttpResult {
719+ override fun serialize(serializer: Serializer) {
720+ serializer.increase_container_depth()
721+ serializer.serialize_variant_index(0)
722+ value.serialize(serializer)
723+ serializer.decrease_container_depth()
724+ }
725+
726+ companion object {
727+ fun deserialize(deserializer: Deserializer): Ok {
728+ deserializer.increase_container_depth()
729+ val value = soy.iko.opencode.core.HttpResponse.deserialize(deserializer)
730+ deserializer.decrease_container_depth()
731+ return Ok(value)
732+ }
733+ }
734+ }
735+
736+ /// The shell could not complete the HTTP exchange due to a transport-level failure.
737+ /// See [`HttpError`] for the possible causes.
738+ data class Err(
739+ val value: soy.iko.opencode.core.HttpError,
740+ ) : HttpResult {
741+ override fun serialize(serializer: Serializer) {
742+ serializer.increase_container_depth()
743+ serializer.serialize_variant_index(1)
744+ value.serialize(serializer)
745+ serializer.decrease_container_depth()
746+ }
747+
748+ companion object {
749+ fun deserialize(deserializer: Deserializer): Err {
750+ deserializer.increase_container_depth()
751+ val value = soy.iko.opencode.core.HttpError.deserialize(deserializer)
752+ deserializer.decrease_container_depth()
753+ return Err(value)
754+ }
755+ }
756+ }
757+
758+ companion object {
759+ @Throws(DeserializationError::class)
760+ fun deserialize(deserializer: Deserializer): HttpResult {
761+ val index = deserializer.deserialize_variant_index()
762+ return when (index) {
763+ 0 -> Ok.deserialize(deserializer)
764+ 1 -> Err.deserialize(deserializer)
765+ else -> throw DeserializationError("Unknown variant index for HttpResult: $index")
766+ }
767+ }
768+
769+ @Throws(DeserializationError::class)
770+ fun bincodeDeserialize(input: ByteArray?): HttpResult {
771+ if (input == null) {
772+ throw DeserializationError("Cannot deserialize null array")
773+ }
774+ val deserializer = BincodeDeserializer(input)
775+ val value = deserialize(deserializer)
776+ if (deserializer.get_buffer_offset() < input.size) {
777+ throw DeserializationError("Some input bytes were not read")
778+ }
779+ return value
780+ }
781+ }
782+}
783+
784+data class MessageView(
785+ val id: String,
786+ val role: String,
787+ val text: String,
788+ val time: ULong,
789+) {
790+ fun serialize(serializer: Serializer) {
791+ serializer.increase_container_depth()
792+ serializer.serialize_str(id)
793+ serializer.serialize_str(role)
794+ serializer.serialize_str(text)
795+ serializer.serialize_u64(time)
796+ serializer.decrease_container_depth()
797+ }
798+
799+ fun bincodeSerialize(): ByteArray {
800+ val serializer = BincodeSerializer()
801+ serialize(serializer)
802+ return serializer.get_bytes()
803+ }
804+
805+ companion object {
806+ fun deserialize(deserializer: Deserializer): MessageView {
807+ deserializer.increase_container_depth()
808+ val id = deserializer.deserialize_str()
809+ val role = deserializer.deserialize_str()
810+ val text = deserializer.deserialize_str()
811+ val time = deserializer.deserialize_u64()
812+ deserializer.decrease_container_depth()
813+ return MessageView(id, role, text, time)
814+ }
815+
816+ @Throws(DeserializationError::class)
817+ fun bincodeDeserialize(input: ByteArray?): MessageView {
818+ if (input == null) {
819+ throw DeserializationError("Cannot deserialize null array")
820+ }
821+ val deserializer = BincodeDeserializer(input)
822+ val value = deserialize(deserializer)
823+ if (deserializer.get_buffer_offset() < input.size) {
824+ throw DeserializationError("Some input bytes were not read")
825+ }
826+ return value
827+ }
828+ }
829+}
830+
831+/// The single operation `Render` implements.
832+data object RenderOperation {
833+ fun serialize(serializer: Serializer) {}
834+
835+ fun bincodeSerialize(): ByteArray {
836+ val serializer = BincodeSerializer()
837+ serialize(serializer)
838+ return serializer.get_bytes()
839+ }
840+
841+ fun deserialize(deserializer: Deserializer): RenderOperation {
842+ return RenderOperation
843+ }
844+
845+ @Throws(DeserializationError::class)
846+ fun bincodeDeserialize(input: ByteArray?): RenderOperation {
847+ if (input == null) {
848+ throw DeserializationError("Cannot deserialize null array")
849+ }
850+ val deserializer = BincodeDeserializer(input)
851+ val value = deserialize(deserializer)
852+ if (deserializer.get_buffer_offset() < input.size) {
853+ throw DeserializationError("Some input bytes were not read")
854+ }
855+ return value
856+ }
857+}
858+
859+/// Request for a side-effect passed from the Core to the Shell.
860+///
861+/// The `EffectId` links the `Request` with the corresponding call to [`Core::resolve`] to pass the data back
862+/// to the [`App::update`] function (wrapped in the event provided to the capability originating the effect).
863+data class Request(
864+ val id: UInt,
865+ val effect: soy.iko.opencode.core.Effect,
866+) {
867+ fun serialize(serializer: Serializer) {
868+ serializer.increase_container_depth()
869+ serializer.serialize_u32(id)
870+ effect.serialize(serializer)
871+ serializer.decrease_container_depth()
872+ }
873+
874+ fun bincodeSerialize(): ByteArray {
875+ val serializer = BincodeSerializer()
876+ serialize(serializer)
877+ return serializer.get_bytes()
878+ }
879+
880+ companion object {
881+ fun deserialize(deserializer: Deserializer): Request {
882+ deserializer.increase_container_depth()
883+ val id = deserializer.deserialize_u32()
884+ val effect = soy.iko.opencode.core.Effect.deserialize(deserializer)
885+ deserializer.decrease_container_depth()
886+ return Request(id, effect)
887+ }
888+
889+ @Throws(DeserializationError::class)
890+ fun bincodeDeserialize(input: ByteArray?): Request {
891+ if (input == null) {
892+ throw DeserializationError("Cannot deserialize null array")
893+ }
894+ val deserializer = BincodeDeserializer(input)
895+ val value = deserialize(deserializer)
896+ if (deserializer.get_buffer_offset() < input.size) {
897+ throw DeserializationError("Some input bytes were not read")
898+ }
899+ return value
900+ }
901+ }
902+}
903+
904+/// A batch of effect requests from the Core to the Shell, as serialised by
905+/// [`Bridge::update`] and [`Bridge::resolve`].
906+///
907+/// The wire format is identical to `Vec<Request<Eff>>` (the newtype is
908+/// `serde(transparent)`), so existing shell code that already deserialises
909+/// a `Vec<Request>` remains binary-compatible.
910+///
911+/// Registering this type with the type-generation system causes the code
912+/// generators to emit a `Requests` type (with a `value` field containing the
913+/// list) together with a top-level `bincodeDeserialize` / `BincodeDeserialize`
914+/// helper, replacing the hand-written extension files that were previously
915+/// appended by `add_extensions()`.
916+data class Requests(
917+ val value: List<soy.iko.opencode.core.Request>,
918+) {
919+ fun serialize(serializer: Serializer) {
920+ serializer.increase_container_depth()
921+ value.serialize(serializer) {
922+ it.serialize(serializer)
923+ }
924+ serializer.decrease_container_depth()
925+ }
926+
927+ fun bincodeSerialize(): ByteArray {
928+ val serializer = BincodeSerializer()
929+ serialize(serializer)
930+ return serializer.get_bytes()
931+ }
932+
933+ companion object {
934+ fun deserialize(deserializer: Deserializer): Requests {
935+ deserializer.increase_container_depth()
936+ val value =
937+ deserializer.deserializeListOf {
938+ soy.iko.opencode.core.Request.deserialize(deserializer)
939+ }
940+ deserializer.decrease_container_depth()
941+ return Requests(value)
942+ }
943+
944+ @Throws(DeserializationError::class)
945+ fun bincodeDeserialize(input: ByteArray?): Requests {
946+ if (input == null) {
947+ throw DeserializationError("Cannot deserialize null array")
948+ }
949+ val deserializer = BincodeDeserializer(input)
950+ val value = deserialize(deserializer)
951+ if (deserializer.get_buffer_offset() < input.size) {
952+ throw DeserializationError("Some input bytes were not read")
953+ }
954+ return value
955+ }
956+ }
957+}
958+
959+enum class Screen {
960+ CONNECT,
961+ SESSIONS,
962+ CHAT;
963+
964+ fun serialize(serializer: Serializer) {
965+ serializer.increase_container_depth()
966+ serializer.serialize_variant_index(ordinal)
967+ serializer.decrease_container_depth()
968+ }
969+
970+ fun bincodeSerialize(): ByteArray {
971+ val serializer = BincodeSerializer()
972+ serialize(serializer)
973+ return serializer.get_bytes()
974+ }
975+
976+ companion object {
977+ @Throws(DeserializationError::class)
978+ fun deserialize(deserializer: Deserializer): Screen {
979+ deserializer.increase_container_depth()
980+ val index = deserializer.deserialize_variant_index()
981+ deserializer.decrease_container_depth()
982+ return when (index) {
983+ 0 -> CONNECT
984+ 1 -> SESSIONS
985+ 2 -> CHAT
986+ else -> throw DeserializationError("Unknown variant index for Screen: $index")
987+ }
988+ }
989+
990+ @Throws(DeserializationError::class)
991+ fun bincodeDeserialize(input: ByteArray?): Screen {
992+ if (input == null) {
993+ throw DeserializationError("Cannot deserialize null array")
994+ }
995+ val deserializer = BincodeDeserializer(input)
996+ val value = deserialize(deserializer)
997+ if (deserializer.get_buffer_offset() < input.size) {
998+ throw DeserializationError("Some input bytes were not read")
999+ }
1000+ return value
1001+ }
1002+ }
1003+}
1004+
1005+data class SessionView(
1006+ val id: String,
1007+ val title: String,
1008+) {
1009+ fun serialize(serializer: Serializer) {
1010+ serializer.increase_container_depth()
1011+ serializer.serialize_str(id)
1012+ serializer.serialize_str(title)
1013+ serializer.decrease_container_depth()
1014+ }
1015+
1016+ fun bincodeSerialize(): ByteArray {
1017+ val serializer = BincodeSerializer()
1018+ serialize(serializer)
1019+ return serializer.get_bytes()
1020+ }
1021+
1022+ companion object {
1023+ fun deserialize(deserializer: Deserializer): SessionView {
1024+ deserializer.increase_container_depth()
1025+ val id = deserializer.deserialize_str()
1026+ val title = deserializer.deserialize_str()
1027+ deserializer.decrease_container_depth()
1028+ return SessionView(id, title)
1029+ }
1030+
1031+ @Throws(DeserializationError::class)
1032+ fun bincodeDeserialize(input: ByteArray?): SessionView {
1033+ if (input == null) {
1034+ throw DeserializationError("Cannot deserialize null array")
1035+ }
1036+ val deserializer = BincodeDeserializer(input)
1037+ val value = deserialize(deserializer)
1038+ if (deserializer.get_buffer_offset() < input.size) {
1039+ throw DeserializationError("Some input bytes were not read")
1040+ }
1041+ return value
1042+ }
1043+ }
1044+}
1045+
1046+data class ViewModel(
1047+ val screen: soy.iko.opencode.core.Screen,
1048+ val serverUrl: String,
1049+ val username: String,
1050+ val password: String,
1051+ val authRequired: Boolean,
1052+ val connected: Boolean,
1053+ val loading: Boolean,
1054+ val error: String? = null,
1055+ val sessions: List<soy.iko.opencode.core.SessionView>,
1056+ val currentSessionId: String? = null,
1057+ val messages: List<soy.iko.opencode.core.MessageView>,
1058+ val draftMessage: String,
1059+ val crashLogCount: UInt,
1060+ val latestCrashLog: String? = null,
1061+) {
1062+ fun serialize(serializer: Serializer) {
1063+ serializer.increase_container_depth()
1064+ screen.serialize(serializer)
1065+ serializer.serialize_str(serverUrl)
1066+ serializer.serialize_str(username)
1067+ serializer.serialize_str(password)
1068+ serializer.serialize_bool(authRequired)
1069+ serializer.serialize_bool(connected)
1070+ serializer.serialize_bool(loading)
1071+ error.serializeOptionOf(serializer) {
1072+ serializer.serialize_str(it)
1073+ }
1074+ sessions.serialize(serializer) {
1075+ it.serialize(serializer)
1076+ }
1077+ currentSessionId.serializeOptionOf(serializer) {
1078+ serializer.serialize_str(it)
1079+ }
1080+ messages.serialize(serializer) {
1081+ it.serialize(serializer)
1082+ }
1083+ serializer.serialize_str(draftMessage)
1084+ serializer.serialize_u32(crashLogCount)
1085+ latestCrashLog.serializeOptionOf(serializer) {
1086+ serializer.serialize_str(it)
1087+ }
1088+ serializer.decrease_container_depth()
1089+ }
1090+
1091+ fun bincodeSerialize(): ByteArray {
1092+ val serializer = BincodeSerializer()
1093+ serialize(serializer)
1094+ return serializer.get_bytes()
1095+ }
1096+
1097+ companion object {
1098+ fun deserialize(deserializer: Deserializer): ViewModel {
1099+ deserializer.increase_container_depth()
1100+ val screen = soy.iko.opencode.core.Screen.deserialize(deserializer)
1101+ val serverUrl = deserializer.deserialize_str()
1102+ val username = deserializer.deserialize_str()
1103+ val password = deserializer.deserialize_str()
1104+ val authRequired = deserializer.deserialize_bool()
1105+ val connected = deserializer.deserialize_bool()
1106+ val loading = deserializer.deserialize_bool()
1107+ val error =
1108+ deserializer.deserializeOptionOf {
1109+ deserializer.deserialize_str()
1110+ }
1111+ val sessions =
1112+ deserializer.deserializeListOf {
1113+ soy.iko.opencode.core.SessionView.deserialize(deserializer)
1114+ }
1115+ val currentSessionId =
1116+ deserializer.deserializeOptionOf {
1117+ deserializer.deserialize_str()
1118+ }
1119+ val messages =
1120+ deserializer.deserializeListOf {
1121+ soy.iko.opencode.core.MessageView.deserialize(deserializer)
1122+ }
1123+ val draftMessage = deserializer.deserialize_str()
1124+ val crashLogCount = deserializer.deserialize_u32()
1125+ val latestCrashLog =
1126+ deserializer.deserializeOptionOf {
1127+ deserializer.deserialize_str()
1128+ }
1129+ deserializer.decrease_container_depth()
1130+ return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, messages, draftMessage, crashLogCount, latestCrashLog)
1131+ }
1132+
1133+ @Throws(DeserializationError::class)
1134+ fun bincodeDeserialize(input: ByteArray?): ViewModel {
1135+ if (input == null) {
1136+ throw DeserializationError("Cannot deserialize null array")
1137+ }
1138+ val deserializer = BincodeDeserializer(input)
1139+ val value = deserialize(deserializer)
1140+ if (deserializer.get_buffer_offset() < input.size) {
1141+ throw DeserializationError("Some input bytes were not read")
1142+ }
1143+ return value
1144+ }
1145+ }
1146+}
gradle.propertiesnew file+4
@@ -0,0 +1,4 @@
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+android.useAndroidX=true
3+kotlin.code.style=official
4+android.nonTransitiveRClass=true
gradle/libs.versions.tomlnew file+49
@@ -0,0 +1,49 @@
1+[versions]
2+agp = "8.13.2"
3+kotlin = "2.3.0"
4+coreKtx = "1.17.0"
5+junit = "4.13.2"
6+junitVersion = "1.3.0"
7+espressoCore = "3.7.0"
8+lifecycleRuntimeKtx = "2.10.0"
9+activityCompose = "1.12.3"
10+composeBom = "2026.01.01"
11+lifecycle = "2.10.0"
12+ktor = "3.1.3"
13+kotlinxCoroutines = "1.10.2"
14+navigationCompose = "2.9.8"
15+[libraries]
16+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
17+junit = { group = "junit", name = "junit", version.ref = "junit" }
18+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
19+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
20+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
21+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
22+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
23+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
24+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
25+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
26+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
27+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
28+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
29+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
30+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
31+lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
32+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
33+
34+ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
35+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
36+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
37+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
38+ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
39+ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
40+
41+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
42+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
43+
44+[plugins]
45+android-application = { id = "com.android.application", version.ref = "agp" }
46+android-library = { id = "com.android.library", version.ref = "agp" }
47+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
48+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
49+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", 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+25
@@ -0,0 +1,25 @@
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")
25+include(":shared")
shared/Cargo.locknew file+1716
@@ -0,0 +1,1716 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "aho-corasick"
7+version = "1.1.4"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10+dependencies = [
11+ "memchr",
12+]
13+
14+[[package]]
15+name = "allocator-api2"
16+version = "0.2.21"
17+source = "registry+https://github.com/rust-lang/crates.io-index"
18+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19+
20+[[package]]
21+name = "anstream"
22+version = "1.0.0"
23+source = "registry+https://github.com/rust-lang/crates.io-index"
24+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
25+dependencies = [
26+ "anstyle",
27+ "anstyle-parse",
28+ "anstyle-query",
29+ "anstyle-wincon",
30+ "colorchoice",
31+ "is_terminal_polyfill",
32+ "utf8parse",
33+]
34+
35+[[package]]
36+name = "anstyle"
37+version = "1.0.14"
38+source = "registry+https://github.com/rust-lang/crates.io-index"
39+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
40+
41+[[package]]
42+name = "anstyle-parse"
43+version = "1.0.0"
44+source = "registry+https://github.com/rust-lang/crates.io-index"
45+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
46+dependencies = [
47+ "utf8parse",
48+]
49+
50+[[package]]
51+name = "anstyle-query"
52+version = "1.1.5"
53+source = "registry+https://github.com/rust-lang/crates.io-index"
54+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
55+dependencies = [
56+ "windows-sys",
57+]
58+
59+[[package]]
60+name = "anstyle-wincon"
61+version = "3.0.11"
62+source = "registry+https://github.com/rust-lang/crates.io-index"
63+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
64+dependencies = [
65+ "anstyle",
66+ "once_cell_polyfill",
67+ "windows-sys",
68+]
69+
70+[[package]]
71+name = "anyhow"
72+version = "1.0.103"
73+source = "registry+https://github.com/rust-lang/crates.io-index"
74+checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
75+
76+[[package]]
77+name = "async-trait"
78+version = "0.1.89"
79+source = "registry+https://github.com/rust-lang/crates.io-index"
80+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
81+dependencies = [
82+ "proc-macro2",
83+ "quote",
84+ "syn 2.0.118",
85+]
86+
87+[[package]]
88+name = "autocfg"
89+version = "1.5.1"
90+source = "registry+https://github.com/rust-lang/crates.io-index"
91+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
92+
93+[[package]]
94+name = "base64"
95+version = "0.22.1"
96+source = "registry+https://github.com/rust-lang/crates.io-index"
97+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
98+
99+[[package]]
100+name = "bincode"
101+version = "1.3.3"
102+source = "registry+https://github.com/rust-lang/crates.io-index"
103+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
104+dependencies = [
105+ "serde",
106+]
107+
108+[[package]]
109+name = "boltffi"
110+version = "0.27.3"
111+source = "registry+https://github.com/rust-lang/crates.io-index"
112+checksum = "a52185e3efe73ae1e37d4a12ecfc51b03392b3bd6fa677b49bf4e6f4eb30d033"
113+dependencies = [
114+ "boltffi_core",
115+ "boltffi_macros",
116+]
117+
118+[[package]]
119+name = "boltffi_ast"
120+version = "0.27.3"
121+source = "registry+https://github.com/rust-lang/crates.io-index"
122+checksum = "447bff7f815366882b6c7152284cf60b35bda4ee4920c20f9ca702b3de5d517e"
123+dependencies = [
124+ "serde",
125+]
126+
127+[[package]]
128+name = "boltffi_binding"
129+version = "0.27.3"
130+source = "registry+https://github.com/rust-lang/crates.io-index"
131+checksum = "f162e2813a4168530ee144a411921c89b2148e2aedda2327b7f9c46cd57e50d0"
132+dependencies = [
133+ "boltffi_ast",
134+ "serde",
135+ "serde_json",
136+]
137+
138+[[package]]
139+name = "boltffi_core"
140+version = "0.27.3"
141+source = "registry+https://github.com/rust-lang/crates.io-index"
142+checksum = "f6df0e010773b88cf3888511de299425dce2ed0458c9f9d02f454e23590ac1e8"
143+dependencies = [
144+ "boltffi_macros",
145+ "url",
146+ "uuid",
147+]
148+
149+[[package]]
150+name = "boltffi_ffi_rules"
151+version = "0.27.3"
152+source = "registry+https://github.com/rust-lang/crates.io-index"
153+checksum = "8d72f6392c860c87c069c1b11c890d208cae4db143f72230d73ce53155ab2370"
154+dependencies = [
155+ "serde",
156+ "serde_json",
157+ "syn 2.0.118",
158+ "walkdir",
159+]
160+
161+[[package]]
162+name = "boltffi_macros"
163+version = "0.27.3"
164+source = "registry+https://github.com/rust-lang/crates.io-index"
165+checksum = "8b2f35b6e1b94218d5178916bdd59f0c358214c146f3ce189fc96ddaa019d435"
166+dependencies = [
167+ "boltffi_ast",
168+ "boltffi_binding",
169+ "boltffi_ffi_rules",
170+ "boltffi_scan",
171+ "proc-macro2",
172+ "quote",
173+ "syn 2.0.118",
174+]
175+
176+[[package]]
177+name = "boltffi_scan"
178+version = "0.27.3"
179+source = "registry+https://github.com/rust-lang/crates.io-index"
180+checksum = "4476cba632b69b8736ea499a463a8eddc6fd311f320f98601e338ddc0c9476a0"
181+dependencies = [
182+ "boltffi_ast",
183+ "proc-macro2",
184+ "quote",
185+ "serde",
186+ "serde_json",
187+ "syn 2.0.118",
188+ "walkdir",
189+]
190+
191+[[package]]
192+name = "bumpalo"
193+version = "3.20.3"
194+source = "registry+https://github.com/rust-lang/crates.io-index"
195+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
196+
197+[[package]]
198+name = "bytes"
199+version = "1.12.0"
200+source = "registry+https://github.com/rust-lang/crates.io-index"
201+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
202+
203+[[package]]
204+name = "cfg-if"
205+version = "1.0.4"
206+source = "registry+https://github.com/rust-lang/crates.io-index"
207+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
208+
209+[[package]]
210+name = "clap"
211+version = "4.6.1"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
214+dependencies = [
215+ "clap_builder",
216+ "clap_derive",
217+]
218+
219+[[package]]
220+name = "clap_builder"
221+version = "4.6.0"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
224+dependencies = [
225+ "anstream",
226+ "anstyle",
227+ "clap_lex",
228+ "strsim",
229+]
230+
231+[[package]]
232+name = "clap_derive"
233+version = "4.6.1"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
236+dependencies = [
237+ "heck 0.5.0",
238+ "proc-macro2",
239+ "quote",
240+ "syn 2.0.118",
241+]
242+
243+[[package]]
244+name = "clap_lex"
245+version = "1.1.0"
246+source = "registry+https://github.com/rust-lang/crates.io-index"
247+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
248+
249+[[package]]
250+name = "colorchoice"
251+version = "1.0.5"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
254+
255+[[package]]
256+name = "const-fnv1a-hash"
257+version = "1.1.0"
258+source = "registry+https://github.com/rust-lang/crates.io-index"
259+checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
260+
261+[[package]]
262+name = "crossbeam-channel"
263+version = "0.5.16"
264+source = "registry+https://github.com/rust-lang/crates.io-index"
265+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
266+dependencies = [
267+ "crossbeam-utils",
268+]
269+
270+[[package]]
271+name = "crossbeam-utils"
272+version = "0.8.22"
273+source = "registry+https://github.com/rust-lang/crates.io-index"
274+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
275+
276+[[package]]
277+name = "crux_core"
278+version = "0.19.0"
279+source = "registry+https://github.com/rust-lang/crates.io-index"
280+checksum = "159720bb44c1a09e0089459217c8ea6c71a78d868c5bf55d5b41f8781e92af7a"
281+dependencies = [
282+ "anyhow",
283+ "bincode",
284+ "crossbeam-channel",
285+ "crux_macros",
286+ "facet",
287+ "facet_generate",
288+ "futures",
289+ "include_dir 0.7.4",
290+ "log",
291+ "serde",
292+ "serde-generate",
293+ "serde-reflection",
294+ "serde_json",
295+ "slab",
296+ "thiserror 2.0.18",
297+]
298+
299+[[package]]
300+name = "crux_http"
301+version = "0.19.0"
302+source = "registry+https://github.com/rust-lang/crates.io-index"
303+checksum = "cb5aec17a4618dc5834f201aaa28f0ea4cad5a5f593714e74a43ff95cf171313"
304+dependencies = [
305+ "anyhow",
306+ "async-trait",
307+ "crux_core",
308+ "derive_builder",
309+ "encoding_rs",
310+ "facet",
311+ "facet-generate-attrs",
312+ "futures-util",
313+ "http",
314+ "mime",
315+ "pin-project-lite",
316+ "serde",
317+ "serde_bytes",
318+ "serde_json",
319+ "serde_qs",
320+ "thiserror 2.0.18",
321+ "url",
322+ "web-sys",
323+]
324+
325+[[package]]
326+name = "crux_macros"
327+version = "0.10.0"
328+source = "registry+https://github.com/rust-lang/crates.io-index"
329+checksum = "952c0b238735c79e917b908c140c7c5f6f6921924ebee21171203fe5fb5fc3f3"
330+dependencies = [
331+ "darling 0.23.0",
332+ "heck 0.5.0",
333+ "proc-macro-error",
334+ "proc-macro2",
335+ "quote",
336+ "syn 2.0.118",
337+]
338+
339+[[package]]
340+name = "darling"
341+version = "0.20.11"
342+source = "registry+https://github.com/rust-lang/crates.io-index"
343+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
344+dependencies = [
345+ "darling_core 0.20.11",
346+ "darling_macro 0.20.11",
347+]
348+
349+[[package]]
350+name = "darling"
351+version = "0.23.0"
352+source = "registry+https://github.com/rust-lang/crates.io-index"
353+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
354+dependencies = [
355+ "darling_core 0.23.0",
356+ "darling_macro 0.23.0",
357+]
358+
359+[[package]]
360+name = "darling_core"
361+version = "0.20.11"
362+source = "registry+https://github.com/rust-lang/crates.io-index"
363+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
364+dependencies = [
365+ "fnv",
366+ "ident_case",
367+ "proc-macro2",
368+ "quote",
369+ "strsim",
370+ "syn 2.0.118",
371+]
372+
373+[[package]]
374+name = "darling_core"
375+version = "0.23.0"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
378+dependencies = [
379+ "ident_case",
380+ "proc-macro2",
381+ "quote",
382+ "strsim",
383+ "syn 2.0.118",
384+]
385+
386+[[package]]
387+name = "darling_macro"
388+version = "0.20.11"
389+source = "registry+https://github.com/rust-lang/crates.io-index"
390+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
391+dependencies = [
392+ "darling_core 0.20.11",
393+ "quote",
394+ "syn 2.0.118",
395+]
396+
397+[[package]]
398+name = "darling_macro"
399+version = "0.23.0"
400+source = "registry+https://github.com/rust-lang/crates.io-index"
401+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
402+dependencies = [
403+ "darling_core 0.23.0",
404+ "quote",
405+ "syn 2.0.118",
406+]
407+
408+[[package]]
409+name = "derive_builder"
410+version = "0.20.2"
411+source = "registry+https://github.com/rust-lang/crates.io-index"
412+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
413+dependencies = [
414+ "derive_builder_macro",
415+]
416+
417+[[package]]
418+name = "derive_builder_core"
419+version = "0.20.2"
420+source = "registry+https://github.com/rust-lang/crates.io-index"
421+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
422+dependencies = [
423+ "darling 0.20.11",
424+ "proc-macro2",
425+ "quote",
426+ "syn 2.0.118",
427+]
428+
429+[[package]]
430+name = "derive_builder_macro"
431+version = "0.20.2"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
434+dependencies = [
435+ "derive_builder_core",
436+ "syn 2.0.118",
437+]
438+
439+[[package]]
440+name = "displaydoc"
441+version = "0.2.6"
442+source = "registry+https://github.com/rust-lang/crates.io-index"
443+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
444+dependencies = [
445+ "proc-macro2",
446+ "quote",
447+ "syn 2.0.118",
448+]
449+
450+[[package]]
451+name = "encoding_rs"
452+version = "0.8.35"
453+source = "registry+https://github.com/rust-lang/crates.io-index"
454+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
455+dependencies = [
456+ "cfg-if",
457+]
458+
459+[[package]]
460+name = "equivalent"
461+version = "1.0.2"
462+source = "registry+https://github.com/rust-lang/crates.io-index"
463+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
464+
465+[[package]]
466+name = "facet"
467+version = "0.44.5"
468+source = "registry+https://github.com/rust-lang/crates.io-index"
469+checksum = "c78066af2cc259674a54fef24323f6081ae54a5f3a3fb53b995af7a867041c81"
470+dependencies = [
471+ "autocfg",
472+ "facet-core",
473+ "facet-macros",
474+]
475+
476+[[package]]
477+name = "facet-core"
478+version = "0.44.4"
479+source = "registry+https://github.com/rust-lang/crates.io-index"
480+checksum = "04625a816bc27bb757b5f62ec17a228b90c08631f579ee0b36eccd6c426df207"
481+dependencies = [
482+ "autocfg",
483+ "const-fnv1a-hash",
484+ "iddqd",
485+ "impls",
486+]
487+
488+[[package]]
489+name = "facet-generate-attrs"
490+version = "0.17.0"
491+source = "registry+https://github.com/rust-lang/crates.io-index"
492+checksum = "3b430b495d59ffcf07dabe103a796d8d3ba809d12bb25a5960b605883835b3de"
493+dependencies = [
494+ "facet",
495+]
496+
497+[[package]]
498+name = "facet-macro-parse"
499+version = "0.44.4"
500+source = "registry+https://github.com/rust-lang/crates.io-index"
501+checksum = "a288cd230608b4d7e89b307f85be2029f265e27dcdc565a2ae5358d0c4e53f57"
502+dependencies = [
503+ "facet-macro-types",
504+ "proc-macro2",
505+ "quote",
506+]
507+
508+[[package]]
509+name = "facet-macro-types"
510+version = "0.44.4"
511+source = "registry+https://github.com/rust-lang/crates.io-index"
512+checksum = "9c805b7f1ad4bba14e1dba5c67ef810bf9eef2373364fe3916af1d08a5411296"
513+dependencies = [
514+ "proc-macro2",
515+ "quote",
516+ "unsynn",
517+]
518+
519+[[package]]
520+name = "facet-macros"
521+version = "0.44.4"
522+source = "registry+https://github.com/rust-lang/crates.io-index"
523+checksum = "e0cc46a94a2725e82f2c0095bac16665057c24d7f4f11169a7a879dbcba81d8f"
524+dependencies = [
525+ "facet-macros-impl",
526+]
527+
528+[[package]]
529+name = "facet-macros-impl"
530+version = "0.44.4"
531+source = "registry+https://github.com/rust-lang/crates.io-index"
532+checksum = "1af13fd5dfa728cc48418cf33cbcfc0d5cfdbaaf22a3d0f5229da4b1e5669fa5"
533+dependencies = [
534+ "facet-macro-parse",
535+ "facet-macro-types",
536+ "proc-macro2",
537+ "quote",
538+ "strsim",
539+ "unsynn",
540+]
541+
542+[[package]]
543+name = "facet_generate"
544+version = "0.17.2"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "4bab57b923bae6e204f5e47643054a9c5ad9c3bd9aadec66df741790de8a859f"
547+dependencies = [
548+ "derive_builder",
549+ "facet",
550+ "facet-generate-attrs",
551+ "heck 0.5.0",
552+ "include_dir 0.7.4",
553+ "indent",
554+ "indoc",
555+ "regex",
556+ "serde",
557+ "serde_json",
558+ "textwrap 0.16.2",
559+ "thiserror 2.0.18",
560+]
561+
562+[[package]]
563+name = "fnv"
564+version = "1.0.7"
565+source = "registry+https://github.com/rust-lang/crates.io-index"
566+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
567+
568+[[package]]
569+name = "foldhash"
570+version = "0.2.0"
571+source = "registry+https://github.com/rust-lang/crates.io-index"
572+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
573+
574+[[package]]
575+name = "form_urlencoded"
576+version = "1.2.2"
577+source = "registry+https://github.com/rust-lang/crates.io-index"
578+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
579+dependencies = [
580+ "percent-encoding",
581+]
582+
583+[[package]]
584+name = "futures"
585+version = "0.3.32"
586+source = "registry+https://github.com/rust-lang/crates.io-index"
587+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
588+dependencies = [
589+ "futures-channel",
590+ "futures-core",
591+ "futures-executor",
592+ "futures-io",
593+ "futures-sink",
594+ "futures-task",
595+ "futures-util",
596+]
597+
598+[[package]]
599+name = "futures-channel"
600+version = "0.3.32"
601+source = "registry+https://github.com/rust-lang/crates.io-index"
602+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
603+dependencies = [
604+ "futures-core",
605+ "futures-sink",
606+]
607+
608+[[package]]
609+name = "futures-core"
610+version = "0.3.32"
611+source = "registry+https://github.com/rust-lang/crates.io-index"
612+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
613+
614+[[package]]
615+name = "futures-executor"
616+version = "0.3.32"
617+source = "registry+https://github.com/rust-lang/crates.io-index"
618+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
619+dependencies = [
620+ "futures-core",
621+ "futures-task",
622+ "futures-util",
623+]
624+
625+[[package]]
626+name = "futures-io"
627+version = "0.3.32"
628+source = "registry+https://github.com/rust-lang/crates.io-index"
629+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
630+
631+[[package]]
632+name = "futures-macro"
633+version = "0.3.32"
634+source = "registry+https://github.com/rust-lang/crates.io-index"
635+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
636+dependencies = [
637+ "proc-macro2",
638+ "quote",
639+ "syn 2.0.118",
640+]
641+
642+[[package]]
643+name = "futures-sink"
644+version = "0.3.32"
645+source = "registry+https://github.com/rust-lang/crates.io-index"
646+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
647+
648+[[package]]
649+name = "futures-task"
650+version = "0.3.32"
651+source = "registry+https://github.com/rust-lang/crates.io-index"
652+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
653+
654+[[package]]
655+name = "futures-util"
656+version = "0.3.32"
657+source = "registry+https://github.com/rust-lang/crates.io-index"
658+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
659+dependencies = [
660+ "futures-channel",
661+ "futures-core",
662+ "futures-io",
663+ "futures-macro",
664+ "futures-sink",
665+ "futures-task",
666+ "memchr",
667+ "pin-project-lite",
668+ "slab",
669+]
670+
671+[[package]]
672+name = "getrandom"
673+version = "0.2.17"
674+source = "registry+https://github.com/rust-lang/crates.io-index"
675+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
676+dependencies = [
677+ "cfg-if",
678+ "libc",
679+ "wasi",
680+]
681+
682+[[package]]
683+name = "glob"
684+version = "0.3.3"
685+source = "registry+https://github.com/rust-lang/crates.io-index"
686+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
687+
688+[[package]]
689+name = "hashbrown"
690+version = "0.16.1"
691+source = "registry+https://github.com/rust-lang/crates.io-index"
692+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
693+dependencies = [
694+ "allocator-api2",
695+]
696+
697+[[package]]
698+name = "heck"
699+version = "0.3.3"
700+source = "registry+https://github.com/rust-lang/crates.io-index"
701+checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
702+dependencies = [
703+ "unicode-segmentation",
704+]
705+
706+[[package]]
707+name = "heck"
708+version = "0.5.0"
709+source = "registry+https://github.com/rust-lang/crates.io-index"
710+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
711+
712+[[package]]
713+name = "http"
714+version = "1.4.2"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
717+dependencies = [
718+ "bytes",
719+ "itoa",
720+]
721+
722+[[package]]
723+name = "icu_collections"
724+version = "2.2.0"
725+source = "registry+https://github.com/rust-lang/crates.io-index"
726+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
727+dependencies = [
728+ "displaydoc",
729+ "potential_utf",
730+ "utf8_iter",
731+ "yoke",
732+ "zerofrom",
733+ "zerovec",
734+]
735+
736+[[package]]
737+name = "icu_locale_core"
738+version = "2.2.0"
739+source = "registry+https://github.com/rust-lang/crates.io-index"
740+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
741+dependencies = [
742+ "displaydoc",
743+ "litemap",
744+ "tinystr",
745+ "writeable",
746+ "zerovec",
747+]
748+
749+[[package]]
750+name = "icu_normalizer"
751+version = "2.2.0"
752+source = "registry+https://github.com/rust-lang/crates.io-index"
753+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
754+dependencies = [
755+ "icu_collections",
756+ "icu_normalizer_data",
757+ "icu_properties",
758+ "icu_provider",
759+ "smallvec",
760+ "zerovec",
761+]
762+
763+[[package]]
764+name = "icu_normalizer_data"
765+version = "2.2.0"
766+source = "registry+https://github.com/rust-lang/crates.io-index"
767+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
768+
769+[[package]]
770+name = "icu_properties"
771+version = "2.2.0"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
774+dependencies = [
775+ "icu_collections",
776+ "icu_locale_core",
777+ "icu_properties_data",
778+ "icu_provider",
779+ "zerotrie",
780+ "zerovec",
781+]
782+
783+[[package]]
784+name = "icu_properties_data"
785+version = "2.2.0"
786+source = "registry+https://github.com/rust-lang/crates.io-index"
787+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
788+
789+[[package]]
790+name = "icu_provider"
791+version = "2.2.0"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
794+dependencies = [
795+ "displaydoc",
796+ "icu_locale_core",
797+ "writeable",
798+ "yoke",
799+ "zerofrom",
800+ "zerotrie",
801+ "zerovec",
802+]
803+
804+[[package]]
805+name = "iddqd"
806+version = "0.3.18"
807+source = "registry+https://github.com/rust-lang/crates.io-index"
808+checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca"
809+dependencies = [
810+ "allocator-api2",
811+ "equivalent",
812+ "foldhash",
813+ "hashbrown",
814+ "rustc-hash",
815+]
816+
817+[[package]]
818+name = "ident_case"
819+version = "1.0.1"
820+source = "registry+https://github.com/rust-lang/crates.io-index"
821+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
822+
823+[[package]]
824+name = "idna"
825+version = "1.1.0"
826+source = "registry+https://github.com/rust-lang/crates.io-index"
827+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
828+dependencies = [
829+ "idna_adapter",
830+ "smallvec",
831+ "utf8_iter",
832+]
833+
834+[[package]]
835+name = "idna_adapter"
836+version = "1.2.2"
837+source = "registry+https://github.com/rust-lang/crates.io-index"
838+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
839+dependencies = [
840+ "icu_normalizer",
841+ "icu_properties",
842+]
843+
844+[[package]]
845+name = "impls"
846+version = "1.0.3"
847+source = "registry+https://github.com/rust-lang/crates.io-index"
848+checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
849+
850+[[package]]
851+name = "include_dir"
852+version = "0.6.2"
853+source = "registry+https://github.com/rust-lang/crates.io-index"
854+checksum = "24b56e147e6187d61e9d0f039f10e070d0c0a887e24fe0bb9ca3f29bfde62cab"
855+dependencies = [
856+ "glob",
857+ "include_dir_impl",
858+ "proc-macro-hack",
859+]
860+
861+[[package]]
862+name = "include_dir"
863+version = "0.7.4"
864+source = "registry+https://github.com/rust-lang/crates.io-index"
865+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
866+dependencies = [
867+ "include_dir_macros",
868+]
869+
870+[[package]]
871+name = "include_dir_impl"
872+version = "0.6.2"
873+source = "registry+https://github.com/rust-lang/crates.io-index"
874+checksum = "0a0c890c85da4bab7bce4204c707396bbd3c6c8a681716a51c8814cfc2b682df"
875+dependencies = [
876+ "anyhow",
877+ "proc-macro-hack",
878+ "proc-macro2",
879+ "quote",
880+ "syn 1.0.109",
881+]
882+
883+[[package]]
884+name = "include_dir_macros"
885+version = "0.7.4"
886+source = "registry+https://github.com/rust-lang/crates.io-index"
887+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
888+dependencies = [
889+ "proc-macro2",
890+ "quote",
891+]
892+
893+[[package]]
894+name = "indent"
895+version = "0.1.1"
896+source = "registry+https://github.com/rust-lang/crates.io-index"
897+checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
898+
899+[[package]]
900+name = "indoc"
901+version = "2.0.7"
902+source = "registry+https://github.com/rust-lang/crates.io-index"
903+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
904+dependencies = [
905+ "rustversion",
906+]
907+
908+[[package]]
909+name = "is_terminal_polyfill"
910+version = "1.70.2"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
913+
914+[[package]]
915+name = "itoa"
916+version = "1.0.18"
917+source = "registry+https://github.com/rust-lang/crates.io-index"
918+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
919+
920+[[package]]
921+name = "js-sys"
922+version = "0.3.103"
923+source = "registry+https://github.com/rust-lang/crates.io-index"
924+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
925+dependencies = [
926+ "cfg-if",
927+ "futures-util",
928+ "wasm-bindgen",
929+]
930+
931+[[package]]
932+name = "libc"
933+version = "0.2.186"
934+source = "registry+https://github.com/rust-lang/crates.io-index"
935+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
936+
937+[[package]]
938+name = "litemap"
939+version = "0.8.2"
940+source = "registry+https://github.com/rust-lang/crates.io-index"
941+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
942+
943+[[package]]
944+name = "log"
945+version = "0.4.33"
946+source = "registry+https://github.com/rust-lang/crates.io-index"
947+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
948+
949+[[package]]
950+name = "memchr"
951+version = "2.8.3"
952+source = "registry+https://github.com/rust-lang/crates.io-index"
953+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
954+
955+[[package]]
956+name = "mime"
957+version = "0.3.17"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
960+
961+[[package]]
962+name = "mutants"
963+version = "0.0.3"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
966+
967+[[package]]
968+name = "once_cell"
969+version = "1.21.4"
970+source = "registry+https://github.com/rust-lang/crates.io-index"
971+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
972+
973+[[package]]
974+name = "once_cell_polyfill"
975+version = "1.70.2"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
978+
979+[[package]]
980+name = "percent-encoding"
981+version = "2.3.2"
982+source = "registry+https://github.com/rust-lang/crates.io-index"
983+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
984+
985+[[package]]
986+name = "phf"
987+version = "0.10.1"
988+source = "registry+https://github.com/rust-lang/crates.io-index"
989+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
990+dependencies = [
991+ "phf_macros",
992+ "phf_shared",
993+ "proc-macro-hack",
994+]
995+
996+[[package]]
997+name = "phf_generator"
998+version = "0.10.0"
999+source = "registry+https://github.com/rust-lang/crates.io-index"
1000+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
1001+dependencies = [
1002+ "phf_shared",
1003+ "rand",
1004+]
1005+
1006+[[package]]
1007+name = "phf_macros"
1008+version = "0.10.0"
1009+source = "registry+https://github.com/rust-lang/crates.io-index"
1010+checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
1011+dependencies = [
1012+ "phf_generator",
1013+ "phf_shared",
1014+ "proc-macro-hack",
1015+ "proc-macro2",
1016+ "quote",
1017+ "syn 1.0.109",
1018+]
1019+
1020+[[package]]
1021+name = "phf_shared"
1022+version = "0.10.0"
1023+source = "registry+https://github.com/rust-lang/crates.io-index"
1024+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
1025+dependencies = [
1026+ "siphasher",
1027+]
1028+
1029+[[package]]
1030+name = "pin-project-lite"
1031+version = "0.2.17"
1032+source = "registry+https://github.com/rust-lang/crates.io-index"
1033+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1034+
1035+[[package]]
1036+name = "potential_utf"
1037+version = "0.1.5"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
1040+dependencies = [
1041+ "zerovec",
1042+]
1043+
1044+[[package]]
1045+name = "ppv-lite86"
1046+version = "0.2.21"
1047+source = "registry+https://github.com/rust-lang/crates.io-index"
1048+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1049+dependencies = [
1050+ "zerocopy",
1051+]
1052+
1053+[[package]]
1054+name = "proc-macro-error"
1055+version = "1.0.4"
1056+source = "registry+https://github.com/rust-lang/crates.io-index"
1057+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
1058+dependencies = [
1059+ "proc-macro-error-attr",
1060+ "proc-macro2",
1061+ "quote",
1062+ "syn 1.0.109",
1063+ "version_check",
1064+]
1065+
1066+[[package]]
1067+name = "proc-macro-error-attr"
1068+version = "1.0.4"
1069+source = "registry+https://github.com/rust-lang/crates.io-index"
1070+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
1071+dependencies = [
1072+ "proc-macro2",
1073+ "quote",
1074+ "version_check",
1075+]
1076+
1077+[[package]]
1078+name = "proc-macro-hack"
1079+version = "0.5.20+deprecated"
1080+source = "registry+https://github.com/rust-lang/crates.io-index"
1081+checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
1082+
1083+[[package]]
1084+name = "proc-macro2"
1085+version = "1.0.106"
1086+source = "registry+https://github.com/rust-lang/crates.io-index"
1087+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1088+dependencies = [
1089+ "unicode-ident",
1090+]
1091+
1092+[[package]]
1093+name = "quote"
1094+version = "1.0.46"
1095+source = "registry+https://github.com/rust-lang/crates.io-index"
1096+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
1097+dependencies = [
1098+ "proc-macro2",
1099+]
1100+
1101+[[package]]
1102+name = "rand"
1103+version = "0.8.6"
1104+source = "registry+https://github.com/rust-lang/crates.io-index"
1105+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
1106+dependencies = [
1107+ "libc",
1108+ "rand_chacha",
1109+ "rand_core",
1110+]
1111+
1112+[[package]]
1113+name = "rand_chacha"
1114+version = "0.3.1"
1115+source = "registry+https://github.com/rust-lang/crates.io-index"
1116+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1117+dependencies = [
1118+ "ppv-lite86",
1119+ "rand_core",
1120+]
1121+
1122+[[package]]
1123+name = "rand_core"
1124+version = "0.6.4"
1125+source = "registry+https://github.com/rust-lang/crates.io-index"
1126+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1127+dependencies = [
1128+ "getrandom",
1129+]
1130+
1131+[[package]]
1132+name = "regex"
1133+version = "1.12.4"
1134+source = "registry+https://github.com/rust-lang/crates.io-index"
1135+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1136+dependencies = [
1137+ "aho-corasick",
1138+ "memchr",
1139+ "regex-automata",
1140+ "regex-syntax",
1141+]
1142+
1143+[[package]]
1144+name = "regex-automata"
1145+version = "0.4.14"
1146+source = "registry+https://github.com/rust-lang/crates.io-index"
1147+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1148+dependencies = [
1149+ "aho-corasick",
1150+ "memchr",
1151+ "regex-syntax",
1152+]
1153+
1154+[[package]]
1155+name = "regex-syntax"
1156+version = "0.8.11"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1159+
1160+[[package]]
1161+name = "rustc-hash"
1162+version = "2.1.3"
1163+source = "registry+https://github.com/rust-lang/crates.io-index"
1164+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
1165+
1166+[[package]]
1167+name = "rustversion"
1168+version = "1.0.23"
1169+source = "registry+https://github.com/rust-lang/crates.io-index"
1170+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
1171+
1172+[[package]]
1173+name = "ryu"
1174+version = "1.0.23"
1175+source = "registry+https://github.com/rust-lang/crates.io-index"
1176+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1177+
1178+[[package]]
1179+name = "same-file"
1180+version = "1.0.6"
1181+source = "registry+https://github.com/rust-lang/crates.io-index"
1182+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1183+dependencies = [
1184+ "winapi-util",
1185+]
1186+
1187+[[package]]
1188+name = "serde"
1189+version = "1.0.228"
1190+source = "registry+https://github.com/rust-lang/crates.io-index"
1191+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1192+dependencies = [
1193+ "serde_core",
1194+ "serde_derive",
1195+]
1196+
1197+[[package]]
1198+name = "serde-generate"
1199+version = "0.26.0"
1200+source = "registry+https://github.com/rust-lang/crates.io-index"
1201+checksum = "8309fc8d475cf5884e92a463b3a003d433684335be19c3f739af0451b027254b"
1202+dependencies = [
1203+ "heck 0.3.3",
1204+ "include_dir 0.6.2",
1205+ "phf",
1206+ "serde",
1207+ "serde-reflection",
1208+ "textwrap 0.13.4",
1209+]
1210+
1211+[[package]]
1212+name = "serde-reflection"
1213+version = "0.4.0"
1214+source = "registry+https://github.com/rust-lang/crates.io-index"
1215+checksum = "5b6798a64289ff550d8d79847467789a5fd30b42c9c406a4d6dc0bc9b567e55c"
1216+dependencies = [
1217+ "once_cell",
1218+ "serde",
1219+ "thiserror 1.0.69",
1220+]
1221+
1222+[[package]]
1223+name = "serde_bytes"
1224+version = "0.11.19"
1225+source = "registry+https://github.com/rust-lang/crates.io-index"
1226+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
1227+dependencies = [
1228+ "serde",
1229+ "serde_core",
1230+]
1231+
1232+[[package]]
1233+name = "serde_core"
1234+version = "1.0.228"
1235+source = "registry+https://github.com/rust-lang/crates.io-index"
1236+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1237+dependencies = [
1238+ "serde_derive",
1239+]
1240+
1241+[[package]]
1242+name = "serde_derive"
1243+version = "1.0.228"
1244+source = "registry+https://github.com/rust-lang/crates.io-index"
1245+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1246+dependencies = [
1247+ "proc-macro2",
1248+ "quote",
1249+ "syn 2.0.118",
1250+]
1251+
1252+[[package]]
1253+name = "serde_json"
1254+version = "1.0.150"
1255+source = "registry+https://github.com/rust-lang/crates.io-index"
1256+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1257+dependencies = [
1258+ "itoa",
1259+ "memchr",
1260+ "serde",
1261+ "serde_core",
1262+ "zmij",
1263+]
1264+
1265+[[package]]
1266+name = "serde_qs"
1267+version = "1.1.2"
1268+source = "registry+https://github.com/rust-lang/crates.io-index"
1269+checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
1270+dependencies = [
1271+ "itoa",
1272+ "percent-encoding",
1273+ "ryu",
1274+ "serde",
1275+]
1276+
1277+[[package]]
1278+name = "shared"
1279+version = "0.1.0"
1280+dependencies = [
1281+ "anyhow",
1282+ "base64",
1283+ "bincode",
1284+ "boltffi",
1285+ "clap",
1286+ "crux_core",
1287+ "crux_http",
1288+ "facet",
1289+ "serde",
1290+ "serde_json",
1291+]
1292+
1293+[[package]]
1294+name = "siphasher"
1295+version = "0.3.11"
1296+source = "registry+https://github.com/rust-lang/crates.io-index"
1297+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
1298+
1299+[[package]]
1300+name = "slab"
1301+version = "0.4.12"
1302+source = "registry+https://github.com/rust-lang/crates.io-index"
1303+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1304+
1305+[[package]]
1306+name = "smallvec"
1307+version = "1.15.2"
1308+source = "registry+https://github.com/rust-lang/crates.io-index"
1309+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1310+
1311+[[package]]
1312+name = "smawk"
1313+version = "0.3.3"
1314+source = "registry+https://github.com/rust-lang/crates.io-index"
1315+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1316+
1317+[[package]]
1318+name = "stable_deref_trait"
1319+version = "1.2.1"
1320+source = "registry+https://github.com/rust-lang/crates.io-index"
1321+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1322+
1323+[[package]]
1324+name = "strsim"
1325+version = "0.11.1"
1326+source = "registry+https://github.com/rust-lang/crates.io-index"
1327+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1328+
1329+[[package]]
1330+name = "syn"
1331+version = "1.0.109"
1332+source = "registry+https://github.com/rust-lang/crates.io-index"
1333+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1334+dependencies = [
1335+ "proc-macro2",
1336+ "quote",
1337+ "unicode-ident",
1338+]
1339+
1340+[[package]]
1341+name = "syn"
1342+version = "2.0.118"
1343+source = "registry+https://github.com/rust-lang/crates.io-index"
1344+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1345+dependencies = [
1346+ "proc-macro2",
1347+ "quote",
1348+ "unicode-ident",
1349+]
1350+
1351+[[package]]
1352+name = "synstructure"
1353+version = "0.13.2"
1354+source = "registry+https://github.com/rust-lang/crates.io-index"
1355+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1356+dependencies = [
1357+ "proc-macro2",
1358+ "quote",
1359+ "syn 2.0.118",
1360+]
1361+
1362+[[package]]
1363+name = "textwrap"
1364+version = "0.13.4"
1365+source = "registry+https://github.com/rust-lang/crates.io-index"
1366+checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
1367+dependencies = [
1368+ "smawk",
1369+ "unicode-width 0.1.14",
1370+]
1371+
1372+[[package]]
1373+name = "textwrap"
1374+version = "0.16.2"
1375+source = "registry+https://github.com/rust-lang/crates.io-index"
1376+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1377+dependencies = [
1378+ "smawk",
1379+ "unicode-linebreak",
1380+ "unicode-width 0.2.2",
1381+]
1382+
1383+[[package]]
1384+name = "thiserror"
1385+version = "1.0.69"
1386+source = "registry+https://github.com/rust-lang/crates.io-index"
1387+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1388+dependencies = [
1389+ "thiserror-impl 1.0.69",
1390+]
1391+
1392+[[package]]
1393+name = "thiserror"
1394+version = "2.0.18"
1395+source = "registry+https://github.com/rust-lang/crates.io-index"
1396+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1397+dependencies = [
1398+ "thiserror-impl 2.0.18",
1399+]
1400+
1401+[[package]]
1402+name = "thiserror-impl"
1403+version = "1.0.69"
1404+source = "registry+https://github.com/rust-lang/crates.io-index"
1405+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1406+dependencies = [
1407+ "proc-macro2",
1408+ "quote",
1409+ "syn 2.0.118",
1410+]
1411+
1412+[[package]]
1413+name = "thiserror-impl"
1414+version = "2.0.18"
1415+source = "registry+https://github.com/rust-lang/crates.io-index"
1416+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1417+dependencies = [
1418+ "proc-macro2",
1419+ "quote",
1420+ "syn 2.0.118",
1421+]
1422+
1423+[[package]]
1424+name = "tinystr"
1425+version = "0.8.3"
1426+source = "registry+https://github.com/rust-lang/crates.io-index"
1427+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1428+dependencies = [
1429+ "displaydoc",
1430+ "zerovec",
1431+]
1432+
1433+[[package]]
1434+name = "unicode-ident"
1435+version = "1.0.24"
1436+source = "registry+https://github.com/rust-lang/crates.io-index"
1437+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1438+
1439+[[package]]
1440+name = "unicode-linebreak"
1441+version = "0.1.5"
1442+source = "registry+https://github.com/rust-lang/crates.io-index"
1443+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1444+
1445+[[package]]
1446+name = "unicode-segmentation"
1447+version = "1.13.3"
1448+source = "registry+https://github.com/rust-lang/crates.io-index"
1449+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
1450+
1451+[[package]]
1452+name = "unicode-width"
1453+version = "0.1.14"
1454+source = "registry+https://github.com/rust-lang/crates.io-index"
1455+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1456+
1457+[[package]]
1458+name = "unicode-width"
1459+version = "0.2.2"
1460+source = "registry+https://github.com/rust-lang/crates.io-index"
1461+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1462+
1463+[[package]]
1464+name = "unsynn"
1465+version = "0.3.0"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
1468+dependencies = [
1469+ "mutants",
1470+ "proc-macro2",
1471+ "rustc-hash",
1472+]
1473+
1474+[[package]]
1475+name = "url"
1476+version = "2.5.8"
1477+source = "registry+https://github.com/rust-lang/crates.io-index"
1478+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1479+dependencies = [
1480+ "form_urlencoded",
1481+ "idna",
1482+ "percent-encoding",
1483+ "serde",
1484+]
1485+
1486+[[package]]
1487+name = "utf8_iter"
1488+version = "1.0.4"
1489+source = "registry+https://github.com/rust-lang/crates.io-index"
1490+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1491+
1492+[[package]]
1493+name = "utf8parse"
1494+version = "0.2.2"
1495+source = "registry+https://github.com/rust-lang/crates.io-index"
1496+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1497+
1498+[[package]]
1499+name = "uuid"
1500+version = "1.23.4"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
1503+dependencies = [
1504+ "js-sys",
1505+ "wasm-bindgen",
1506+]
1507+
1508+[[package]]
1509+name = "version_check"
1510+version = "0.9.5"
1511+source = "registry+https://github.com/rust-lang/crates.io-index"
1512+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1513+
1514+[[package]]
1515+name = "walkdir"
1516+version = "2.5.0"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1519+dependencies = [
1520+ "same-file",
1521+ "winapi-util",
1522+]
1523+
1524+[[package]]
1525+name = "wasi"
1526+version = "0.11.1+wasi-snapshot-preview1"
1527+source = "registry+https://github.com/rust-lang/crates.io-index"
1528+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1529+
1530+[[package]]
1531+name = "wasm-bindgen"
1532+version = "0.2.126"
1533+source = "registry+https://github.com/rust-lang/crates.io-index"
1534+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
1535+dependencies = [
1536+ "cfg-if",
1537+ "once_cell",
1538+ "rustversion",
1539+ "wasm-bindgen-macro",
1540+ "wasm-bindgen-shared",
1541+]
1542+
1543+[[package]]
1544+name = "wasm-bindgen-macro"
1545+version = "0.2.126"
1546+source = "registry+https://github.com/rust-lang/crates.io-index"
1547+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
1548+dependencies = [
1549+ "quote",
1550+ "wasm-bindgen-macro-support",
1551+]
1552+
1553+[[package]]
1554+name = "wasm-bindgen-macro-support"
1555+version = "0.2.126"
1556+source = "registry+https://github.com/rust-lang/crates.io-index"
1557+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
1558+dependencies = [
1559+ "bumpalo",
1560+ "proc-macro2",
1561+ "quote",
1562+ "syn 2.0.118",
1563+ "wasm-bindgen-shared",
1564+]
1565+
1566+[[package]]
1567+name = "wasm-bindgen-shared"
1568+version = "0.2.126"
1569+source = "registry+https://github.com/rust-lang/crates.io-index"
1570+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
1571+dependencies = [
1572+ "unicode-ident",
1573+]
1574+
1575+[[package]]
1576+name = "web-sys"
1577+version = "0.3.103"
1578+source = "registry+https://github.com/rust-lang/crates.io-index"
1579+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
1580+dependencies = [
1581+ "js-sys",
1582+ "wasm-bindgen",
1583+]
1584+
1585+[[package]]
1586+name = "winapi-util"
1587+version = "0.1.11"
1588+source = "registry+https://github.com/rust-lang/crates.io-index"
1589+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1590+dependencies = [
1591+ "windows-sys",
1592+]
1593+
1594+[[package]]
1595+name = "windows-link"
1596+version = "0.2.1"
1597+source = "registry+https://github.com/rust-lang/crates.io-index"
1598+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1599+
1600+[[package]]
1601+name = "windows-sys"
1602+version = "0.61.2"
1603+source = "registry+https://github.com/rust-lang/crates.io-index"
1604+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1605+dependencies = [
1606+ "windows-link",
1607+]
1608+
1609+[[package]]
1610+name = "writeable"
1611+version = "0.6.3"
1612+source = "registry+https://github.com/rust-lang/crates.io-index"
1613+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
1614+
1615+[[package]]
1616+name = "yoke"
1617+version = "0.8.3"
1618+source = "registry+https://github.com/rust-lang/crates.io-index"
1619+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1620+dependencies = [
1621+ "stable_deref_trait",
1622+ "yoke-derive",
1623+ "zerofrom",
1624+]
1625+
1626+[[package]]
1627+name = "yoke-derive"
1628+version = "0.8.2"
1629+source = "registry+https://github.com/rust-lang/crates.io-index"
1630+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
1631+dependencies = [
1632+ "proc-macro2",
1633+ "quote",
1634+ "syn 2.0.118",
1635+ "synstructure",
1636+]
1637+
1638+[[package]]
1639+name = "zerocopy"
1640+version = "0.8.53"
1641+source = "registry+https://github.com/rust-lang/crates.io-index"
1642+checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
1643+dependencies = [
1644+ "zerocopy-derive",
1645+]
1646+
1647+[[package]]
1648+name = "zerocopy-derive"
1649+version = "0.8.53"
1650+source = "registry+https://github.com/rust-lang/crates.io-index"
1651+checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
1652+dependencies = [
1653+ "proc-macro2",
1654+ "quote",
1655+ "syn 2.0.118",
1656+]
1657+
1658+[[package]]
1659+name = "zerofrom"
1660+version = "0.1.8"
1661+source = "registry+https://github.com/rust-lang/crates.io-index"
1662+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1663+dependencies = [
1664+ "zerofrom-derive",
1665+]
1666+
1667+[[package]]
1668+name = "zerofrom-derive"
1669+version = "0.1.7"
1670+source = "registry+https://github.com/rust-lang/crates.io-index"
1671+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
1672+dependencies = [
1673+ "proc-macro2",
1674+ "quote",
1675+ "syn 2.0.118",
1676+ "synstructure",
1677+]
1678+
1679+[[package]]
1680+name = "zerotrie"
1681+version = "0.2.4"
1682+source = "registry+https://github.com/rust-lang/crates.io-index"
1683+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
1684+dependencies = [
1685+ "displaydoc",
1686+ "yoke",
1687+ "zerofrom",
1688+]
1689+
1690+[[package]]
1691+name = "zerovec"
1692+version = "0.11.6"
1693+source = "registry+https://github.com/rust-lang/crates.io-index"
1694+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
1695+dependencies = [
1696+ "yoke",
1697+ "zerofrom",
1698+ "zerovec-derive",
1699+]
1700+
1701+[[package]]
1702+name = "zerovec-derive"
1703+version = "0.11.3"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
1706+dependencies = [
1707+ "proc-macro2",
1708+ "quote",
1709+ "syn 2.0.118",
1710+]
1711+
1712+[[package]]
1713+name = "zmij"
1714+version = "1.0.21"
1715+source = "registry+https://github.com/rust-lang/crates.io-index"
1716+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
shared/Cargo.tomlnew file+34
@@ -0,0 +1,34 @@
1+[package]
2+name = "shared"
3+version = "0.1.0"
4+edition = "2024"
5+rust-version = "1.90"
6+license = "Apache-2.0"
7+
8+[lib]
9+crate-type = ["cdylib", "lib", "staticlib"]
10+
11+[[bin]]
12+name = "codegen"
13+required-features = ["codegen"]
14+
15+[features]
16+facet_typegen = ["crux_core/facet_typegen"]
17+codegen = ["facet_typegen", "dep:clap", "dep:anyhow", "crux_core/typegen"]
18+
19+[dependencies]
20+facet = "=0.44"
21+boltffi = "0.27"
22+crux_core = "0.19"
23+crux_http = "0.19"
24+serde = { version = "1", features = ["derive"] }
25+serde_json = "1"
26+base64 = "0.22"
27+
28+# Optional codegen deps
29+anyhow = { version = "1", optional = true }
30+clap = { version = "4", optional = true, features = ["derive"] }
31+
32+[dev-dependencies]
33+bincode = "1"
34+crux_core = { version = "0.19", features = ["testing"] }
shared/build.gradle.ktsnew file+39
@@ -0,0 +1,39 @@
1+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
3+plugins {
4+ alias(libs.plugins.android.library)
5+ alias(libs.plugins.kotlin.android)
6+}
7+
8+android {
9+ namespace = "soy.iko.opencode.core"
10+
11+ compileSdk = 36
12+ buildToolsVersion = "36.0.0"
13+
14+ defaultConfig {
15+ minSdk = 26
16+ }
17+
18+ compileOptions {
19+ sourceCompatibility = JavaVersion.VERSION_11
20+ targetCompatibility = JavaVersion.VERSION_11
21+ }
22+
23+ kotlin {
24+ compilerOptions {
25+ jvmTarget = JvmTarget.JVM_11
26+ }
27+ }
28+
29+ sourceSets {
30+ getByName("main") {
31+ kotlin.srcDirs("${projectDir}/../generated")
32+ jniLibs.srcDirs("${projectDir}/../generated/jniLibs")
33+ }
34+ }
35+}
36+
37+dependencies {
38+ implementation(libs.kotlinx.coroutines.android)
39+}
shared/src/app.rsnew file+749
@@ -0,0 +1,749 @@
1+use base64::Engine;
2+use crux_core::{
3+ App, Command,
4+ macros::effect,
5+ render::{RenderOperation, render},
6+};
7+use crux_http::command::{Http, RequestBuilder};
8+use crux_http::protocol::HttpRequest;
9+use facet::Facet;
10+use serde::{Deserialize, Serialize};
11+
12+// ─── Events ────────────────────────────────────────────────────────────────
13+
14+#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
15+#[repr(C)]
16+pub enum Event {
17+ // ── Type-generated events (sent from Shell → Core).
18+ // These MUST be contiguous starting at index 0 to match the
19+ // generated Kotlin/Swift/TS enum indices. Do not interleave
20+ // skipped variants here. ──────────────────────────────────────
21+
22+ // Lifecycle
23+ Start,
24+ ServerUrlChanged(String),
25+ UsernameChanged(String),
26+ PasswordChanged(String),
27+ Connect,
28+ CancelAuth,
29+
30+ // Session list
31+ LoadSessions,
32+ SelectSession(String),
33+ CreateSession,
34+
35+ // Chat
36+ LoadMessages(String),
37+ SendMessage(String),
38+
39+ // SSE event received
40+ EventReceived(String),
41+
42+ // Navigation
43+ NavigateToChat(String),
44+ NavigateToSessions,
45+
46+ // Errors
47+ DismissError,
48+
49+ // ── Internal events (skipped from typegen, sent only via then_send).
50+ // These occupy higher discriminant indices that are never
51+ // produced by the shell, so the index gap is harmless. ─────────
52+
53+ #[serde(skip)]
54+ #[facet(skip)]
55+ SessionsLoaded(#[facet(opaque)] HttpResult<String>),
56+ #[serde(skip)]
57+ #[facet(skip)]
58+ SessionCreated(#[facet(opaque)] HttpResult<String>),
59+ #[serde(skip)]
60+ #[facet(skip)]
61+ MessagesLoaded(#[facet(opaque)] HttpResult<String>),
62+ #[serde(skip)]
63+ #[facet(skip)]
64+ MessageSent(#[facet(opaque)] HttpResult<String>),
65+ #[serde(skip)]
66+ #[facet(skip)]
67+ CrashLog(String),
68+ #[serde(skip)]
69+ #[facet(skip)]
70+ HealthProbed(#[facet(opaque)] HttpResult<String>),
71+}
72+
73+// ─── Effects ───────────────────────────────────────────────────────────────
74+
75+#[effect(facet_typegen)]
76+#[derive(Debug)]
77+pub enum Effect {
78+ Render(RenderOperation),
79+ Http(HttpRequest),
80+}
81+
82+// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
83+
84+pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
85+
86+// ─── Model ─────────────────────────────────────────────────────────────────
87+
88+#[derive(Default)]
89+pub struct Model {
90+ server_url: String,
91+ username: String,
92+ password: String,
93+ /// Set once the server replies 401 to an unauthenticated probe.
94+ /// The view surfaces this so the shell can show credential fields.
95+ auth_required: bool,
96+ /// Set once a request with credentials succeeds. All subsequent
97+ /// requests carry the Authorization header.
98+ authed: bool,
99+ connected: bool,
100+ loading: bool,
101+ error: Option<String>,
102+ sessions: Vec<SessionSummary>,
103+ current_session_id: Option<String>,
104+ messages: Vec<MessageView>,
105+ draft_message: String,
106+ /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
107+ crash_logs: Vec<String>,
108+}
109+
110+#[derive(Clone, Debug, Default)]
111+pub struct SessionSummary {
112+ pub id: String,
113+ pub title: String,
114+}
115+
116+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
117+pub struct MessageView {
118+ pub id: String,
119+ pub role: String,
120+ pub text: String,
121+ pub time: u64,
122+}
123+
124+// ─── ViewModel ─────────────────────────────────────────────────────────────
125+
126+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
127+pub struct ViewModel {
128+ pub screen: Screen,
129+ pub server_url: String,
130+ pub username: String,
131+ pub password: String,
132+ pub auth_required: bool,
133+ pub connected: bool,
134+ pub loading: bool,
135+ pub error: Option<String>,
136+ pub sessions: Vec<SessionView>,
137+ pub current_session_id: Option<String>,
138+ pub messages: Vec<MessageView>,
139+ pub draft_message: String,
140+ pub crash_log_count: u32,
141+ pub latest_crash_log: Option<String>,
142+}
143+
144+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
145+#[repr(C)]
146+pub enum Screen {
147+ #[default]
148+ Connect,
149+ Sessions,
150+ Chat,
151+}
152+
153+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
154+pub struct SessionView {
155+ pub id: String,
156+ pub title: String,
157+}
158+
159+// ─── App ───────────────────────────────────────────────────────────────────
160+
161+#[derive(Default)]
162+pub struct OpencodeApp;
163+
164+impl OpencodeApp {
165+ /// Build a GET request to `path`, adding the Basic-Auth header when the
166+ /// model has credentials. The header is always added once `authed` is true
167+ /// (i.e. after the server has accepted credentials), and also during the
168+ /// credential-retry attempt itself.
169+ fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
170+ let url = format!("{}{path}", model.server_url);
171+ let req = Http::get(&url).expect_string();
172+ self.with_auth(req, model)
173+ }
174+
175+ /// Build a POST request to `path` with the given body bytes.
176+ fn post(&self, model: &Model, path: &str, body: Vec<u8>) -> RequestBuilder<Effect, Event, String> {
177+ let url = format!("{}{path}", model.server_url);
178+ let req = Http::post(&url).expect_string().body_bytes(body);
179+ self.with_auth(req, model)
180+ }
181+
182+ /// Attach the Basic-Auth `Authorization` header if the model carries
183+ /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
184+ /// `username:password`.
185+ fn with_auth<E>(
186+ &self,
187+ req: RequestBuilder<Effect, Event, E>,
188+ model: &Model,
189+ ) -> RequestBuilder<Effect, Event, E>
190+ where
191+ E: 'static,
192+ {
193+ if !model.username.is_empty() || !model.password.is_empty() {
194+ let creds = format!("{}:{}", model.username, model.password);
195+ let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
196+ req.header("Authorization", format!("Basic {encoded}"))
197+ } else {
198+ req
199+ }
200+ }
201+
202+ /// Render helper that also stamps the crash-log summary into the view.
203+ fn render_with_crashes(&self, _model: &Model) -> Command<Effect, Event> {
204+ // render() is the base; the view() function reads the model directly
205+ // for crash-log fields, so a plain render suffices.
206+ render()
207+ }
208+}
209+
210+impl App for OpencodeApp {
211+ type Event = Event;
212+ type Model = Model;
213+ type ViewModel = ViewModel;
214+ type Effect = Effect;
215+
216+ fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
217+ match event {
218+ Event::Start => {
219+ model.server_url = "http://localhost:4096".to_string();
220+ render()
221+ }
222+
223+ Event::ServerUrlChanged(url) => {
224+ model.server_url = url;
225+ render()
226+ }
227+
228+ Event::UsernameChanged(u) => {
229+ model.username = u;
230+ render()
231+ }
232+
233+ Event::PasswordChanged(p) => {
234+ model.password = p;
235+ render()
236+ }
237+
238+ Event::Connect => {
239+ model.loading = true;
240+ model.error = None;
241+ // Probe health. `with_auth` attaches credentials if present,
242+ // so a retry after a 401 carries the Authorization header.
243+ let req = self.get(model, "/global/health");
244+ req.build().then_send(|result| Event::HealthProbed(result))
245+ }
246+
247+ Event::CancelAuth => {
248+ model.auth_required = false;
249+ model.username.clear();
250+ model.password.clear();
251+ model.loading = false;
252+ render()
253+ }
254+
255+ Event::LoadSessions => {
256+ model.loading = true;
257+ self.get(model, "/session").build().then_send(Event::SessionsLoaded)
258+ }
259+
260+ Event::SessionsLoaded(result) => {
261+ model.loading = false;
262+ match result {
263+ Ok(mut response) => {
264+ let body = response.take_body().unwrap_or_default();
265+ model.sessions = parse_sessions(&body);
266+ model.connected = true;
267+ render()
268+ }
269+ Err(e) => {
270+ model.error = Some(format!("Failed to load sessions: {e}"));
271+ render()
272+ }
273+ }
274+ }
275+
276+ Event::SelectSession(id) => {
277+ model.current_session_id = Some(id.clone());
278+ Command::event(Event::NavigateToChat(id))
279+ }
280+
281+ Event::NavigateToChat(id) => {
282+ model.current_session_id = Some(id.clone());
283+ model.messages.clear();
284+ render().and(Command::event(Event::LoadMessages(id)))
285+ }
286+
287+ Event::NavigateToSessions => {
288+ model.current_session_id = None;
289+ model.messages.clear();
290+ render()
291+ }
292+
293+ Event::CreateSession => {
294+ model.loading = true;
295+ self.post(model, "/session", Vec::new())
296+ .build()
297+ .then_send(Event::SessionCreated)
298+ }
299+
300+ Event::SessionCreated(result) => {
301+ model.loading = false;
302+ match result {
303+ Ok(mut response) => {
304+ let body = response.take_body().unwrap_or_default();
305+ let id = parse_session_id(&body);
306+ Command::event(Event::NavigateToChat(id))
307+ }
308+ Err(e) => {
309+ model.error = Some(format!("Failed to create session: {e}"));
310+ render()
311+ }
312+ }
313+ }
314+
315+ Event::LoadMessages(id) => {
316+ model.loading = true;
317+ let path = format!("/session/{id}/message");
318+ self.get(model, &path).build().then_send(Event::MessagesLoaded)
319+ }
320+
321+ Event::MessagesLoaded(result) => {
322+ model.loading = false;
323+ match result {
324+ Ok(mut response) => {
325+ let body = response.take_body().unwrap_or_default();
326+ model.messages = parse_messages(&body);
327+ render()
328+ }
329+ Err(e) => {
330+ model.error = Some(format!("Failed to load messages: {e}"));
331+ render()
332+ }
333+ }
334+ }
335+
336+ Event::SendMessage(text) => {
337+ let Some(session_id) = model.current_session_id.clone() else {
338+ return render();
339+ };
340+ model.draft_message.clear();
341+ let body = serde_json::json!({
342+ "sessionID": session_id,
343+ "parts": [{ "type": "text", "text": text }]
344+ })
345+ .to_string()
346+ .into_bytes();
347+ let path = format!("/session/{session_id}/prompt_async");
348+ self.post(model, &path, body)
349+ .build()
350+ .then_send(Event::MessageSent)
351+ }
352+
353+ Event::MessageSent(result) => {
354+ match result {
355+ Ok(_) => {
356+ // Reload messages to pick up the assistant reply.
357+ if let Some(session_id) = model.current_session_id.clone() {
358+ Command::event(Event::LoadMessages(session_id))
359+ } else {
360+ render()
361+ }
362+ }
363+ Err(e) => {
364+ model.error = Some(format!("Failed to send message: {e}"));
365+ render()
366+ }
367+ }
368+ }
369+
370+ Event::EventReceived(_data) => {
371+ // SSE event — reload messages for the current session.
372+ if let Some(session_id) = model.current_session_id.clone() {
373+ Command::event(Event::LoadMessages(session_id))
374+ } else {
375+ render()
376+ }
377+ }
378+
379+ Event::CrashLog(entry) => {
380+ model.crash_logs.push(entry);
381+ self.render_with_crashes(model)
382+ }
383+
384+ Event::DismissError => {
385+ model.error = None;
386+ render()
387+ }
388+
389+ // ── Internal events (not type-generated, sent via then_send) ───
390+ Event::HealthProbed(result) => handle_health_probed(self, model, result),
391+ }
392+ }
393+
394+ fn view(&self, model: &Self::Model) -> Self::ViewModel {
395+ let screen = if model.current_session_id.is_some() {
396+ Screen::Chat
397+ } else if model.connected {
398+ Screen::Sessions
399+ } else {
400+ Screen::Connect
401+ };
402+
403+ ViewModel {
404+ screen,
405+ server_url: model.server_url.clone(),
406+ username: model.username.clone(),
407+ password: model.password.clone(),
408+ auth_required: model.auth_required,
409+ connected: model.connected,
410+ loading: model.loading,
411+ error: model.error.clone(),
412+ sessions: model
413+ .sessions
414+ .iter()
415+ .map(|s| SessionView {
416+ id: s.id.clone(),
417+ title: s.title.clone(),
418+ })
419+ .collect(),
420+ current_session_id: model.current_session_id.clone(),
421+ messages: model.messages.clone(),
422+ draft_message: model.draft_message.clone(),
423+ crash_log_count: model.crash_logs.len() as u32,
424+ latest_crash_log: model.crash_logs.last().cloned(),
425+ }
426+ }
427+}
428+
429+// ─── Basic-auth detection ──────────────────────────────────────────────────
430+//
431+// The opencode server uses HTTP Basic Auth when OPENCODE_SERVER_PASSWORD is set
432+// (username defaults to "opencode"). We detect this by probing /global/health
433+// without credentials:
434+//
435+// - 200 → server is open; proceed to load sessions.
436+// - 401 → server is behind basic auth; surface `auth_required` and wait for
437+// the user to supply credentials. The user then re-issues Connect,
438+// which now attaches the Authorization header to the same probe.
439+// A 200 on that retry flips `authed` and proceeds.
440+// - Other non-2xx → surface as a connection error.
441+// - Transport error → surface as a connection error.
442+
443+fn handle_health_probed(
444+ app: &OpencodeApp,
445+ model: &mut Model,
446+ result: HttpResult<String>,
447+) -> Command<Effect, Event> {
448+ let has_creds = !model.username.is_empty() || !model.password.is_empty();
449+ match result {
450+ Ok(response) => {
451+ let status = response.status().as_u16();
452+ if status == 401 { model.loading = false;
453+ if has_creds {
454+ // We sent credentials but the server still rejected them.
455+ model.error = Some("Invalid credentials".to_string());
456+ } else {
457+ // No credentials were sent — server is behind basic auth.
458+ // Surface the credential fields and wait for the user.
459+ model.auth_required = true;
460+ }
461+ return render();
462+ }
463+ if (200..300).contains(&status) {
464+ if has_creds {
465+ model.authed = true;
466+ }
467+ model.loading = true;
468+ return app
469+ .get(model, "/session")
470+ .build()
471+ .then_send(Event::SessionsLoaded);
472+ }
473+ // Other status codes — treat as connection error.
474+ model.loading = false;
475+ model.error = Some(format!("Server returned status {status}"));
476+ render()
477+ }
478+ Err(e) => {
479+ model.loading = false;
480+ model.error = Some(format!("Connection failed: {e}"));
481+ render()
482+ }
483+ }
484+}
485+
486+// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
487+
488+fn parse_sessions(body: &str) -> Vec<SessionSummary> {
489+ let mut sessions = Vec::new();
490+ let mut depth = 0i32;
491+ let mut start = 0usize;
492+ for (i, ch) in body.char_indices() {
493+ match ch {
494+ '{' => {
495+ if depth == 0 {
496+ start = i;
497+ }
498+ depth += 1;
499+ }
500+ '}' => {
501+ depth -= 1;
502+ if depth == 0 {
503+ let obj = &body[start..=i];
504+ let id = extract_string(obj, "id").unwrap_or_default();
505+ let title = extract_string(obj, "title").unwrap_or_default();
506+ if !id.is_empty() {
507+ sessions.push(SessionSummary {
508+ id,
509+ title: if title.is_empty() {
510+ "Untitled".to_string()
511+ } else {
512+ title
513+ },
514+ });
515+ }
516+ }
517+ }
518+ _ => {}
519+ }
520+ }
521+ sessions
522+}
523+
524+fn parse_session_id(body: &str) -> String {
525+ extract_string(body, "id").unwrap_or_default()
526+}
527+
528+fn parse_messages(body: &str) -> Vec<MessageView> {
529+ let mut messages = Vec::new();
530+ let mut depth = 0i32;
531+ let mut start = 0usize;
532+ let mut i = 0usize;
533+ let bytes = body.as_bytes();
534+ while i < body.len() {
535+ let ch = bytes[i] as char;
536+ match ch {
537+ '{' => {
538+ if depth == 0 {
539+ start = i;
540+ }
541+ depth += 1;
542+ }
543+ '}' => {
544+ depth -= 1;
545+ if depth == 0 {
546+ let obj = &body[start..=i];
547+ let id = extract_string(obj, "id").unwrap_or_default();
548+ let role = extract_string(obj, "role").unwrap_or_default();
549+ let time = extract_number(obj, "created").unwrap_or(0);
550+ if !id.is_empty() {
551+ let text = extract_message_text(obj);
552+ messages.push(MessageView { id, role, text, time });
553+ }
554+ }
555+ }
556+ _ => {}
557+ }
558+ i += 1;
559+ }
560+ messages
561+}
562+
563+/// Extract the text from a message part. opencode messages nest the user/assistant
564+/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
565+/// first `"parts"` key.
566+fn extract_message_text(obj: &str) -> String {
567+ if let Some(parts_idx) = obj.find("\"parts\"") {
568+ let rest = &obj[parts_idx..];
569+ if let Some(text) = extract_string(rest, "text") {
570+ return text;
571+ }
572+ }
573+ extract_string(obj, "text").unwrap_or_default()
574+}
575+
576+fn extract_string(json: &str, key: &str) -> Option<String> {
577+ let pattern = format!("\"{key}\"");
578+ let idx = json.find(&pattern)?;
579+ let rest = &json[idx + pattern.len()..];
580+ let colon = rest.find(':')?;
581+ let rest = &rest[colon + 1..];
582+ let quote = rest.find('"')?;
583+ let rest = &rest[quote + 1..];
584+ let end = rest.find('"')?;
585+ Some(rest[..end].to_string())
586+}
587+
588+fn extract_number(json: &str, key: &str) -> Option<u64> {
589+ let pattern = format!("\"{key}\"");
590+ let idx = json.find(&pattern)?;
591+ let rest = &json[idx + pattern.len()..];
592+ let colon = rest.find(':')?;
593+ let rest = &rest[colon + 1..].trim_start();
594+ let end = rest
595+ .find(|c: char| !c.is_ascii_digit())
596+ .unwrap_or(rest.len());
597+ rest[..end].parse().ok()
598+}
599+
600+// ─── Tests ─────────────────────────────────────────────────────────────────
601+
602+#[cfg(test)]
603+mod test {
604+ use super::*;
605+
606+ #[test]
607+ fn start_sets_default_server_url() {
608+ let app = OpencodeApp;
609+ let mut model = Model::default();
610+ app.update(Event::Start, &mut model).expect_only_render();
611+ assert_eq!(model.server_url, "http://localhost:4096");
612+ }
613+
614+ #[test]
615+ fn server_url_change_updates_model() {
616+ let app = OpencodeApp;
617+ let mut model = Model::default();
618+ app.update(Event::Start, &mut model);
619+ app.update(
620+ Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
621+ &mut model,
622+ )
623+ .expect_only_render();
624+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
625+ }
626+
627+ #[test]
628+ fn connect_emits_http_effect() {
629+ let app = OpencodeApp;
630+ let mut model = Model::default();
631+ app.update(Event::Start, &mut model);
632+ let mut cmd = app.update(Event::Connect, &mut model);
633+ assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
634+ assert!(model.loading);
635+ }
636+
637+ #[test]
638+ fn parse_sessions_extracts_id_and_title() {
639+ let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
640+ let sessions = parse_sessions(json);
641+ assert_eq!(sessions.len(), 2);
642+ assert_eq!(sessions[0].id, "abc123");
643+ assert_eq!(sessions[0].title, "My Session");
644+ assert_eq!(sessions[1].id, "def456");
645+ }
646+
647+ #[test]
648+ fn parse_session_id_extracts_id() {
649+ let json = r#"{"id":"xyz789","title":"New"}"#;
650+ let id = parse_session_id(json);
651+ assert_eq!(id, "xyz789");
652+ }
653+
654+ #[test]
655+ fn extract_string_finds_nested_keys() {
656+ let json = r#"{"a":"b","id":"target","c":"d"}"#;
657+ assert_eq!(extract_string(json, "id"), Some("target".to_string()));
658+ assert_eq!(extract_string(json, "missing"), None);
659+ }
660+
661+ #[test]
662+ fn extract_number_parses_values() {
663+ let json = r#"{"created":1234567890}"#;
664+ assert_eq!(extract_number(json, "created"), Some(1234567890));
665+ }
666+
667+ #[test]
668+ fn navigate_to_sessions_clears_state() {
669+ let app = OpencodeApp;
670+ let mut model = Model::default();
671+ model.current_session_id = Some("test".to_string());
672+ model.messages.push(MessageView {
673+ id: "m1".to_string(),
674+ role: "user".to_string(),
675+ text: "hello".to_string(),
676+ time: 0,
677+ });
678+ app.update(Event::NavigateToSessions, &mut model);
679+ assert!(model.current_session_id.is_none());
680+ assert!(model.messages.is_empty());
681+ }
682+
683+ #[test]
684+ fn auth_header_built_from_credentials() {
685+ let app = OpencodeApp;
686+ let mut model = Model::default();
687+ model.server_url = "http://localhost:4096".to_string();
688+ model.username = "opencode".to_string();
689+ model.password = "secret".to_string();
690+ // Verify the base64 encoding matches RFC 7617.
691+ let creds = format!("{}:{}", model.username, model.password);
692+ let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
693+ assert_eq!(encoded, "b3BlbmNvZGU6c2VjcmV0");
694+ // Building a request should not panic.
695+ let _ = app.get(&model, "/global/health");
696+ }
697+
698+ #[test]
699+ fn crash_log_event_accumulates() {
700+ let app = OpencodeApp;
701+ let mut model = Model::default();
702+ app.update(Event::CrashLog("boom".to_string()), &mut model);
703+ assert_eq!(model.crash_logs.len(), 1);
704+ let view = app.view(&model);
705+ assert_eq!(view.crash_log_count, 1);
706+ assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
707+ }
708+
709+ /// Verify that the bincode discriminant indices the shell sends match
710+ /// the Rust enum variant positions — the type generator skips
711+ /// `#[facet(skip)]` variants and renumbers the remaining ones, so the
712+ /// shell's index 7 (SelectSession) must land on Rust's SelectSession,
713+ /// not on a skipped variant.
714+ #[test]
715+ fn event_discriminants_match_generated_indices() {
716+ // The generated Kotlin Event enum (see generated/.../Core.kt) maps:
717+ // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
718+ // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
719+ // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
720+ // 12=NavigateToChat, 13=NavigateToSessions, 14=DismissError
721+ //
722+ // Serialize each Rust variant and check the leading u32 index.
723+ let cases: &[(Event, u32)] = &[
724+ (Event::Start, 0),
725+ (Event::ServerUrlChanged(String::new()), 1),
726+ (Event::UsernameChanged(String::new()), 2),
727+ (Event::PasswordChanged(String::new()), 3),
728+ (Event::Connect, 4),
729+ (Event::CancelAuth, 5),
730+ (Event::LoadSessions, 6),
731+ (Event::SelectSession(String::new()), 7),
732+ (Event::CreateSession, 8),
733+ (Event::LoadMessages(String::new()), 9),
734+ (Event::SendMessage(String::new()), 10),
735+ (Event::EventReceived(String::new()), 11),
736+ (Event::NavigateToChat(String::new()), 12),
737+ (Event::NavigateToSessions, 13),
738+ (Event::DismissError, 14),
739+ ];
740+ for (event, expected) in cases {
741+ let bytes = bincode::serialize(event).unwrap();
742+ let idx = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
743+ assert_eq!(
744+ idx, *expected,
745+ "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
746+ );
747+ }
748+ }
749+}
shared/src/bin/codegen.rsnew file+40
@@ -0,0 +1,40 @@
1+use std::path::PathBuf;
2+
3+use clap::{Parser, ValueEnum};
4+use crux_core::type_generation::facet::{Config, TypeRegistry};
5+use shared::OpencodeApp;
6+
7+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
8+enum Language {
9+ Kotlin,
10+}
11+
12+#[derive(Parser)]
13+#[command(version, about, long_about = None)]
14+struct Args {
15+ #[arg(short, long, value_enum)]
16+ language: Language,
17+ #[arg(short, long)]
18+ output_dir: PathBuf,
19+}
20+
21+fn main() -> anyhow::Result<()> {
22+ let args = Args::parse();
23+
24+ let typegen_app = TypeRegistry::new()
25+ .register_app::<OpencodeApp>()?
26+ .build()?;
27+
28+ let name = match args.language {
29+ Language::Kotlin => "soy.iko.opencode.core",
30+ };
31+ let config = Config::builder(name, &args.output_dir).build();
32+
33+ match args.language {
34+ Language::Kotlin => {
35+ typegen_app.kotlin(&config)?;
36+ }
37+ }
38+
39+ Ok(())
40+}
shared/src/ffi.rsnew file+53
@@ -0,0 +1,53 @@
1+use crux_core::{
2+ Core,
3+ bridge::{Bridge, EffectId},
4+};
5+
6+use crate::OpencodeApp;
7+
8+pub struct CoreFfi {
9+ core: Bridge<OpencodeApp>,
10+}
11+
12+impl Default for CoreFfi {
13+ fn default() -> Self {
14+ Self::new()
15+ }
16+}
17+
18+#[boltffi::export]
19+impl CoreFfi {
20+ #[must_use]
21+ pub fn new() -> Self {
22+ Self {
23+ core: Bridge::new(Core::new()),
24+ }
25+ }
26+
27+ #[must_use]
28+ pub fn update(&self, data: &[u8]) -> Vec<u8> {
29+ let mut effects = Vec::new();
30+ match self.core.update(data, &mut effects) {
31+ Ok(()) => effects,
32+ Err(e) => panic!("{e}"),
33+ }
34+ }
35+
36+ #[must_use]
37+ pub fn resolve(&self, id: u32, data: &[u8]) -> Vec<u8> {
38+ let mut effects = Vec::new();
39+ match self.core.resolve(EffectId(id), data, &mut effects) {
40+ Ok(()) => effects,
41+ Err(e) => panic!("{e}"),
42+ }
43+ }
44+
45+ #[must_use]
46+ pub fn view(&self) -> Vec<u8> {
47+ let mut view_model = Vec::new();
48+ match self.core.view(&mut view_model) {
49+ Ok(()) => view_model,
50+ Err(e) => panic!("{e}"),
51+ }
52+ }
53+}
shared/src/lib.rsnew file+6
@@ -0,0 +1,6 @@
1+#![allow(clippy::used_underscore_items)]
2+
3+mod app;
4+pub mod ffi;
5+
6+pub use app::*;