git.iko.soy/opencode-android
on master help sign in
Push vqopwwqwptzx (#4)

* Fix misleading failed-message a11y label, correct Sessions back-button content description, surface Copied snackbar in CrashLogDialog, and add a Live SSE indicator pill in the chat TopAppBar * Rebuild opencode-android from scratch: Crux core + Kotlin/Compose shell with SSE streaming, add CI/release workflows, update build system

commit59d09234d3440b68239332ffc0f2d46aa1668bf6 authoriko <mail@iko.soy> dateWed 8 Jul 2026, 19:32 (3 months ago) parentaed5cef
101 files changed+4431−7287
.github/workflows/build.yml+12 .github/workflows/release.yml+43−37 .gitignore+12−1 README.md+122−109 app/build.gradle.kts+48−42 app/proguard-rules.pro+15−7 app/src/main/AndroidManifest.xml+7−8 app/src/main/java/soy/iko/opencode/CoreViewModel.kt+48 app/src/main/java/soy/iko/opencode/CrashLogger.kt−102 app/src/main/java/soy/iko/opencode/MainActivity.kt+5−101 app/src/main/java/soy/iko/opencode/OpencodeApp.kt−10 app/src/main/java/soy/iko/opencode/core/Core.kt−238 app/src/main/java/soy/iko/opencode/core/CoreFfi.kt−44 app/src/main/java/soy/iko/opencode/core/HttpClient.kt+50−62 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+456 app/src/main/java/soy/iko/opencode/core/Protocol.kt+148 app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt−483 app/src/main/java/soy/iko/opencode/core/SseClient.kt+47−93 app/src/main/java/soy/iko/opencode/core/ViewModel.kt+76 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+60 app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt−129 app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt−216 app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt−90 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt−134 app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.kt−83 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+85−334 app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt−134 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+75−448 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+79−286 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+82−242 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt+19 app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt−53 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt−33 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+26−29 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt+7−62 app/src/main/res/values-night-v31/themes.xml−8 app/src/main/res/values-night/colors.xml+1−1 app/src/main/res/values-night/themes.xml+4−2 app/src/main/res/values-v31/themes.xml−12 app/src/main/res/values/colors.xml+1−1 app/src/main/res/values/strings.xml+2−101 app/src/main/res/values/themes.xml+9−7 app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt−284 build.gradle.kts−1 flake.nix+28−17 generated/build.gradle.kts−21 generated/com/novi/bincode/BincodeDeserializer.java+38 generated/com/novi/bincode/BincodeDeserializer.kt−39 generated/com/novi/bincode/BincodeSerializer.java+33 generated/com/novi/bincode/BincodeSerializer.kt−33 generated/com/novi/serde/ArrayLen.java+12 generated/com/novi/serde/BinaryDeserializer.java+195 generated/com/novi/serde/BinaryDeserializer.kt−182 generated/com/novi/serde/BinarySerializer.java+146 generated/com/novi/serde/BinarySerializer.kt−122 generated/com/novi/serde/Bytes.java+54 generated/com/novi/serde/Bytes.kt−39 generated/com/novi/serde/DeserializationError.java+11 generated/com/novi/serde/DeserializationError.kt−6 generated/com/novi/serde/Deserializer.java+56 generated/com/novi/serde/Deserializer.kt−76 generated/com/novi/serde/Int128.java+11 generated/com/novi/serde/Int128.kt−6 generated/com/novi/serde/SerdeByteArrayOutput.kt−48 generated/com/novi/serde/SerializationError.java+11 generated/com/novi/serde/SerializationError.kt−6 generated/com/novi/serde/Serializer.java+58 generated/com/novi/serde/Serializer.kt−77 generated/com/novi/serde/Slice.java+40 generated/com/novi/serde/Slice.kt−36 generated/com/novi/serde/Tuple2.java+40 generated/com/novi/serde/Tuple3.java+46 generated/com/novi/serde/Tuple4.java+54 generated/com/novi/serde/Tuple4.kt−6 generated/com/novi/serde/Tuple5.java+61 generated/com/novi/serde/Tuple5.kt−12 generated/com/novi/serde/Tuple6.java+67 generated/com/novi/serde/Tuple6.kt−13 generated/com/novi/serde/UInt128.kt−6 generated/com/novi/serde/Unit.java+20 generated/com/novi/serde/Unsigned.java+11 generated/soy/iko/opencode/core/Core.kt−1291 generated/soy/iko/opencode/shared/MessageStatus.java+150 generated/soy/iko/opencode/shared/MessageView.java+131 generated/soy/iko/opencode/shared/Requests.java+27 generated/soy/iko/opencode/shared/Screen.java+150 generated/soy/iko/opencode/shared/SessionView.java+77 generated/soy/iko/opencode/shared/ToolView.java+86 generated/soy/iko/opencode/shared/TraitHelpers.java+71 generated/soy/iko/opencode/shared/ViewModel.java+185 gradle.properties+7−2 gradle/libs.versions.toml+23−30 settings.gradle.kts+3−1 shared/Cargo.lock+4−362 shared/Cargo.toml+8−11 shared/build.gradle.kts−39 shared/src/app.rs+690−692 shared/src/bin/codegen.rs+27−32 shared/src/ffi.rs−53 shared/src/lib.rs+13−2 shared/src/protocol.rs+248
.github/workflows/build.yml+12
@@ -12,7 +12,19 @@ concurrency:
1212 cancel-in-progress: true
1313
1414jobs:
15+ # The Crux core is plain Rust — build and unit-test it natively (the Android
16+ # shell mirrors this logic; see README).
17+ core:
18+ name: Crux core
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - name: Test the Crux core
23+ working-directory: shared
24+ run: cargo test
25+
1526 build:
27+ name: Android app
1628 runs-on: ubuntu-latest
1729
1830 steps:
.github/workflows/release.yml+43−37
@@ -1,17 +1,22 @@
11name: Release
22
3−# Build a signed production 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.08.1430). It is passed to the Gradle build as
7−# `-PversionName=...` so it is stamped into the APK.
3+# Build a minified, signed release APK and publish it as a GitHub Release.
4+#
5+# Trigger by pushing a version tag (e.g. `git tag v1.2.0 && git push --tags`),
6+# or run manually from the Actions tab (workflow_dispatch), in which case the
7+# version is derived from the current UTC time as YYYY.MM.DD.HHMM.
8+#
9+# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
10+# with that keystore; otherwise the Gradle build falls back to the debug key so
11+# the released APK is still installable (see app/build.gradle.kts). The version
12+# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
813on:
914 push:
10− branches: [master]
15+ tags: ["v*"]
16+ workflow_dispatch:
1117
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.
18+# Never abort a release mid-flight — a half-published release with a missing
19+# artifact is worse than a slightly delayed one.
1520concurrency:
1621 group: ${{ github.workflow }}-${{ github.ref }}
1722 cancel-in-progress: false
@@ -20,9 +25,14 @@ jobs:
2025 release:
2126 runs-on: ubuntu-latest
2227 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
28+ contents: write # push the tag and create the release
29+ id-token: write # OIDC so GitHub can sign the provenance attestation
30+ attestations: write # publish the attestation record
31+ env:
32+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
33+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
34+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
35+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
2636 steps:
2737 - uses: actions/checkout@v4
2838 with:
@@ -30,14 +40,23 @@ jobs:
3040 # accurate instead of collapsing to 1 on a shallow clone.
3141 fetch-depth: 0
3242
33− - name: Compute version from commit date
43+ - name: Compute version
3444 id: version
3545 run: |
36− VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
46+ if [ "${{ github.ref_type }}" = "tag" ]; then
47+ VERSION="${GITHUB_REF_NAME#v}"
48+ TAG="${GITHUB_REF_NAME}"
49+ else
50+ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
51+ TAG="v${VERSION}"
52+ fi
3753 CODE="$(git rev-list --count HEAD)"
38− echo "version=$VERSION" >> "$GITHUB_OUTPUT"
39− echo "code=$CODE" >> "$GITHUB_OUTPUT"
40− echo "Version: $VERSION (code $CODE)"
54+ {
55+ echo "version=$VERSION"
56+ echo "tag=$TAG"
57+ echo "code=$CODE"
58+ } >> "$GITHUB_OUTPUT"
59+ echo "Releasing $TAG (version $VERSION, code $CODE)"
4160
4261 - uses: actions/setup-java@v4
4362 with:
@@ -49,43 +68,30 @@ jobs:
4968 - uses: gradle/actions/setup-gradle@v4
5069
5170 # The keystore ships as base64 in a secret so it never touches the repo.
52− # When the secret isn't configured the Gradle build falls back to an
53− # unsigned APK (the same behavior as a local build), so this workflow is
54− # green even before signing is wired up.
71+ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
5572 - name: Decode signing keystore
5673 if: env.OPENCODE_STORE_BASE64 != ''
57− env:
58− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
5974 run: |
6075 STORE_FILE="$RUNNER_TEMP/release.keystore"
6176 printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
6277 echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
6378
6479 - name: Build release APK
65− env:
66− OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
67− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
68− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
69− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
7080 run: |
7181 ./gradlew assembleRelease \
7282 -PversionName="${{ steps.version.outputs.version }}" \
83+ -PversionCode="${{ steps.version.outputs.code }}" \
7384 --no-daemon
7485
75− # Rename to a stable, version-tagged filename so the release attachment is
76− # recognizable regardless of the release/ vs release-signed/ output dir.
86+ # Rename to a stable, version-tagged filename for the release attachment.
7787 - name: Stage APK
7888 run: |
7989 mkdir -p staging
80− APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
90+ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
8191 cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
8292
83− # Generate a signed build-provenance attestation for the staged APK and
84− # store it in GitHub's artifact attestation store, keyed by the file's
85− # digest. Anyone can later verify the APK's origin/provenance with
86− # `gh attestation verify` -- its repository & workflow. Run *after*
87− # staging and *before* the release is published so the attested subject
88− # is byte-identical to the artifact attached to the release.
93+ # Signed build-provenance attestation for the exact artifact being
94+ # published; later verifiable with `gh attestation verify <apk>`.
8995 - name: Attest build provenance
9096 uses: actions/attest-build-provenance@v2
9197 with:
@@ -94,7 +100,7 @@ jobs:
94100 - name: Create release
95101 uses: softprops/action-gh-release@v2
96102 with:
97− tag_name: ${{ steps.version.outputs.version }}
103+ tag_name: ${{ steps.version.outputs.tag }}
98104 name: ${{ steps.version.outputs.version }}
99105 make_latest: true
100106 generate_release_notes: true
.gitignore+12−1
@@ -1,6 +1,8 @@
11# Gradle
22.gradle/
33build/
4+*.apk
5+*.aab
46
57# Kotlin
68.kotlin/
@@ -29,12 +31,21 @@ Thumbs.db
2931
3032# Nix
3133result
34+result-*
3235.direnv/
3336
37+# Local tooling / editor state (not part of the project)
38+.crush/
39+.crush.db
40+*.log
41+
3442# Rust
3543shared/target/
44+/target/
3645
37−# Generated Kotlin types (regenerated from Rust core)
46+# The `generated/` shared type sources ARE committed (regenerable via
47+# `cargo run --features typegen --bin codegen`); only the built JNI .so
48+# artifacts are ignored.
3849generated/jniLibs/
3950
4051# Gradle wrapper jar is committed so CI can run ./gradlew
README.md+122−109
@@ -1,140 +1,153 @@
11# opencode-android
22
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.
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4+`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5+and **Jetpack Compose**.
6+
7+Connect to a running `opencode serve`, browse and create sessions, and chat with
8+the agent — assistant replies (text, reasoning, and tool calls) stream in live
9+over the server's SSE event stream.
10+
11+<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
512
613## Architecture
714
15+The app follows the Crux pattern: **all application logic lives in a pure,
16+platform-agnostic core; the platform is a thin shell** that renders the core's
17+view model, sends it events, and performs the side effects it requests.
18+
819```
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−└─────────────────────────────────────────────────┘
20+┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
21+│ Crux core — the single source of truth │
22+│ • Model / Event / Effect / ViewModel │
23+│ • update(): connect+auth, sessions, chat, SSE stream application │
24+│ • crux_http for HTTP effects; unit-tested with `cargo test` │
25+└───────────────────────────────────────────────────────────────────────┘
26+ ▲ mirrored, behaviour-for-behaviour
27+┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
28+│ Jetpack Compose shell │
29+│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
30+│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
31+│ • Compose UI: Connect / Sessions / Chat │
32+└───────────────────────────────────────────────────────────────────────┘
3233```
3334
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−```
35+### Why a Kotlin port instead of a JNI `.so`?
7336
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
37+Crux normally compiles the Rust core to a native library and the shell talks to
38+it over an FFI bridge. That requires an Android cross-compilation toolchain
39+(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40+environment intentionally ships **without** an NDK, so instead:
41+
42+- The **Rust core is the canonical specification** of behaviour and is fully
43+ unit-tested (`cd shared && cargo test`).
44+- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45+ reducer, driving an identical `UiState`/`ViewModel`.
46+
47+The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48+can be swapped for the compiled `.so` behind the same seam. The crate already
49+builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50+type-generation.
51+
52+## Project layout
7853
79−# Build and pack with BoltFFI
80−boltffi pack android
8154```
55+flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56+shared/ Crux core (Rust)
57+ src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58+ src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59+ src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60+generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61+app/ Android app (Kotlin + Compose)
62+ src/main/java/soy/iko/opencode/
63+ core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64+ ui/ Compose screens (Connect/Sessions/Chat), theme, components
65+ MainActivity.kt, CoreViewModel.kt
66+```
67+
68+## Getting started
69+
70+### Dev shell
71+
72+Everything you need is provided by the Nix flake:
8273
83−### Build the APK
8474```sh
85−./gradlew assembleDebug
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
8676```
8777
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.
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79+build-tools 35.0.0) on your `PATH`.
80+
81+### Build & test
9182
92−### Run tests
9383```sh
94−# Rust core tests
84+# Rust core — logic + streaming unit tests
9585cd shared && cargo test
9686
97−# Kotlin shell tests
98−./gradlew testDebugUnitTest
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89+
90+# Android unit tests + lint
91+./gradlew testDebugUnitTest lintDebug
9992```
10093
101−## Basic Auth Detection
94+### Run against a server
95+
96+Start opencode's server on your machine or LAN:
97+
98+```sh
99+opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
100+```
101+
102+Install the APK, launch the app, and enter the server URL (e.g.
103+`http://192.168.1.10:4096`). If the server was started with a password the app
104+detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105+
106+> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
107+
108+## The opencode API this client speaks
102109
103−The opencode server uses HTTP Basic Auth when `OPENCODE_SERVER_PASSWORD` is set
104−(username defaults to `opencode`). The client detects this automatically:
110+- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111+ HTTP Basic auth.
112+- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113+- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114+- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115+ streams over SSE).
116+- **Cancel** — `POST /session/{id}/abort`.
117+- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118+ `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119+ `session.idle` (clears the "generating" state), `session.error`, and
120+ `session.{created,updated,deleted}` incrementally. Unknown event types are
121+ ignored so new server versions don't break the stream.
105122
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"
123+## Continuous integration & releases
114124
115−## Crash Logs
125+- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126+ unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127+ and builds the debug APK (uploaded as an artifact).
128+- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129+ release — it builds a minified, signed release APK and publishes a GitHub
130+ Release with the APK attached and a signed build-provenance attestation.
116131
117−`CrashLogger` installs as the global `Thread.UncaughtExceptionHandler` in
118−`OpencodeApp.onCreate()`. When a crash occurs:
132+ ```sh
133+ git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134+ ```
119135
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()`
136+ The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137+ timestamp), stamped into the APK via `-PversionName/-PversionCode`.
124138
125−The crash log count is displayed on the Connect screen.
139+ **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140+ keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141+ `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142+ build falls back to the debug key, so the APK is still installable.
126143
127−## API Coverage
144+## Notes & limitations
128145
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
146+- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147+ **permission** prompts (`permission.updated`) are not yet surfaced.
148+- Model/agent selection uses the server's configured default (no picker yet).
149+- No `.so` is built in this environment (see *Architecture* above).
136150
137−## Configuration
151+## License
138152
139−The default server URL is `http://localhost:4096`. Change it in the Connect screen.
140−`android:usesCleartextTraffic="true"` is enabled for local development.
153+Apache-2.0.
app/build.gradle.kts+48−42
@@ -1,5 +1,3 @@
1−import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2−
31plugins {
42 alias(libs.plugins.android.application)
53 alias(libs.plugins.kotlin.android)
@@ -7,91 +5,99 @@ plugins {
75 alias(libs.plugins.kotlin.serialization)
86}
97
8+// Version is overridable from the release workflow via
9+// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10+val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11+val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
12+
13+// A release keystore is supplied by CI (decoded from a secret) via env vars.
14+// When it's absent — local builds, or CI before signing is configured — the
15+// release build falls back to the debug signing config so the APK is still
16+// installable, rather than producing an uninstallable unsigned APK.
17+val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19+
1020android {
1121 namespace = "soy.iko.opencode"
12− compileSdk = 37
13− buildToolsVersion = "37.0.0"
22+ compileSdk = 35
1423
1524 defaultConfig {
1625 applicationId = "soy.iko.opencode"
1726 minSdk = 26
18− targetSdk = 36
19− versionCode = 1
20− versionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
27+ targetSdk = 35
28+ versionCode = appVersionCode
29+ versionName = appVersionName
30+
2131 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2232 }
2333
2434 signingConfigs {
25− create("release") {
26− storeFile = System.getenv("OPENCODE_STORE_FILE")?.let { file(it) }
27− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
28− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
29− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
35+ if (hasReleaseKeystore) {
36+ create("release") {
37+ storeFile = file(releaseStorePath!!)
38+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
39+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
40+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41+ }
3042 }
3143 }
3244
3345 buildTypes {
3446 release {
35− isMinifyEnabled = false
36− // Only sign when the keystore env vars are present (CI / local
37− // signing). Without them Gradle falls back to an unsigned APK.
38− if (System.getenv("OPENCODE_STORE_FILE") != null) {
39− signingConfig = signingConfigs.getByName("release")
40− }
47+ isMinifyEnabled = true
4148 proguardFiles(
4249 getDefaultProguardFile("proguard-android-optimize.txt"),
4350 "proguard-rules.pro",
4451 )
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
4557 }
4658 }
4759
4860 compileOptions {
49− sourceCompatibility = JavaVersion.VERSION_11
50− targetCompatibility = JavaVersion.VERSION_11
61+ sourceCompatibility = JavaVersion.VERSION_17
62+ targetCompatibility = JavaVersion.VERSION_17
5163 }
5264
53− kotlin {
54− compilerOptions {
55− jvmTarget = JvmTarget.JVM_11
56− }
65+ kotlinOptions {
66+ jvmTarget = "17"
5767 }
5868
5969 buildFeatures {
6070 compose = true
6171 }
72+
73+ packaging {
74+ resources {
75+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
76+ }
77+ }
6278}
6379
6480dependencies {
65− implementation(project(":shared"))
66−
6781 implementation(libs.androidx.core.ktx)
6882 implementation(libs.androidx.lifecycle.runtime.ktx)
83+ implementation(libs.androidx.lifecycle.runtime.compose)
84+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
85+ implementation(libs.androidx.lifecycle.viewmodel.compose)
6986 implementation(libs.androidx.activity.compose)
87+
7088 implementation(platform(libs.androidx.compose.bom))
7189 implementation(libs.androidx.compose.ui)
7290 implementation(libs.androidx.compose.ui.graphics)
7391 implementation(libs.androidx.compose.ui.tooling.preview)
7492 implementation(libs.androidx.compose.material3)
7593 implementation(libs.androidx.compose.material.icons.extended)
76− implementation(libs.lifecycle.viewmodel.compose)
94+ debugImplementation(libs.androidx.compose.ui.tooling)
7795
7896 implementation(libs.kotlinx.coroutines.android)
79− implementation(libs.markdown.renderer.core)
80− implementation(libs.markdown.renderer.m3)
81−
82− implementation(libs.ktor.client.core)
83− implementation(libs.ktor.client.okhttp)
84− implementation(libs.ktor.client.content.negotiation)
85− implementation(libs.ktor.serialization.kotlinx.json)
86− implementation(libs.ktor.client.logging)
87− implementation(libs.ktor.client.auth)
97+ implementation(libs.kotlinx.serialization.json)
98+ implementation(libs.okhttp)
99+ implementation(libs.okhttp.sse)
88100
89101 testImplementation(libs.junit)
90102 testImplementation(libs.kotlinx.coroutines.test)
91− androidTestImplementation(libs.androidx.junit)
92− androidTestImplementation(libs.androidx.espresso.core)
93− androidTestImplementation(platform(libs.androidx.compose.bom))
94− androidTestImplementation(libs.androidx.compose.ui.test.junit4)
95− debugImplementation(libs.androidx.compose.ui.tooling)
96− debugImplementation(libs.androidx.compose.ui.test.manifest)
97103}
app/proguard-rules.pro+15−7
@@ -1,8 +1,16 @@
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.** { *; }
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2+-keepattributes *Annotation*, InnerClasses
3+-dontnote kotlinx.serialization.**
54
6−# Ktor
7−-keep class io.ktor.** { *; }
8−-dontwarn io.ktor.**
5+-keepclassmembers class **$$serializer { *; }
6+-keepclasseswithmembers class * {
7+ @kotlinx.serialization.Serializable <methods>;
8+}
9+-if @kotlinx.serialization.Serializable class **
10+-keep, allowobfuscation, allowshrinking class <1>
11+
12+# OkHttp / okio — platform-specific bits referenced reflectively.
13+-dontwarn okhttp3.internal.platform.**
14+-dontwarn org.conscrypt.**
15+-dontwarn org.bouncycastle.**
16+-dontwarn org.openjsse.**
app/src/main/AndroidManifest.xml+7−8
@@ -1,27 +1,26 @@
11<?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">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
43
54 <uses-permission android:name="android.permission.INTERNET" />
65 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
76
87 <application
9− android:name=".OpencodeApp"
108 android:allowBackup="true"
9+ android:icon="@android:drawable/sym_def_app_icon"
1110 android:label="@string/app_name"
1211 android:supportsRtl="true"
13− android:theme="@style/Theme.Opencode"
14− android:usesCleartextTraffic="true"
15− tools:targetApi="31">
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
14+
1615 <activity
1716 android:name=".MainActivity"
1817 android:exported="true"
19− android:theme="@style/Theme.Opencode">
18+ android:windowSoftInputMode="adjustResize"
19+ android:theme="@style/Theme.OpencodeAndroid">
2020 <intent-filter>
2121 <action android:name="android.intent.action.MAIN" />
2222 <category android:name="android.intent.category.LAUNCHER" />
2323 </intent-filter>
2424 </activity>
2525 </application>
26−
2726</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+48
@@ -0,0 +1,48 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import androidx.core.content.edit
6+import androidx.lifecycle.AndroidViewModel
7+import androidx.lifecycle.viewModelScope
8+import kotlinx.coroutines.flow.distinctUntilChangedBy
9+import kotlinx.coroutines.launch
10+import soy.iko.opencode.core.Event
11+import soy.iko.opencode.core.OpencodeCore
12+
13+/**
14+ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15+ * connection) survives configuration changes. It also persists the last server
16+ * URL / username so reconnecting is one tap.
17+ */
18+class CoreViewModel(app: Application) : AndroidViewModel(app) {
19+ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20+
21+ val core = OpencodeCore(viewModelScope)
22+
23+ init {
24+ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25+ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26+
27+ // Persist connection details once a connection succeeds.
28+ viewModelScope.launch {
29+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
30+ if (state.connected) {
31+ prefs.edit {
32+ putString(KEY_URL, state.serverUrl)
33+ putString(KEY_USER, state.username)
34+ }
35+ }
36+ }
37+ }
38+ }
39+
40+ override fun onCleared() {
41+ core.shutdown()
42+ }
43+
44+ private companion object {
45+ const val KEY_URL = "serverUrl"
46+ const val KEY_USER = "username"
47+ }
48+}
app/src/main/java/soy/iko/opencode/CrashLogger.ktdeleted−102
@@ -1,102 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.flow.MutableStateFlow
6−import kotlinx.coroutines.flow.StateFlow
7−import kotlinx.coroutines.flow.asStateFlow
8−import java.io.File
9−import java.io.PrintWriter
10−import java.io.StringWriter
11−import java.text.SimpleDateFormat
12−import java.util.Date
13−import java.util.Locale
14−
15−/**
16− * Crash reporter that installs as the global [Thread.UncaughtExceptionHandler].
17− *
18− * Crashes are written to logcat and persisted to internal storage
19− * (`filesDir/crashes/`) so they survive process death. The persisted reports
20− * can be enumerated with [getReports], exported, or cleared with [clearReports].
21− *
22− * Call [init] once in [Application.onCreate] before any other work.
23− */
24−object CrashLogger {
25−
26− private const val TAG = "opencode-crash"
27− private const val CRASH_DIR = "crashes"
28− private const val MAX_REPORTS = 10
29−
30− private lateinit var appContext: Context
31− private var previousHandler: Thread.UncaughtExceptionHandler? = null
32−
33− private val _reportCount = MutableStateFlow(0)
34− /** Live count of persisted crash reports, safe to observe in composition. */
35− val reportCount: StateFlow<Int> = _reportCount.asStateFlow()
36−
37− fun init(context: Context) {
38− appContext = context.applicationContext
39− refreshReportCount()
40− previousHandler = Thread.getDefaultUncaughtExceptionHandler()
41− Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
42− report(throwable, thread.name)
43− previousHandler?.uncaughtException(thread, throwable)
44− }
45− }
46−
47− private fun refreshReportCount() {
48− _reportCount.value = getReports().size
49− }
50−
51− /**
52− * Persist a crash report and log it. Safe to call from any thread,
53− * including the crashing thread.
54− */
55− fun report(throwable: Throwable, source: String = Thread.currentThread().name) {
56− val stackTrace = stackTraceString(throwable)
57− Log.e(TAG, "Uncaught exception in [$source]\n$stackTrace", throwable)
58− persistReport(source, stackTrace)
59− }
60−
61− /**
62− * Enumerate persisted crash report files, newest first.
63− */
64− fun getReports(): List<File> =
65− crashDir().listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
66−
67− /**
68− * Delete all persisted crash reports.
69− */
70− fun clearReports() {
71− crashDir().listFiles()?.forEach { it.delete() }
72− refreshReportCount()
73− }
74−
75− private fun persistReport(source: String, stackTrace: String) {
76− try {
77− val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
78− val file = File(crashDir(), "crash_${timestamp}.txt")
79− file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
80− pruneOldReports()
81− refreshReportCount()
82− } catch (e: Exception) {
83− Log.e(TAG, "Failed to persist crash report", e)
84− }
85− }
86−
87− private fun pruneOldReports() {
88− val reports = getReports()
89− if (reports.size > MAX_REPORTS) {
90− reports.drop(MAX_REPORTS).forEach { it.delete() }
91− }
92− }
93−
94− private fun crashDir(): File =
95− File(appContext.filesDir, CRASH_DIR).apply { mkdirs() }
96−
97− private fun stackTraceString(throwable: Throwable): String {
98− val sw = StringWriter()
99− throwable.printStackTrace(PrintWriter(sw))
100− return sw.toString()
101− }
102−}
app/src/main/java/soy/iko/opencode/MainActivity.kt+5−101
@@ -3,117 +3,21 @@ package soy.iko.opencode
33import android.os.Bundle
44import androidx.activity.ComponentActivity
55import androidx.activity.compose.setContent
6−import androidx.activity.enableEdgeToEdge
7−import androidx.activity.compose.BackHandler
8−import androidx.compose.animation.AnimatedContent
9−import androidx.compose.animation.togetherWith
10−import androidx.compose.animation.fadeIn
11−import androidx.compose.animation.fadeOut
12−import androidx.compose.animation.slideInHorizontally
13−import androidx.compose.animation.slideOutHorizontally
14−import androidx.compose.material3.SnackbarDuration
15−import androidx.compose.material3.SnackbarHost
16−import androidx.compose.material3.SnackbarHostState
17−import androidx.compose.runtime.Composable
18−import androidx.compose.runtime.LaunchedEffect
19−import androidx.compose.runtime.collectAsState
206import androidx.compose.runtime.getValue
21−import androidx.compose.runtime.mutableLongStateOf
22−import androidx.compose.runtime.remember
23−import androidx.compose.runtime.setValue
24−import androidx.compose.foundation.layout.padding
25−import androidx.compose.ui.Modifier
26−import androidx.compose.ui.platform.LocalContext
27−import androidx.compose.ui.res.stringResource
7+import androidx.lifecycle.compose.collectAsStateWithLifecycle
288import androidx.lifecycle.viewmodel.compose.viewModel
29−import soy.iko.opencode.core.Core
30−import soy.iko.opencode.core.Event
31−import soy.iko.opencode.core.Screen
32−import soy.iko.opencode.ui.screens.ChatScreen
33−import soy.iko.opencode.ui.screens.ConnectScreen
34−import soy.iko.opencode.ui.screens.SessionsScreen
9+import soy.iko.opencode.ui.OpencodeApp
3510import soy.iko.opencode.ui.theme.OpencodeTheme
3611
3712class MainActivity : ComponentActivity() {
3813 override fun onCreate(savedInstanceState: Bundle?) {
3914 super.onCreate(savedInstanceState)
40− enableEdgeToEdge()
4115 setContent {
4216 OpencodeTheme {
43− App()
17+ val vm: CoreViewModel = viewModel()
18+ val state by vm.core.view.collectAsStateWithLifecycle()
19+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
4420 }
4521 }
4622 }
4723}
48−
49−@Composable
50−fun App(core: Core = viewModel()) {
51− val view by core.view.collectAsState()
52− val context = LocalContext.current
53− var lastBackPress by remember { mutableLongStateOf(0L) }
54− val snackbarHostState = remember { SnackbarHostState() }
55− val exitHint = stringResource(R.string.press_back_again_to_exit)
56−
57− // Map the system back button to logical in-app navigation so it never
58− // silently exits the app from a sub-screen. On the root Connect screen,
59− // require a double press to exit (with a snackbar hint the first time).
60− BackHandler(enabled = true) {
61− when (view.screen) {
62− Screen.CHAT -> core.update(Event.NavigateToSessions)
63− Screen.SESSIONS -> core.update(Event.NavigateToConnect)
64− Screen.CONNECT -> {
65− val now = System.currentTimeMillis()
66− if (now - lastBackPress < EXIT_CONFIRMATION_MS) {
67− (context as? android.app.Activity)?.finish()
68− } else {
69− lastBackPress = now
70− }
71− }
72− }
73− }
74−
75− // Show the "press back again to exit" hint via a snackbar instead of a
76− // toast, for consistency with the rest of the app's feedback.
77− LaunchedEffect(lastBackPress) {
78− if (lastBackPress > 0L && view.screen == Screen.CONNECT) {
79− snackbarHostState.showSnackbar(
80− message = exitHint,
81− duration = SnackbarDuration.Short,
82− )
83− }
84− }
85−
86− // Directional slide between screens for a smoother navigation feel:
87− // forward navigation slides the new screen in from the end side, back
88− // navigation slides it in from the start side. A short fade is layered on
89− // so the transition doesn't feel harsh. The TopAppBar still fades with
90− // each screen, but the directional motion reads as natural page changes
91− // rather than a flat crossfade.
92− androidx.compose.material3.Scaffold(
93− snackbarHost = { SnackbarHost(snackbarHostState) },
94− ) { padding ->
95− AnimatedContent(
96− targetState = view.screen,
97− transitionSpec = {
98− val forward = targetState.ordinal > initialState.ordinal
99− if (forward) {
100− slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
101− slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
102− } else {
103− slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
104− slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
105− }
106− },
107− label = "screen-transition",
108− modifier = Modifier.padding(padding),
109− ) { screen ->
110− when (screen) {
111− Screen.CONNECT -> ConnectScreen(core)
112− Screen.SESSIONS -> SessionsScreen(core)
113− Screen.CHAT -> ChatScreen(core)
114− }
115− }
116− }
117−}
118−
119−private val EXIT_CONFIRMATION_MS = 2000L
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−10
@@ -1,10 +0,0 @@
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.ktdeleted−238
@@ -1,238 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import android.app.Application
4−import androidx.lifecycle.AndroidViewModel
5−import androidx.lifecycle.viewModelScope
6−import kotlinx.coroutines.flow.MutableSharedFlow
7−import kotlinx.coroutines.flow.MutableStateFlow
8−import kotlinx.coroutines.flow.SharedFlow
9−import kotlinx.coroutines.flow.StateFlow
10−import kotlinx.coroutines.flow.asSharedFlow
11−import kotlinx.coroutines.flow.asStateFlow
12−import kotlinx.coroutines.launch
13−import soy.iko.opencode.CrashLogger
14−
15−/**
16− * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
17− * Jetpack Compose UI.
18− *
19− * - Sends [Event]s to the core via [update]
20− * - Processes [Effect]s returned by the core (Render, Http)
21− * - Exposes the current [ViewModel] as a [StateFlow] for Compose to observe
22− *
23− * Basic-auth detection is handled entirely in the Rust core: when the server
24− * returns 401, the core sets [ViewModel.authRequired] and the UI shows
25− * credential fields. Once the user supplies credentials and re-connects, the
26− * core attaches the `Authorization` header to all subsequent requests.
27− *
28− * Crash logs from [CrashLogger] are forwarded to the core as
29− * [Event.CrashLog] events so they appear in the ViewModel.
30− */
31−class Core(application: Application) : AndroidViewModel(application) {
32− private val ffi: CoreFfi = CoreFfi.create()
33− private val httpClient = HttpClient()
34− private val sseClient = SseClient()
35−
36− private val prefs = application.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
37−
38− private val _view = MutableStateFlow(getInitialView())
39− val view: StateFlow<ViewModel> = _view.asStateFlow()
40−
41− /** Live status of the SSE event stream, for the "disconnected" banner. */
42− val sseState: StateFlow<SseState> = sseClient.state
43−
44− /**
45− * Transient, user-facing success/info notifications (e.g. "Session
46− * created", "Connected"). Consumed by the UI as a snackbar. This is a
47− * SharedFlow so a configuration change doesn't re-fire an old message.
48− */
49− private val _info = MutableSharedFlow<String>(extraBufferCapacity = 4)
50− val info: SharedFlow<String> = _info.asSharedFlow()
51−
52− /** The last event the user triggered, so an error snackbar can retry it. */
53− private var lastUserEvent: Event? = null
54− val retryableEvent: Event? get() = lastUserEvent
55−
56− init {
57− // Restore the persisted server URL before the first connect so the
58− // user doesn't have to re-enter it on every launch.
59− val savedUrl = prefs.getString(KEY_SERVER_URL, null)
60− if (savedUrl != null) {
61− update(Event.ServerUrlChanged(savedUrl))
62− }
63− update(Event.Start)
64−
65− viewModelScope.launch {
66− sseClient.events.collect { data ->
67− update(Event.EventReceived(data))
68− }
69− }
70−
71− forwardCrashLogs()
72− }
73−
74− fun update(event: Event) {
75− // Track user-initiated events so an error snackbar can offer Retry.
76− if (event.isRetryable()) {
77− lastUserEvent = event
78− }
79− val effectsBytes = ffi.update(event.bincodeSerialize())
80− val requests = Requests.bincodeDeserialize(effectsBytes).value
81− for (request in requests) {
82− processEffect(request)
83− }
84− }
85−
86− /**
87− * Re-send the last user-initiated event, if any. Wired to the snackbar's
88− * "Retry" action.
89− */
90− fun retry() {
91− val last = lastUserEvent ?: return
92− update(last)
93− }
94−
95− /** Re-establish the SSE stream after a drop. Wired to the banner button. */
96− fun reconnectSse() {
97− sseClient.reconnect()
98− }
99−
100− /** Surface a transient "Copied" toast/snackbar to the user. */
101− fun notifyCopied() {
102− viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103− }
104−
105− /** Surface a transient "Session deleted" snackbar to the user. */
106− fun notifyDeleted() {
107− viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
108− }
109−
110− private fun processEffect(request: Request) {
111− when (val effect = request.effect) {
112− is Effect.Render -> {
113− refreshView()
114− emitSuccessInfo()
115− }
116− is Effect.Http -> {
117− handleHttpEffect(effect.value, request.id)
118− }
119− }
120− }
121−
122− private fun handleHttpEffect(request: HttpRequest, requestId: UInt) {
123− viewModelScope.launch {
124− val result = httpClient.request(request)
125− val effectsBytes = ffi.resolve(requestId, result.bincodeSerialize())
126− val requests = Requests.bincodeDeserialize(effectsBytes).value
127− for (req in requests) {
128− processEffect(req)
129− }
130− refreshView()
131− emitSuccessInfo()
132−
133− val currentView = _view.value
134− if (currentView.connected && currentView.currentSessionId != null) {
135− sseClient.connect("${currentView.serverUrl}/event")
136− }
137− }
138− }
139−
140− /**
141− * Derive a brief success/info notification from the latest view state —
142− * e.g. when the user just connected or created a session. The UI surfaces
143− * these as a snackbar so the user gets positive feedback, not just screen
144− * changes.
145− */
146− private fun emitSuccessInfo() {
147− val v = _view.value
148− val prev = previousScreen
149− when {
150− v.connected && prev == Screen.CONNECT -> {
151− viewModelScope.launch { _info.emit(SUCCESS_CONNECTED) }
152− }
153− v.currentSessionId != null && prev == Screen.SESSIONS &&
154− lastUserEvent is Event.CreateSession -> {
155− viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
156− }
157− lastUserEvent is Event.DeleteSession && v.error == null -> {
158− viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
159− }
160− }
161− previousScreen = v.screen
162− }
163−
164− private var previousScreen: Screen = _view.value.screen
165−
166− private fun refreshView() {
167− val newView = ViewModel.bincodeDeserialize(ffi.view())
168− _view.value = newView
169− // Persist the server URL whenever it changes so it survives relaunch.
170− prefs.edit().putString(KEY_SERVER_URL, newView.serverUrl).apply()
171− }
172−
173− /**
174− * Forward any persisted crash reports to the core so they appear in the
175− * ViewModel's crash-log summary. This runs once on init.
176− *
177− * Note: [Event.CrashLog] is an internal event skipped from type generation,
178− * so it's only available when the native core is loaded. In the pure-Kotlin
179− * fallback, crash logs are tracked separately via [CrashLogger.getReports].
180− */
181− private fun forwardCrashLogs() {
182− val reports = CrashLogger.getReports()
183− // Crash logs are surfaced in the UI via CrashLogger directly.
184− // The native core would receive Event::CrashLog, but that variant
185− // is #[facet(skip)] so it's not in the generated Kotlin Event type.
186− // The PureCoreFfi fallback tracks crashes in its own model.
187− }
188−
189− private fun getInitialView(): ViewModel {
190− return try {
191− ViewModel.bincodeDeserialize(ffi.view())
192− } catch (e: Throwable) {
193− val defaultUrl = prefs.getString(KEY_SERVER_URL, "http://localhost:4096") ?: "http://localhost:4096"
194− ViewModel(
195− screen = Screen.CONNECT,
196− serverUrl = defaultUrl,
197− username = "",
198− password = "",
199− authRequired = false,
200− connected = false,
201− loading = false,
202− error = null,
203− sessions = emptyList(),
204− currentSessionId = null,
205− currentSessionTitle = "",
206− messages = emptyList(),
207− draftMessage = "",
208− generating = false,
209− crashLogCount = 0u,
210− latestCrashLog = null,
211− )
212− }
213− }
214−
215− override fun onCleared() {
216− sseClient.disconnect()
217− }
218−
219− private fun Event.isRetryable(): Boolean = when (this) {
220− is Event.Connect,
221− is Event.LoadSessions,
222− is Event.CreateSession,
223− is Event.LoadMessages,
224− is Event.SendMessage,
225− is Event.DeleteSession -> true
226− else -> false
227− }
228−
229− companion object {
230− private const val PREFS_NAME = "opencode_prefs"
231− private const val KEY_SERVER_URL = "server_url"
232−
233− const val SUCCESS_CONNECTED = "__success_connected__"
234− const val SUCCESS_SESSION_CREATED = "__success_session_created__"
235− const val SUCCESS_COPIED = "__success_copied__"
236− const val SUCCESS_SESSION_DELETED = "__success_session_deleted__"
237− }
238−}
app/src/main/java/soy/iko/opencode/core/CoreFfi.ktdeleted−44
@@ -1,44 +0,0 @@
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.kt+50−62
@@ -1,76 +1,64 @@
11package soy.iko.opencode.core
22
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
133import kotlinx.coroutines.Dispatchers
4+import kotlinx.coroutines.suspendCancellableCoroutine
145import kotlinx.coroutines.withContext
15−import java.net.SocketTimeoutException
16−import java.net.UnknownHostException
17−import kotlin.coroutines.cancellation.CancellationException
6+import okhttp3.Call
7+import okhttp3.Callback
8+import okhttp3.MediaType.Companion.toMediaType
9+import okhttp3.OkHttpClient
10+import okhttp3.Request
11+import okhttp3.RequestBody.Companion.toRequestBody
12+import okhttp3.Response
13+import java.io.IOException
14+import java.util.concurrent.TimeUnit
15+import kotlin.coroutines.resume
16+import kotlin.coroutines.resumeWithException
17+
18+/** A completed HTTP response (any status code — non-2xx is not an error here). */
19+data class HttpResp(val code: Int, val body: String)
1820
1921/**
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.
22+ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23+ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24+ * carrying its status code, matching the Rust core's `HttpResult` handling.
2725 */
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− }
26+class HttpClient(
27+ val okhttp: OkHttpClient = OkHttpClient.Builder()
28+ .connectTimeout(15, TimeUnit.SECONDS)
29+ .readTimeout(30, TimeUnit.SECONDS)
30+ .build(),
31+) {
32+ private val jsonMedia = "application/json".toMediaType()
33+
34+ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35+ exec(Request.Builder().url(url).get(), auth)
36+
37+ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38+ exec(Request.Builder().url(url).post(body.toRequestBody(jsonMedia)), auth)
39+
40+ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41+ exec(Request.Builder().url(url).delete(), auth)
3642
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− }
43+ private suspend fun exec(builder: Request.Builder, auth: String?): Result<HttpResp> = withContext(Dispatchers.IO) {
44+ if (auth != null) builder.header("Authorization", auth)
45+ val request = builder.build()
46+ runCatching { await(okhttp.newCall(request)) }
4647 }
4748
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)
49+ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
50+ cont.invokeOnCancellation { runCatching { call.cancel() } }
51+ call.enqueue(object : Callback {
52+ override fun onFailure(call: Call, e: IOException) {
53+ if (cont.isActive) cont.resumeWithException(e)
5854 }
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− }
7055
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")
56+ override fun onResponse(call: Call, response: Response) {
57+ response.use {
58+ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
59+ if (cont.isActive) cont.resume(HttpResp(it.code, body))
60+ }
61+ }
62+ })
7563 }
7664}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktnew file+456
@@ -0,0 +1,456 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.delay
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.StateFlow
7+import kotlinx.coroutines.flow.asStateFlow
8+import kotlinx.coroutines.launch
9+import java.util.Base64
10+
11+/**
12+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
13+ *
14+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
15+ * observable [view]. Effects are executed here in the shell: REST calls through
16+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
17+ * server-sent event is fed back in and applied incrementally, so assistant
18+ * replies stream in token-by-token.
19+ *
20+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
21+ * network I/O hops to a background thread inside the clients.
22+ */
23+class OpencodeCore(
24+ private val scope: CoroutineScope,
25+ private val http: HttpClient = HttpClient(),
26+) {
27+ // ── Model ────────────────────────────────────────────────────────────────
28+ private var serverUrl = ""
29+ private var username = ""
30+ private var password = ""
31+ private var authRequired = false
32+ private var authed = false
33+ private var connected = false
34+ private var loading = false
35+ private var error: String? = null
36+ private var screen = Screen.Connect
37+ private val sessions = mutableListOf<SessionView>()
38+ private var currentSessionId: String? = null
39+ private var currentSessionTitle = ""
40+ private val messages = mutableListOf<MsgState>()
41+ private var draft = ""
42+ private var generating = false
43+ private var localSeq = 0L
44+
45+ private val _view = MutableStateFlow(snapshot())
46+ val view: StateFlow<UiState> = _view.asStateFlow()
47+
48+ private var sse: SseClient? = null
49+ private var sseClosedIntentionally = false
50+
51+ // ── Event entry point ──────────────────────────────────────────────────────
52+ fun dispatch(event: Event) {
53+ when (event) {
54+ is Event.ServerUrlChanged -> { serverUrl = normalizeUrl(event.url); emit() }
55+ is Event.UsernameChanged -> { username = event.value; emit() }
56+ is Event.PasswordChanged -> { password = event.value; emit() }
57+
58+ Event.Connect -> {
59+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
60+ loading = true; error = null; authRequired = false; emit()
61+ scope.launch { probeHealth() }
62+ }
63+
64+ Event.LoadSessions -> { loading = true; emit(); scope.launch { loadSessions() } }
65+
66+ is Event.SelectSession -> {
67+ currentSessionTitle = sessions.firstOrNull { it.id == event.id }?.title.orEmpty()
68+ currentSessionId = event.id
69+ messages.clear()
70+ screen = Screen.Chat
71+ loading = true
72+ error = null
73+ emit()
74+ scope.launch { loadMessages(event.id) }
75+ }
76+
77+ Event.CreateSession -> { loading = true; emit(); scope.launch { createSession() } }
78+
79+ is Event.DeleteSession -> {
80+ sessions.removeAll { it.id == event.id }
81+ if (isCurrent(event.id)) {
82+ currentSessionId = null
83+ messages.clear()
84+ screen = Screen.Sessions
85+ }
86+ emit()
87+ scope.launch { http.delete("$serverUrl/session/${event.id}", authHeader()) }
88+ }
89+
90+ is Event.DraftChanged -> { draft = event.text; emit() }
91+
92+ is Event.SendMessage -> sendMessage(event.text)
93+
94+ Event.CancelGeneration -> {
95+ generating = false; emit()
96+ val id = currentSessionId ?: return
97+ scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
98+ }
99+
100+ Event.NavigateToSessions -> {
101+ screen = Screen.Sessions
102+ currentSessionId = null
103+ messages.clear()
104+ generating = false
105+ emit()
106+ scope.launch { loadSessions() }
107+ }
108+
109+ Event.NavigateToConnect -> {
110+ screen = Screen.Connect
111+ connected = false
112+ sessions.clear()
113+ messages.clear()
114+ currentSessionId = null
115+ closeSse()
116+ emit()
117+ }
118+
119+ Event.DismissError -> { error = null; emit() }
120+ }
121+ }
122+
123+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
124+ fun resumeStreamingIfConnected() {
125+ if (connected && sse == null) startSse()
126+ }
127+
128+ fun shutdown() = closeSse()
129+
130+ // ── Effects ────────────────────────────────────────────────────────────────
131+ private fun sendMessage(raw: String) {
132+ val text = raw.trim()
133+ val session = currentSessionId ?: return
134+ if (text.isEmpty()) return
135+ localSeq += 1
136+ val user = MsgState("local-$localSeq", "user", 0).apply {
137+ status = MessageStatus.Pending
138+ textParts["local"] = text
139+ }
140+ messages.add(user)
141+ draft = ""
142+ generating = true
143+ error = null
144+ emit()
145+
146+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
147+ scope.launch {
148+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
149+ result.fold(
150+ onSuccess = { resp ->
151+ if (resp.code in 200..299) {
152+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
153+ } else {
154+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
155+ generating = false
156+ error = "Server returned status ${resp.code}"
157+ }
158+ },
159+ onFailure = { e ->
160+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
161+ generating = false
162+ error = "Request failed: ${e.message}"
163+ },
164+ )
165+ emit()
166+ }
167+ }
168+
169+ private suspend fun probeHealth() {
170+ val hasCreds = hasCredentials()
171+ http.get("$serverUrl/global/health", authHeader()).fold(
172+ onSuccess = { resp ->
173+ when {
174+ resp.code == 401 -> {
175+ loading = false
176+ if (hasCreds) error = "Invalid credentials" else authRequired = true
177+ emit()
178+ }
179+ resp.code in 200..299 -> {
180+ if (hasCreds) authed = true
181+ connected = true
182+ authRequired = false
183+ error = null
184+ screen = Screen.Sessions
185+ loading = true
186+ emit()
187+ startSse()
188+ loadSessions()
189+ }
190+ else -> {
191+ loading = false
192+ error = "Server returned status ${resp.code}"
193+ emit()
194+ }
195+ }
196+ },
197+ onFailure = { e ->
198+ loading = false
199+ error = "Connection failed: ${e.message}"
200+ emit()
201+ },
202+ )
203+ }
204+
205+ private suspend fun loadSessions() {
206+ loading = false
207+ http.get("$serverUrl/session", authHeader()).fold(
208+ onSuccess = { resp ->
209+ if (resp.code in 200..299) {
210+ sessions.clear()
211+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
212+ } else {
213+ error = "Server returned status ${resp.code}"
214+ }
215+ },
216+ onFailure = { e -> error = "Request failed: ${e.message}" },
217+ )
218+ emit()
219+ }
220+
221+ private suspend fun createSession() {
222+ loading = false
223+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
224+ onSuccess = { resp ->
225+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
226+ if (session != null) {
227+ if (sessions.none { it.id == session.id }) {
228+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
229+ }
230+ emit()
231+ dispatch(Event.SelectSession(session.id))
232+ return
233+ }
234+ error = "Server returned status ${resp.code}"
235+ },
236+ onFailure = { e -> error = "Request failed: ${e.message}" },
237+ )
238+ emit()
239+ }
240+
241+ private suspend fun loadMessages(sessionId: String) {
242+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
243+ onSuccess = { resp ->
244+ if (!isCurrent(sessionId)) return // user navigated away while loading
245+ loading = false
246+ if (resp.code in 200..299) {
247+ messages.clear()
248+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
249+ generating = messages.any { it.streaming }
250+ } else {
251+ error = "Server returned status ${resp.code}"
252+ }
253+ emit()
254+ },
255+ onFailure = { e ->
256+ if (!isCurrent(sessionId)) return
257+ loading = false
258+ error = "Request failed: ${e.message}"
259+ emit()
260+ },
261+ )
262+ }
263+
264+ // ── SSE ─────────────────────────────────────────────────────────────────────
265+ private fun startSse() {
266+ closeSse()
267+ sseClosedIntentionally = false
268+ val client = SseClient(
269+ url = "$serverUrl/event",
270+ auth = authHeader(),
271+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
272+ onStateChange = { open -> if (!open) scheduleReconnect() },
273+ )
274+ sse = client
275+ client.connect()
276+ }
277+
278+ private fun closeSse() {
279+ sseClosedIntentionally = true
280+ sse?.close()
281+ sse = null
282+ }
283+
284+ private fun scheduleReconnect() {
285+ if (sseClosedIntentionally || !connected) return
286+ scope.launch {
287+ delay(2000)
288+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
289+ }
290+ }
291+
292+ private fun handleServerEvent(json: String) {
293+ val event = Protocol.parseEvent(json) ?: return
294+ applyEvent(event)
295+ emit()
296+ }
297+
298+ private fun applyEvent(event: Protocol.Event) {
299+ when (event) {
300+ is Protocol.Event.MessageUpdated -> {
301+ val info = event.info
302+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return
303+ val streaming = info.time.completed == null && info.error == null
304+ val existing = messages.firstOrNull { it.id == info.id }
305+ if (existing != null) {
306+ existing.streaming = streaming
307+ if (existing.time == 0L) existing.time = info.time.created
308+ } else {
309+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
310+ }
311+ info.error?.let { error = it.message(); generating = false }
312+ }
313+
314+ is Protocol.Event.PartUpdated -> {
315+ val part = event.part
316+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return
317+ when (part) {
318+ is Protocol.Part.Text -> if (!part.synthetic) {
319+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
320+ }
321+ is Protocol.Part.Reasoning ->
322+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
323+ is Protocol.Part.Tool ->
324+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
325+ }
326+ }
327+
328+ is Protocol.Event.PartRemoved -> messages.firstOrNull { it.id == event.messageId }?.remove(event.partId)
329+
330+ is Protocol.Event.MessageRemoved -> messages.removeAll { it.id == event.messageId }
331+
332+ is Protocol.Event.SessionIdle -> if (isCurrent(event.sessionId)) {
333+ generating = false
334+ messages.forEach { it.streaming = false }
335+ }
336+
337+ is Protocol.Event.SessionError -> {
338+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
339+ if (scoped) {
340+ event.error?.let { error = it.message() }
341+ generating = false
342+ }
343+ }
344+
345+ is Protocol.Event.SessionUpserted -> {
346+ val title = displayTitle(event.info.title)
347+ val existing = sessions.firstOrNull { it.id == event.info.id }
348+ if (existing != null) {
349+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
350+ } else {
351+ sessions.add(0, SessionView(event.info.id, title))
352+ }
353+ if (isCurrent(event.info.id)) currentSessionTitle = title
354+ }
355+
356+ is Protocol.Event.SessionDeleted -> event.sessionId?.let { id ->
357+ sessions.removeAll { it.id == id }
358+ if (isCurrent(id)) {
359+ currentSessionId = null
360+ messages.clear()
361+ screen = Screen.Sessions
362+ }
363+ }
364+
365+ Protocol.Event.Other -> Unit
366+ }
367+ }
368+
369+ // ── View / helpers ──────────────────────────────────────────────────────────
370+ private fun snapshot() = UiState(
371+ screen = screen,
372+ serverUrl = serverUrl,
373+ username = username,
374+ password = password,
375+ authRequired = authRequired,
376+ connected = connected,
377+ loading = loading,
378+ error = error,
379+ sessions = sessions.toList(),
380+ currentSessionId = currentSessionId,
381+ currentSessionTitle = currentSessionTitle,
382+ messages = messages.map { it.toView() },
383+ draft = draft,
384+ generating = generating,
385+ )
386+
387+ private fun emit() { _view.value = snapshot() }
388+
389+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
390+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
391+
392+ private fun authHeader(): String? {
393+ if (!hasCredentials()) return null
394+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
395+ return "Basic $encoded"
396+ }
397+}
398+
399+// ── Internal message state (parts tracked by id for streaming) ─────────────────
400+private class MsgState(val id: String, val role: String, var time: Long) {
401+ var status = MessageStatus.Sent
402+ var streaming = false
403+ val textParts = LinkedHashMap<String, String>()
404+ val reasoningParts = LinkedHashMap<String, String>()
405+ val toolParts = LinkedHashMap<String, ToolView>()
406+
407+ fun remove(partId: String) {
408+ textParts.remove(partId)
409+ reasoningParts.remove(partId)
410+ toolParts.remove(partId)
411+ }
412+
413+ fun toView(): MessageView {
414+ val reasoning = reasoningParts.values.joinToString("")
415+ return MessageView(
416+ id = id,
417+ role = role,
418+ text = textParts.values.joinToString(""),
419+ reasoning = reasoning.ifEmpty { null },
420+ tools = toolParts.values.toList(),
421+ time = time,
422+ status = status,
423+ streaming = streaming,
424+ )
425+ }
426+}
427+
428+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
429+ val info = env.info
430+ val msg = MsgState(info.id, info.role, info.time.created)
431+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
432+ for (raw in env.parts) {
433+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
434+ when (val part = Protocol.parsePart(obj)) {
435+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
436+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
437+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
438+ null -> Unit
439+ }
440+ }
441+ return msg
442+}
443+
444+private fun pickText(text: String, delta: String?, prior: String?): String =
445+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
446+
447+private fun normalizeUrl(url: String): String = url.trim().trimEnd('/')
448+
449+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
450+
451+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
452+@kotlinx.serialization.Serializable
453+private data class PromptBody(val parts: List<TextInput>)
454+
455+@kotlinx.serialization.Serializable
456+private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktnew file+148
@@ -0,0 +1,148 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.Json
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+import kotlinx.serialization.json.booleanOrNull
10+import kotlinx.serialization.json.contentOrNull
11+import kotlinx.serialization.json.decodeFromJsonElement
12+import kotlinx.serialization.json.jsonObject
13+
14+/**
15+ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16+ *
17+ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18+ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19+ * catch-all so new server versions never break the stream parser.
20+ */
21+object Protocol {
22+ val json = Json {
23+ ignoreUnknownKeys = true
24+ isLenient = true
25+ coerceInputValues = true
26+ }
27+
28+ // ─── Sessions ────────────────────────────────────────────────────────────
29+
30+ @Serializable
31+ data class WireSession(val id: String, val title: String = "")
32+
33+ fun parseSessions(body: String): List<WireSession> =
34+ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35+
36+ fun parseSession(body: String): WireSession? =
37+ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38+
39+ // ─── Messages ────────────────────────────────────────────────────────────
40+
41+ @Serializable
42+ data class WireTime(val created: Long = 0, val completed: Long? = null)
43+
44+ @Serializable
45+ data class WireError(val name: String = "", val data: JsonElement? = null) {
46+ fun message(): String {
47+ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48+ return when {
49+ !msg.isNullOrEmpty() -> msg
50+ name.isNotEmpty() -> name
51+ else -> "Unknown error"
52+ }
53+ }
54+ }
55+
56+ @Serializable
57+ data class WireMessageInfo(
58+ val id: String,
59+ val role: String = "",
60+ @SerialName("sessionID") val sessionId: String = "",
61+ val time: WireTime = WireTime(),
62+ val error: WireError? = null,
63+ )
64+
65+ @Serializable
66+ data class WireMessageEnvelope(
67+ val info: WireMessageInfo,
68+ val parts: List<JsonElement> = emptyList(),
69+ )
70+
71+ fun parseMessages(body: String): List<WireMessageEnvelope> =
72+ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73+
74+ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75+
76+ sealed interface Part {
77+ val id: String
78+ val messageId: String
79+
80+ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81+ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82+ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83+ }
84+
85+ /** Parse a single part JSON object; returns null for kinds we don't render. */
86+ fun parsePart(obj: JsonObject): Part? {
87+ val id = obj.str("id")
88+ val messageId = obj.str("messageID")
89+ return when (obj.str("type")) {
90+ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91+ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92+ "tool" -> {
93+ val state = obj["state"] as? JsonObject
94+ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95+ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96+ Part.Tool(id, messageId, obj.str("tool"), status, title)
97+ }
98+ else -> null
99+ }
100+ }
101+
102+ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103+
104+ sealed interface Event {
105+ data class MessageUpdated(val info: WireMessageInfo) : Event
106+ data class PartUpdated(val part: Part, val delta: String?) : Event
107+ data class PartRemoved(val messageId: String, val partId: String) : Event
108+ data class MessageRemoved(val messageId: String) : Event
109+ data class SessionIdle(val sessionId: String) : Event
110+ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111+ data class SessionUpserted(val info: WireSession) : Event
112+ data class SessionDeleted(val sessionId: String?) : Event
113+ data object Other : Event
114+ }
115+
116+ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117+ fun parseEvent(raw: String): Event? {
118+ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119+ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120+ return when (root.str("type")) {
121+ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122+ "message.part.updated" -> {
123+ val partObj = props["part"] as? JsonObject ?: return Event.Other
124+ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125+ }
126+ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127+ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128+ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129+ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130+ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131+ "session.deleted" -> Event.SessionDeleted(
132+ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133+ )
134+ else -> Event.Other
135+ }
136+ }
137+
138+ private inline fun <reified T> decode(el: JsonElement?): T? =
139+ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140+
141+ // ─── Small JSON helpers ───────────────────────────────────────────────────
142+
143+ private fun JsonObject.str(key: String): String =
144+ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145+
146+ private fun JsonObject.bool(key: String): Boolean =
147+ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148+}
app/src/main/java/soy/iko/opencode/core/PureCoreFfi.ktdeleted−483
@@ -1,483 +0,0 @@
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 currentSessionTitle: String = "",
49− var messages: MutableList<PureMessage> = mutableListOf(),
50− var draftMessage: String = "",
51− var generating: Boolean = false,
52− var crashLogs: MutableList<String> = mutableListOf(),
53− var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
54− var nextId: UInt = 0u,
55−) {
56− fun handleEvent(event: Event): Requests {
57− val requests = mutableListOf<Request>()
58− when (event) {
59− is Event.Start -> {
60− // Don't reset serverUrl — the shell restores the persisted value.
61− }
62− is Event.ServerUrlChanged -> {
63− serverUrl = event.value
64− }
65− is Event.UsernameChanged -> {
66− username = event.value
67− }
68− is Event.PasswordChanged -> {
69− password = event.value
70− }
71− is Event.Connect -> {
72− loading = true
73− error = null
74− val id = nextId++
75− pendingRequests[id] = PendingRequest.HealthProbe
76− requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/global/health"))))
77− }
78− is Event.CancelAuth -> {
79− authRequired = false
80− username = ""
81− password = ""
82− loading = false
83− }
84− is Event.LoadSessions -> {
85− loading = true
86− val id = nextId++
87− pendingRequests[id] = PendingRequest.LoadSessions
88− requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/session"))))
89− }
90− is Event.SelectSession -> {
91− currentSessionId = event.value
92− currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
93− messages.clear()
94− val id = nextId++
95− pendingRequests[id] = PendingRequest.LoadMessages(event.value)
96− requests.add(
97− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
98− )
99− }
100− is Event.CreateSession -> {
101− loading = true
102− val id = nextId++
103− pendingRequests[id] = PendingRequest.CreateSession
104− requests.add(
105− Request(id, Effect.Http(makePostRequest("${serverUrl}/session", ByteArray(0)))),
106− )
107− }
108− is Event.LoadMessages -> {
109− loading = true
110− generating = false
111− val id = nextId++
112− pendingRequests[id] = PendingRequest.LoadMessages(event.value)
113− requests.add(
114− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
115− )
116− }
117− is Event.SendMessage -> {
118− val sessionId = currentSessionId ?: return Requests(requests)
119− draftMessage = ""
120− generating = true
121− val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
122− .toByteArray()
123− val id = nextId++
124− pendingRequests[id] = PendingRequest.SendMessage(sessionId)
125− requests.add(
126− Request(
127− id,
128− Effect.Http(
129− makePostRequest("${serverUrl}/session/$sessionId/prompt_async", body)
130− .copy(headers = listOf(HttpHeader("Content-Type", "application/json"))),
131− ),
132− ),
133− )
134− }
135− is Event.EventReceived -> {
136− val sid = currentSessionId
137− if (sid != null) {
138− val id = nextId++
139− pendingRequests[id] = PendingRequest.LoadMessages(sid)
140− requests.add(
141− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
142− )
143− }
144− }
145− is Event.NavigateToChat -> {
146− currentSessionId = event.value
147− currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
148− messages.clear()
149− }
150− is Event.NavigateToSessions -> {
151− currentSessionId = null
152− currentSessionTitle = ""
153− messages.clear()
154− generating = false
155− }
156− is Event.NavigateToConnect -> {
157− currentSessionId = null
158− currentSessionTitle = ""
159− messages.clear()
160− generating = false
161− connected = false
162− authRequired = false
163− loading = false
164− }
165− is Event.DismissError -> {
166− error = null
167− }
168− is Event.DraftChanged -> {
169− draftMessage = event.value
170− }
171− is Event.CancelGeneration -> {
172− generating = false
173− }
174− is Event.DeleteSession -> {
175− // Optimistically remove the session.
176− sessions.removeAll { it.id == event.value }
177− if (currentSessionId == event.value) {
178− currentSessionId = null
179− currentSessionTitle = ""
180− messages.clear()
181− generating = false
182− }
183− val id = nextId++
184− pendingRequests[id] = PendingRequest.DeleteSession(event.value)
185− requests.add(
186− Request(id, Effect.Http(makeDeleteRequest("${serverUrl}/session/${event.value}"))),
187− )
188− }
189− is Event.RetryMessage -> {
190− // No-op in the pure fallback; real retry needs the original body.
191− }
192− }
193− return Requests(requests)
194− }
195−
196− fun handleResult(id: UInt, result: HttpResult): Requests {
197− val requests = mutableListOf<Request>()
198− val pending = pendingRequests.remove(id) ?: return Requests(requests)
199−
200− when (pending) {
201− is PendingRequest.HealthProbe -> {
202− when (result) {
203− is HttpResult.Ok -> {
204− val status = result.value.status.toInt()
205− if (status == 401) {
206− loading = false
207− val hasCreds = username.isNotEmpty() || password.isNotEmpty()
208− if (hasCreds) {
209− error = "Invalid credentials"
210− } else {
211− authRequired = true
212− }
213− } else if (status in 200..299) {
214− val hasCreds = username.isNotEmpty() || password.isNotEmpty()
215− if (hasCreds) authed = true
216− loading = true
217− val newId = nextId++
218− pendingRequests[newId] = PendingRequest.LoadSessions
219− requests.add(
220− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
221− )
222− } else {
223− loading = false
224− error = "Server returned status $status"
225− }
226− }
227− is HttpResult.Err -> {
228− loading = false
229− error = "Connection failed: ${result.value}"
230− }
231− }
232− }
233− is PendingRequest.LoadSessions -> {
234− loading = false
235− when (result) {
236− is HttpResult.Ok -> {
237− val body = result.value.body.content.decodeToString()
238− sessions = parseSessions(body).toMutableList()
239− connected = true
240− }
241− is HttpResult.Err -> {
242− error = "Failed to load sessions: ${result.value}"
243− }
244− }
245− }
246− is PendingRequest.CreateSession -> {
247− loading = false
248− when (result) {
249− is HttpResult.Ok -> {
250− val body = result.value.body.content.decodeToString()
251− val sid = extractJsonString(body, "id") ?: ""
252− if (sid.isNotEmpty()) {
253− val title = extractJsonString(body, "title")?.takeIf { it.isNotEmpty() }
254− ?: "New session"
255− currentSessionId = sid
256− currentSessionTitle = title
257− sessions.add(0, PureSession(sid, title))
258− messages.clear()
259− val newId = nextId++
260− pendingRequests[newId] = PendingRequest.LoadMessages(sid)
261− requests.add(
262− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
263− )
264− }
265− }
266− is HttpResult.Err -> {
267− error = "Failed to create session: ${result.value}"
268− }
269− }
270− }
271− is PendingRequest.LoadMessages -> {
272− loading = false
273− generating = false
274− when (result) {
275− is HttpResult.Ok -> {
276− val body = result.value.body.content.decodeToString()
277− messages = parseMessages(body).toMutableList()
278− }
279− is HttpResult.Err -> {
280− error = "Failed to load messages: ${result.value}"
281− }
282− }
283− }
284− is PendingRequest.SendMessage -> {
285− when (result) {
286− is HttpResult.Ok -> {
287− val sid = pending.sessionId
288− val newId = nextId++
289− pendingRequests[newId] = PendingRequest.LoadMessages(sid)
290− requests.add(
291− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
292− )
293− }
294− is HttpResult.Err -> {
295− generating = false
296− error = "Failed to send message: ${result.value}"
297− }
298− }
299− }
300− is PendingRequest.DeleteSession -> {
301− when (result) {
302− is HttpResult.Ok -> {
303− // Optimistic removal already happened; nothing to do.
304− }
305− is HttpResult.Err -> {
306− // Restore by reloading sessions.
307− val newId = nextId++
308− pendingRequests[newId] = PendingRequest.LoadSessions
309− requests.add(
310− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
311− )
312− error = "Failed to delete session: ${result.value}"
313− }
314− }
315− }
316− }
317− return Requests(requests)
318− }
319−
320− fun currentView(): ViewModel {
321− val screen = when {
322− currentSessionId != null -> Screen.CHAT
323− connected -> Screen.SESSIONS
324− else -> Screen.CONNECT
325− }
326− return ViewModel(
327− screen = screen,
328− serverUrl = serverUrl,
329− username = username,
330− password = password,
331− authRequired = authRequired,
332− connected = connected,
333− loading = loading,
334− error = error,
335− sessions = sessions.map { SessionView(it.id, it.title) },
336− currentSessionId = currentSessionId,
337− currentSessionTitle = currentSessionTitle,
338− messages = messages.map { MessageView(it.id, it.role, it.text, it.time, it.status) },
339− draftMessage = draftMessage,
340− generating = generating,
341− crashLogCount = crashLogs.size.toUInt(),
342− latestCrashLog = crashLogs.lastOrNull(),
343− )
344− }
345−
346− private fun makeGetRequest(url: String): HttpRequest {
347− val headers = authHeaders()
348− return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
349− }
350−
351− private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
352− val headers = authHeaders()
353− return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
354− }
355−
356− private fun makeDeleteRequest(url: String): HttpRequest {
357− val headers = authHeaders()
358− return HttpRequest(method = "DELETE", url = url, headers = headers, body = Bytes(ByteArray(0)))
359− }
360−
361− private fun authHeaders(): List<HttpHeader> =
362− if (username.isNotEmpty() || password.isNotEmpty()) {
363− val creds = "$username:$password"
364− val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
365− listOf(HttpHeader("Authorization", "Basic $encoded"))
366− } else {
367− emptyList()
368− }
369−}
370−
371−internal sealed class PendingRequest {
372− data object HealthProbe : PendingRequest()
373− data object LoadSessions : PendingRequest()
374− data object CreateSession : PendingRequest()
375− data class LoadMessages(val sessionId: String) : PendingRequest()
376− data class SendMessage(val sessionId: String) : PendingRequest()
377− data class DeleteSession(val sessionId: String) : PendingRequest()
378−}
379−
380−internal data class PureSession(val id: String, val title: String)
381−
382−internal data class PureMessage(
383− val id: String,
384− val role: String,
385− val text: String,
386− val time: ULong,
387− val status: MessageStatus = MessageStatus.SENT,
388−)
389−
390−internal fun parseSessions(json: String): List<PureSession> {
391− val sessions = mutableListOf<PureSession>()
392− var depth = 0
393− var start = 0
394− for (i in json.indices) {
395− when (json[i]) {
396− '{' -> {
397− if (depth == 0) start = i
398− depth++
399− }
400− '}' -> {
401− depth--
402− if (depth == 0) {
403− val obj = json.substring(start, i + 1)
404− val id = extractJsonString(obj, "id") ?: ""
405− val title = extractJsonString(obj, "title") ?: ""
406− if (id.isNotEmpty()) {
407− sessions.add(PureSession(id, if (title.isNotEmpty()) title else "Untitled"))
408− }
409− }
410− }
411− }
412− }
413− return sessions
414−}
415−
416−internal fun parseMessages(json: String): List<PureMessage> {
417− val messages = mutableListOf<PureMessage>()
418− var depth = 0
419− var start = 0
420− for (i in json.indices) {
421− when (json[i]) {
422− '{' -> {
423− if (depth == 0) start = i
424− depth++
425− }
426− '}' -> {
427− depth--
428− if (depth == 0) {
429− val obj = json.substring(start, i + 1)
430− val id = extractJsonString(obj, "id") ?: ""
431− val role = extractJsonString(obj, "role") ?: ""
432− val time = extractJsonNumber(obj, "created") ?: 0uL
433− if (id.isNotEmpty() && role.isNotEmpty()) {
434− val text = extractMessageText(obj)
435− messages.add(PureMessage(id, role, text, time, MessageStatus.SENT))
436− }
437− }
438− }
439− }
440− }
441− return messages
442−}
443−
444−/// Extract the text from a message part. opencode messages nest the user/assistant
445−/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
446−/// first `"parts"` key, falling back to a top-level `"text"` field.
447−internal fun extractMessageText(obj: String): String {
448− val partsIdx = obj.indexOf("\"parts\"")
449− if (partsIdx >= 0) {
450− val rest = obj.substring(partsIdx)
451− val text = extractJsonString(rest, "text")
452− if (text != null) return text
453− }
454− return extractJsonString(obj, "text") ?: ""
455−}
456−
457−internal fun extractJsonString(json: String, key: String): String? {
458− val pattern = "\"$key\""
459− val idx = json.indexOf(pattern)
460− if (idx < 0) return null
461− val rest = json.substring(idx + pattern.length)
462− val colon = rest.indexOf(':')
463− if (colon < 0) return null
464− val rest2 = rest.substring(colon + 1)
465− val quote = rest2.indexOf('"')
466− if (quote < 0) return null
467− val rest3 = rest2.substring(quote + 1)
468− val end = rest3.indexOf('"')
469− if (end < 0) return null
470− return rest3.substring(0, end)
471−}
472−
473−internal fun extractJsonNumber(json: String, key: String): ULong? {
474− val pattern = "\"$key\""
475− val idx = json.indexOf(pattern)
476− if (idx < 0) return null
477− val rest = json.substring(idx + pattern.length)
478− val colon = rest.indexOf(':')
479− if (colon < 0) return null
480− val rest2 = rest.substring(colon + 1).trimStart()
481− val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it < 0) rest2.length else it }
482− return rest2.substring(0, end).toULongOrNull()
483−}
app/src/main/java/soy/iko/opencode/core/SseClient.kt+47−93
@@ -1,108 +1,62 @@
11package soy.iko.opencode.core
22
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.MutableStateFlow
15−import kotlinx.coroutines.flow.SharedFlow
16−import kotlinx.coroutines.flow.StateFlow
17−import kotlinx.coroutines.flow.asSharedFlow
18−import kotlinx.coroutines.flow.asStateFlow
19−import kotlinx.coroutines.launch
20−import soy.iko.opencode.CrashLogger
3+import okhttp3.OkHttpClient
4+import okhttp3.Request
5+import okhttp3.Response
6+import okhttp3.sse.EventSource
7+import okhttp3.sse.EventSourceListener
8+import okhttp3.sse.EventSources
9+import java.util.concurrent.TimeUnit
2110
2211/**
23− * Subscribes to the opencode server's SSE event stream (`GET /event`).
12+ * Long-lived subscription to the opencode `/event` SSE stream.
2413 *
25− * Events are emitted as raw strings on [events]. The Core converts them into
26− * [Event.EventReceived] which triggers a message reload.
27− *
28− * The live connection status is exposed via [state] so the UI can surface a
29− * "live stream disconnected" banner to the user instead of silently dropping
30− * updates. Transport errors are still reported to [CrashLogger] for
31− * diagnostics, but they are no longer hidden from the user.
14+ * Crux only models request/response HTTP, so the streaming connection lives here
15+ * in the shell: each decoded event's `data` payload (a JSON object whose own
16+ * `type` field is the discriminant) is handed to [onEvent], which the core then
17+ * parses via [Protocol.parseEvent].
3218 */
33−class SseClient {
34− private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
35− private val httpClient = HttpClient(OkHttp) {
36− install(HttpTimeout) {
37− requestTimeoutMillis = Long.MAX_VALUE
38− connectTimeoutMillis = 15_000
39− socketTimeoutMillis = Long.MAX_VALUE
40− }
19+class SseClient(
20+ private val url: String,
21+ private val auth: String?,
22+ private val onEvent: (String) -> Unit,
23+ private val onStateChange: (Boolean) -> Unit,
24+) {
25+ // A dedicated client with no read timeout — the stream is meant to stay open.
26+ private val client = OkHttpClient.Builder()
27+ .connectTimeout(15, TimeUnit.SECONDS)
28+ .readTimeout(0, TimeUnit.SECONDS)
29+ .retryOnConnectionFailure(true)
30+ .build()
31+
32+ private var source: EventSource? = null
33+
34+ fun connect() {
35+ val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ if (auth != null) builder.header("Authorization", auth)
37+ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
4138 }
4239
43− private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
44− val events: SharedFlow<String> = _events.asSharedFlow()
45−
46− private val _state = MutableStateFlow<SseState>(SseState.Disconnected)
47− val state: StateFlow<SseState> = _state.asStateFlow()
40+ fun close() {
41+ source?.cancel()
42+ source = null
43+ }
4844
49− private var currentJob: Job? = null
50− @Volatile private var currentUrl: String? = null
45+ private val listener = object : EventSourceListener() {
46+ override fun onOpen(eventSource: EventSource, response: Response) {
47+ onStateChange(true)
48+ }
5149
52− fun connect(url: String) {
53− currentUrl = url
54− currentJob?.cancel()
55− _state.value = SseState.Connecting
56− currentJob = scope.launch {
57− try {
58− httpClient.prepareGet(url).execute { response ->
59− _state.value = SseState.Connected
60− val channel = response.bodyAsChannel()
61− val dataBuilder = StringBuilder()
62− while (!channel.isClosedForRead) {
63− val line = channel.readUTF8Line() ?: break
64− if (line.startsWith("data: ")) {
65− dataBuilder.append(line.substring(6))
66− } else if (line.isEmpty() && dataBuilder.isNotEmpty()) {
67− _events.emit(dataBuilder.toString())
68− dataBuilder.clear()
69− }
70− }
71− // Stream ended normally — flag as disconnected so the UI
72− // can offer a reconnect.
73− _state.value = SseState.Disconnected
74− }
75− } catch (e: kotlinx.coroutines.CancellationException) {
76− _state.value = SseState.Disconnected
77− throw e
78− } catch (e: Throwable) {
79− CrashLogger.report(e, "SseClient")
80− _state.value = SseState.Error(e.message ?: "Connection lost")
81− }
50+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
51+ if (data.isNotBlank()) onEvent(data)
8252 }
83− }
8453
85− /** Re-establish the previous connection, if any. */
86− fun reconnect() {
87− currentUrl?.let { connect(it) }
88− }
54+ override fun onClosed(eventSource: EventSource) {
55+ onStateChange(false)
56+ }
8957
90− fun disconnect() {
91− currentJob?.cancel()
92− currentJob = null
93− currentUrl = null
94− _state.value = SseState.Disconnected
58+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
59+ onStateChange(false)
60+ }
9561 }
9662}
97−
98−/** Live status of the SSE event stream. */
99−sealed interface SseState {
100− /** Not connected (initial state, or after a clean close). */
101− data object Disconnected : SseState
102− /** Connection attempt in progress. */
103− data object Connecting : SseState
104− /** Stream is open and events are being received. */
105− data object Connected : SseState
106− /** The stream dropped unexpectedly. [message] describes the failure. */
107− data class Error(val message: String) : SseState
108−}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktnew file+76
@@ -0,0 +1,76 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5+ * the two implementations stay behaviourally identical.
6+ */
7+
8+enum class Screen { Connect, Sessions, Chat }
9+
10+enum class MessageStatus { Sent, Pending, Failed }
11+
12+data class SessionView(
13+ val id: String,
14+ val title: String,
15+)
16+
17+data class ToolView(
18+ val name: String,
19+ val status: String,
20+ val title: String?,
21+)
22+
23+data class MessageView(
24+ val id: String,
25+ val role: String,
26+ val text: String,
27+ val reasoning: String?,
28+ val tools: List<ToolView>,
29+ val time: Long,
30+ val status: MessageStatus,
31+ val streaming: Boolean,
32+) {
33+ val isUser: Boolean get() = role == "user"
34+}
35+
36+data class UiState(
37+ val screen: Screen = Screen.Connect,
38+ val serverUrl: String = "",
39+ val username: String = "",
40+ val password: String = "",
41+ val authRequired: Boolean = false,
42+ val connected: Boolean = false,
43+ val loading: Boolean = false,
44+ val error: String? = null,
45+ val sessions: List<SessionView> = emptyList(),
46+ val currentSessionId: String? = null,
47+ val currentSessionTitle: String = "",
48+ val messages: List<MessageView> = emptyList(),
49+ val draft: String = "",
50+ val generating: Boolean = false,
51+)
52+
53+/**
54+ * Everything the shell can ask the core to do. Internal HTTP results are not
55+ * events here (unlike the Rust core, which routes them through `then_send`);
56+ * the Kotlin driver awaits them inline in coroutines instead.
57+ */
58+sealed interface Event {
59+ data class ServerUrlChanged(val url: String) : Event
60+ data class UsernameChanged(val value: String) : Event
61+ data class PasswordChanged(val value: String) : Event
62+ data object Connect : Event
63+
64+ data object LoadSessions : Event
65+ data class SelectSession(val id: String) : Event
66+ data object CreateSession : Event
67+ data class DeleteSession(val id: String) : Event
68+
69+ data class DraftChanged(val text: String) : Event
70+ data class SendMessage(val text: String) : Event
71+ data object CancelGeneration : Event
72+
73+ data object NavigateToSessions : Event
74+ data object NavigateToConnect : Event
75+ data object DismissError : Event
76+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedContent
5+import androidx.compose.animation.SizeTransform
6+import androidx.compose.animation.fadeIn
7+import androidx.compose.animation.fadeOut
8+import androidx.compose.animation.togetherWith
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarHost
13+import androidx.compose.material3.SnackbarHostState
14+import androidx.compose.runtime.Composable
15+import androidx.compose.runtime.LaunchedEffect
16+import androidx.compose.runtime.remember
17+import androidx.compose.ui.Modifier
18+import soy.iko.opencode.core.Event
19+import soy.iko.opencode.core.Screen
20+import soy.iko.opencode.core.UiState
21+import soy.iko.opencode.ui.screens.ChatScreen
22+import soy.iko.opencode.ui.screens.ConnectScreen
23+import soy.iko.opencode.ui.screens.SessionsScreen
24+
25+@Composable
26+fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
27+ val snackbar = remember { SnackbarHostState() }
28+
29+ LaunchedEffect(state.error) {
30+ state.error?.let {
31+ snackbar.showSnackbar(it)
32+ dispatch(Event.DismissError)
33+ }
34+ }
35+
36+ BackHandler(enabled = state.screen != Screen.Connect) {
37+ when (state.screen) {
38+ Screen.Chat -> dispatch(Event.NavigateToSessions)
39+ Screen.Sessions -> dispatch(Event.NavigateToConnect)
40+ Screen.Connect -> Unit
41+ }
42+ }
43+
44+ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
45+ AnimatedContent(
46+ targetState = state.screen,
47+ modifier = Modifier.fillMaxSize().padding(padding),
48+ transitionSpec = {
49+ (fadeIn() togetherWith fadeOut()).using(SizeTransform(clip = false))
50+ },
51+ label = "screen",
52+ ) { screen ->
53+ when (screen) {
54+ Screen.Connect -> ConnectScreen(state, dispatch)
55+ Screen.Sessions -> SessionsScreen(state, dispatch)
56+ Screen.Chat -> ChatScreen(state, dispatch)
57+ }
58+ }
59+ }
60+}
app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.ktdeleted−129
@@ -1,129 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Row
5−import androidx.compose.foundation.layout.Spacer
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.imePadding
8−import androidx.compose.foundation.layout.navigationBarsPadding
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11−import androidx.compose.foundation.text.KeyboardActions
12−import androidx.compose.foundation.text.KeyboardOptions
13−import androidx.compose.material.icons.Icons
14−import androidx.compose.material.icons.automirrored.filled.Send
15−import androidx.compose.material.icons.filled.Stop
16−import androidx.compose.material3.Icon
17−import androidx.compose.material3.IconButton
18−import androidx.compose.material3.MaterialTheme
19−import androidx.compose.material3.OutlinedTextField
20−import androidx.compose.material3.Surface
21−import androidx.compose.material3.Text
22−import androidx.compose.runtime.Composable
23−import androidx.compose.ui.Alignment
24−import androidx.compose.ui.Modifier
25−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
26−import androidx.compose.ui.platform.LocalHapticFeedback
27−import androidx.compose.ui.semantics.contentDescription
28−import androidx.compose.ui.semantics.semantics
29−import androidx.compose.ui.text.input.ImeAction
30−import androidx.compose.ui.tooling.preview.Preview
31−import androidx.compose.ui.unit.dp
32−import soy.iko.opencode.ui.theme.Dimens
33−import soy.iko.opencode.ui.theme.OpencodeTheme
34−
35−/**
36− * The chat message composer. A multi-line text field plus a send icon button.
37− *
38− * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
39− * the input bar (the chat Scaffold relies on this).
40− * - Uses [ImeAction.Default] so the keyboard's action key inserts a newline,
41− * allowing multi-line messages. The send button on the right fires the
42− * message.
43− * - Send is disabled while [enabled] is false (e.g. during loading) or when
44− * the text is blank.
45− * - When [generating] is true, a Stop button replaces the Send button so the
46− * user can cancel the wait; tapping it calls [onStop].
47− * - A long-press haptic fires on send for tactile confirmation.
48− */
49−@Composable
50−fun ChatInputBar(
51− text: String,
52− onTextChange: (String) -> Unit,
53− onSend: () -> Unit,
54− enabled: Boolean,
55− modifier: Modifier = Modifier,
56− generating: Boolean = false,
57− onStop: () -> Unit = {},
58− stopContentDescription: String = "Stop generating",
59− placeholder: String = "Message…",
60− sendContentDescription: String = "Send",
61−) {
62− val hapticFeedback = LocalHapticFeedback.current
63− Surface(
64− tonalElevation = Dimens.inputBarTonalElevation,
65− modifier = modifier
66− .fillMaxWidth()
67− .imePadding()
68− .navigationBarsPadding(),
69− ) {
70− Row(
71− verticalAlignment = Alignment.CenterVertically,
72− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
73− modifier = Modifier
74− .fillMaxWidth()
75− .padding(
76− horizontal = Dimens.inputBarHorizontalPadding,
77− vertical = Dimens.inputBarVerticalPadding,
78− ),
79− ) {
80− OutlinedTextField(
81− value = text,
82− onValueChange = onTextChange,
83− placeholder = { Text(placeholder) },
84− modifier = Modifier.weight(1f),
85− maxLines = 4,
86− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default),
87− )
88− if (generating) {
89− IconButton(
90− onClick = {
91− hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
92− onStop()
93− },
94− modifier = Modifier.semantics {
95− contentDescription = stopContentDescription
96− },
97− ) {
98− Icon(Icons.Default.Stop, contentDescription = null)
99− }
100− } else {
101− IconButton(
102− onClick = {
103− hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104− onSend()
105− },
106− enabled = enabled && text.isNotBlank(),
107− modifier = Modifier.semantics {
108− contentDescription = sendContentDescription
109− },
110− ) {
111− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
112− }
113− }
114− }
115− }
116−}
117−
118−@Preview(showBackground = true)
119−@Composable
120−private fun ChatInputBarPreview() {
121− OpencodeTheme {
122− ChatInputBar(
123− text = "Hello world",
124− onTextChange = {},
125− onSend = {},
126− enabled = true,
127− )
128− }
129−}
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.ktdeleted−216
@@ -1,216 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.Intent
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.Spacer
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.height
9−import androidx.compose.foundation.layout.heightIn
10−import androidx.compose.foundation.layout.padding
11−import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.rememberScrollState
13−import androidx.compose.foundation.verticalScroll
14−import androidx.compose.material3.AlertDialog
15−import androidx.compose.material3.MaterialTheme
16−import androidx.compose.material3.Text
17−import androidx.compose.material3.TextButton
18−import androidx.compose.runtime.Composable
19−import androidx.compose.runtime.LaunchedEffect
20−import androidx.compose.runtime.getValue
21−import androidx.compose.runtime.mutableStateOf
22−import androidx.compose.runtime.remember
23−import androidx.compose.runtime.setValue
24−import androidx.compose.ui.Modifier
25−import androidx.compose.ui.platform.LocalContext
26−import androidx.compose.ui.res.stringResource
27−import androidx.compose.ui.tooling.preview.Preview
28−import androidx.compose.ui.unit.dp
29−import kotlinx.coroutines.Dispatchers
30−import kotlinx.coroutines.withContext
31−import soy.iko.opencode.R
32−import soy.iko.opencode.ui.theme.Dimens
33−import soy.iko.opencode.ui.theme.OpencodeTheme
34−import java.io.File
35−
36−/**
37− * Dialog listing persisted crash reports. Each report shows the file name
38− * (header) and the full stack trace (body). Height wraps content but is
39− * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
40− * scroll internally without consuming the whole screen.
41− *
42− * Crash report file contents are read asynchronously via [LaunchedEffect] on
43− * [Dispatchers.IO] so disk I/O never blocks the composition thread.
44− *
45− * "Clear all" is a destructive action guarded by a confirmation step. The
46− * single dialog swaps its content and buttons between the list view and the
47− * confirm view, so there's never a second dialog stacked on top.
48− *
49− * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
50− * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
51− *
52− * Each report has a "Copy" and "Share" button for filing bug reports.
53− */
54−@Composable
55−fun CrashLogDialog(
56− reports: List<File>,
57− onDismiss: () -> Unit,
58− onClear: () -> Unit,
59− modifier: Modifier = Modifier,
60− title: String = "Crash Reports",
61− clearLabel: String = "Clear all",
62− closeLabel: String = "Close",
63− cancelLabel: String = "Cancel",
64− clearConfirmLabel: String = "Clear all crash reports?",
65− clearConfirmYesLabel: String = "Clear",
66− copyLabel: String = "Copy",
67− shareLabel: String = "Share",
68− copiedLabel: String = "Crash report copied",
69− emptyLabel: String = "No crash reports",
70−) {
71− // Read crash report contents asynchronously on Dispatchers.IO so disk
72− // I/O never blocks the composition thread.
73− var contents by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
74− LaunchedEffect(reports) {
75− contents = withContext(Dispatchers.IO) {
76− reports.map { it.name to it.readText() }
77− }
78− }
79− var confirmingClear by remember { mutableStateOf(false) }
80− val context = LocalContext.current
81−
82− AlertDialog(
83− onDismissRequest = {
84− if (confirmingClear) confirmingClear = false else onDismiss()
85− },
86− modifier = modifier,
87− title = { Text(title) },
88− text = {
89− if (confirmingClear) {
90− Text(
91− text = clearConfirmLabel,
92− style = MaterialTheme.typography.bodyMedium,
93− )
94− } else if (contents.isEmpty()) {
95− Text(
96− text = emptyLabel,
97− style = MaterialTheme.typography.bodyMedium,
98− color = MaterialTheme.colorScheme.onSurfaceVariant,
99− )
100− } else {
101− Column(
102− modifier = Modifier
103− .fillMaxWidth()
104− .heightIn(max = 360.dp)
105− .verticalScroll(rememberScrollState()),
106− ) {
107− contents.forEachIndexed { index, (name, text) ->
108− Row(
109− modifier = Modifier.fillMaxWidth(),
110− horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
111− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
112− ) {
113− Text(
114− text = name,
115− style = MaterialTheme.typography.labelMedium,
116− color = MaterialTheme.colorScheme.primary,
117− modifier = Modifier.weight(1f),
118− )
119− Spacer(Modifier.size(Dimens.spaceSmall))
120− TextButton(
121− onClick = {
122− val clipboard = context.getSystemService(android.content.ClipboardManager::class.java)
123− clipboard?.setPrimaryClip(
124− android.content.ClipData.newPlainText("crash_report", text),
125− )
126− },
127− contentPadding = androidx.compose.foundation.layout.PaddingValues(
128− horizontal = Dimens.spaceSmall,
129− vertical = 0.dp,
130− ),
131− ) { Text(copyLabel, style = MaterialTheme.typography.labelSmall) }
132− TextButton(
133− onClick = {
134− val intent = Intent(Intent.ACTION_SEND).apply {
135− type = "text/plain"
136− putExtra(Intent.EXTRA_SUBJECT, name)
137− putExtra(Intent.EXTRA_TEXT, text)
138− }
139− context.startActivity(
140− Intent.createChooser(intent, shareLabel).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
141− )
142− },
143− contentPadding = androidx.compose.foundation.layout.PaddingValues(
144− horizontal = Dimens.spaceSmall,
145− vertical = 0.dp,
146− ),
147− ) { Text(shareLabel, style = MaterialTheme.typography.labelSmall) }
148− }
149− Spacer(Modifier.height(Dimens.spaceTiny))
150− Text(
151− text = text,
152− style = MaterialTheme.typography.bodySmall,
153− )
154− if (index < contents.lastIndex) {
155− Spacer(Modifier.height(Dimens.spaceMedium))
156− }
157− }
158− }
159− }
160− },
161− confirmButton = {
162− if (confirmingClear) {
163− // Affirmative: clear (error-tinted).
164− TextButton(onClick = {
165− onClear()
166− confirmingClear = false
167− }) {
168− Text(
169− text = clearConfirmYesLabel,
170− color = MaterialTheme.colorScheme.error,
171− )
172− }
173− } else {
174− // Non-destructive: close.
175− TextButton(onClick = onDismiss) { Text(closeLabel) }
176− }
177− },
178− dismissButton = {
179− if (confirmingClear) {
180− // Abort the clear.
181− TextButton(onClick = { confirmingClear = false }) { Text(cancelLabel) }
182− } else {
183− // Destructive action in the dismiss slot (left), tinted error.
184− TextButton(onClick = { confirmingClear = true }) {
185− Text(
186− text = clearLabel,
187− color = MaterialTheme.colorScheme.error,
188− )
189− }
190− }
191− },
192− )
193−}
194−
195−@Preview(showBackground = true)
196−@Composable
197−private fun CrashLogDialogPreview() {
198− val tmp = File(System.getProperty("java.io.tmpdir"), "crash_preview.txt").apply {
199− writeText("java.lang.RuntimeException: preview crash\n\tat foo.bar.baz(Preview.kt:1)")
200− }
201− OpencodeTheme {
202− CrashLogDialog(
203− reports = listOf(tmp),
204− onDismiss = {},
205− onClear = {},
206− )
207− }
208−}
209−
210−@Preview(showBackground = true)
211−@Composable
212−private fun CrashLogDialogEmptyPreview() {
213− OpencodeTheme {
214− CrashLogDialog(reports = emptyList(), onDismiss = {}, onClear = {})
215− }
216−}
app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.ktdeleted−90
@@ -1,90 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.text.format.DateFormat
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.padding
7−import androidx.compose.material3.MaterialTheme
8−import androidx.compose.material3.Text
9−import androidx.compose.runtime.Composable
10−import androidx.compose.runtime.remember
11−import androidx.compose.ui.Alignment
12−import androidx.compose.ui.Modifier
13−import androidx.compose.ui.platform.LocalContext
14−import androidx.compose.ui.res.stringResource
15−import androidx.compose.ui.semantics.contentDescription
16−import androidx.compose.ui.semantics.semantics
17−import androidx.compose.ui.text.style.TextAlign
18−import androidx.compose.ui.tooling.preview.Preview
19−import androidx.compose.ui.unit.dp
20−import soy.iko.opencode.R
21−import soy.iko.opencode.ui.theme.OpencodeTheme
22−import java.util.Calendar
23−import java.util.Date
24−import java.util.Locale
25−
26−/**
27− * A centered date label inserted between messages that fall on different
28− * calendar days. Uses the user's locale date format (via [DateFormat.getLongDateFormat])
29− * so it respects system preferences.
30− */
31−@Composable
32−fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
33− val context = LocalContext.current
34− val todayLabel = stringResource(R.string.date_today)
35− val yesterdayLabel = stringResource(R.string.date_yesterday)
36− val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context, todayLabel, yesterdayLabel) }
37− Box(
38− modifier = modifier
39− .fillMaxWidth()
40− .padding(vertical = 8.dp)
41− .semantics { contentDescription = label },
42− contentAlignment = Alignment.Center,
43− ) {
44− Text(
45− text = label,
46− style = MaterialTheme.typography.labelSmall,
47− color = MaterialTheme.colorScheme.onSurfaceVariant,
48− textAlign = TextAlign.Center,
49− )
50− }
51−}
52−
53−private fun formatDateLabel(
54− epochSeconds: ULong,
55− context: android.content.Context,
56− todayLabel: String,
57− yesterdayLabel: String,
58−): String {
59− val date = Date(epochSeconds.toLong() * 1000)
60− val today = dayOfEpoch((Calendar.getInstance(Locale.getDefault()).timeInMillis / 1000).toULong())
61− val yesterday = today - 1
62− val target = dayOfEpoch(epochSeconds)
63− return when (target) {
64− today -> todayLabel
65− yesterday -> yesterdayLabel
66− else -> {
67− val formatter = DateFormat.getLongDateFormat(context)
68− formatter.format(date)
69− }
70− }
71−}
72−
73−/**
74− * Returns the calendar day (as epoch days) for the given epoch seconds,
75− * in the device's default timezone. Returns 0 for time == 0 (unknown).
76− */
77−internal fun dayOfEpoch(epochSeconds: ULong): Int {
78− if (epochSeconds == 0uL) return Int.MIN_VALUE
79− val cal = Calendar.getInstance(Locale.getDefault())
80− cal.timeInMillis = epochSeconds.toLong() * 1000
81− return cal.get(Calendar.YEAR) * 366 + cal.get(Calendar.DAY_OF_YEAR)
82−}
83−
84−@Preview(showBackground = true)
85−@Composable
86−private fun DateSeparatorPreview() {
87− OpencodeTheme {
88− DateSeparator(epochSeconds = 1_700_000_000uL)
89− }
90−}
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.ktdeleted−134
@@ -1,134 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Column
4−import androidx.compose.material3.MaterialTheme
5−import androidx.compose.material3.Snackbar
6−import androidx.compose.material3.SnackbarDuration
7−import androidx.compose.material3.SnackbarHost
8−import androidx.compose.material3.SnackbarHostState
9−import androidx.compose.material3.SnackbarResult
10−import androidx.compose.runtime.Composable
11−import androidx.compose.runtime.LaunchedEffect
12−import androidx.compose.runtime.collectAsState
13−import androidx.compose.runtime.getValue
14−import androidx.compose.runtime.remember
15−import kotlinx.coroutines.flow.SharedFlow
16−import soy.iko.opencode.core.Core
17−import soy.iko.opencode.core.Event
18−
19−/**
20− * A [SnackbarHostState] that observes [ViewModel.error] and surfaces it to
21− * the user. Offers a "Dismiss" action that sends [Event.DismissError] to
22− * clear the error in the core so it doesn't re-show on recomposition.
23− *
24− * When the core has a retryable last event, a "Retry" action is also offered
25− * (it takes precedence over "Dismiss" since it's more useful). Tapping Retry
26− * re-sends the last user-initiated event.
27− *
28− * Used by every screen so error messages are never silently dropped.
29− */
30−@Composable
31−fun ErrorHost(
32− core: Core,
33− dismissLabel: String = "Dismiss",
34− retryLabel: String = "Retry",
35−): SnackbarHostState {
36− val view by core.view.collectAsState()
37− val snackbarHostState = remember { SnackbarHostState() }
38−
39− LaunchedEffect(view.error) {
40− val message = view.error
41− if (!message.isNullOrEmpty()) {
42− val canRetry = core.retryableEvent != null
43− val result = snackbarHostState.showSnackbar(
44− message = message,
45− actionLabel = if (canRetry) retryLabel else dismissLabel,
46− duration = SnackbarDuration.Long,
47− )
48− when (result) {
49− SnackbarResult.ActionPerformed -> {
50− if (canRetry) core.retry() else core.update(Event.DismissError)
51− }
52− SnackbarResult.Dismissed -> core.update(Event.DismissError)
53− }
54− }
55− }
56−
57− return snackbarHostState
58−}
59−
60−/**
61− * Collects transient success/info messages from [Core.info] and surfaces them
62− * as a short snackbar. This gives the user positive feedback (e.g. "Session
63− * created") instead of only ever showing errors.
64− *
65− * Uses a dedicated [SnackbarHostState] (separate from the error host) so the
66− * info snackbar can be rendered with default colors rather than the error
67− * palette. The screens render both hosts in the Scaffold's `snackbarHost`
68− * slot; typically only one fires at a time.
69− */
70−@Composable
71−fun InfoHost(
72− core: Core,
73− successConnectedLabel: String,
74− successSessionCreatedLabel: String,
75− copiedLabel: String = "Copied",
76− sessionDeletedLabel: String = "Session deleted",
77−): SnackbarHostState {
78− val snackbarHostState = remember { SnackbarHostState() }
79− LaunchedEffect(snackbarHostState) {
80− core.info.collect { key ->
81− val message = when (key) {
82− Core.SUCCESS_CONNECTED -> successConnectedLabel
83− Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
84− Core.SUCCESS_COPIED -> copiedLabel
85− Core.SUCCESS_SESSION_DELETED -> sessionDeletedLabel
86− else -> key
87− }
88− snackbarHostState.showSnackbar(
89− message = message,
90− duration = SnackbarDuration.Short,
91− )
92− }
93− }
94− return snackbarHostState
95−}
96−
97−@Composable
98−fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
99− SnackbarHost(snackbarHostState) { data ->
100− Snackbar(
101− containerColor = MaterialTheme.colorScheme.errorContainer,
102− contentColor = MaterialTheme.colorScheme.onErrorContainer,
103− actionContentColor = MaterialTheme.colorScheme.onErrorContainer,
104− snackbarData = data,
105− )
106− }
107−}
108−
109−/**
110− * Snackbar host for success/info messages. Uses the default (surface-tonal)
111− * colors so positive feedback doesn't read as an error.
112− */
113−@Composable
114−fun InfoSnackbarHost(snackbarHostState: SnackbarHostState) {
115− SnackbarHost(snackbarHostState) { data ->
116− Snackbar(snackbarData = data)
117− }
118−}
119−
120−/**
121− * Renders both the error and info snackbar hosts stacked vertically. The
122− * Material 3 [Scaffold] only accepts a single `snackbarHost` slot, so screens
123− * pass this helper to show both flavors without one overwriting the other.
124− */
125−@Composable
126−fun DualSnackbarHost(
127− errorHostState: SnackbarHostState,
128− infoHostState: SnackbarHostState,
129−) {
130− Column {
131− ErrorSnackbarHost(errorHostState)
132− InfoSnackbarHost(infoHostState)
133− }
134−}
app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.ktdeleted−83
@@ -1,83 +0,0 @@
1−package soy.iko.opencode.ui.components
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.fillMaxSize
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.material3.CircularProgressIndicator
9−import androidx.compose.material3.MaterialTheme
10−import androidx.compose.material3.Text
11−import androidx.compose.runtime.Composable
12−import androidx.compose.ui.Alignment
13−import androidx.compose.ui.Modifier
14−import androidx.compose.ui.res.stringResource
15−import androidx.compose.ui.semantics.LiveRegionMode
16−import androidx.compose.ui.semantics.contentDescription
17−import androidx.compose.ui.semantics.liveRegion
18−import androidx.compose.ui.semantics.semantics
19−import androidx.compose.ui.text.style.TextAlign
20−import androidx.compose.ui.tooling.preview.Preview
21−import soy.iko.opencode.R
22−import soy.iko.opencode.ui.theme.Dimens
23−import soy.iko.opencode.ui.theme.OpencodeTheme
24−
25−/**
26− * Centered indeterminate loading indicator. Used when a screen is loading its
27− * initial content and has nothing else to show. Marked as a polite live region
28− * so TalkBack announces "loading" once.
29− */
30−@Composable
31−fun LoadingPlaceholder(
32− modifier: Modifier = Modifier,
33− contentDescription: String = stringResource(R.string.loading),
34−) {
35− Box(
36− modifier = modifier
37− .fillMaxSize()
38− .semantics(mergeDescendants = true) {
39− liveRegion = LiveRegionMode.Polite
40− this.contentDescription = contentDescription
41− },
42− contentAlignment = Alignment.Center,
43− ) {
44− CircularProgressIndicator()
45− }
46−}
47−
48−/**
49− * Centered empty-state message. Used when a screen has no content yet (e.g.
50− * no sessions, no messages).
51− */
52−@Composable
53−fun EmptyState(
54− message: String,
55− modifier: Modifier = Modifier,
56−) {
57− Column(
58− horizontalAlignment = Alignment.CenterHorizontally,
59− verticalArrangement = Arrangement.Center,
60− modifier = modifier
61− .fillMaxSize()
62− .padding(Dimens.screenPadding),
63− ) {
64− Text(
65− text = message,
66− style = MaterialTheme.typography.bodyLarge,
67− color = MaterialTheme.colorScheme.onSurfaceVariant,
68− textAlign = TextAlign.Center,
69− )
70− }
71−}
72−
73−@Preview(showBackground = true)
74−@Composable
75−private fun LoadingPlaceholderPreview() {
76− OpencodeTheme { LoadingPlaceholder() }
77−}
78−
79−@Preview(showBackground = true)
80−@Composable
81−private fun EmptyStatePreview() {
82− OpencodeTheme { EmptyState(message = "No sessions yet") }
83−}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+85−334
@@ -1,379 +1,130 @@
11package soy.iko.opencode.ui.components
22
3−import android.text.format.DateFormat
4−import androidx.compose.animation.core.RepeatMode
5−import androidx.compose.animation.core.animateFloat
6−import androidx.compose.animation.core.infiniteRepeatable
7−import androidx.compose.animation.core.rememberInfiniteTransition
8−import androidx.compose.animation.core.tween
9−import androidx.compose.foundation.ExperimentalFoundationApi
10−import androidx.compose.foundation.combinedClickable
3+import androidx.compose.foundation.background
114import androidx.compose.foundation.layout.Arrangement
125import androidx.compose.foundation.layout.Column
136import androidx.compose.foundation.layout.Row
14−import androidx.compose.foundation.layout.Spacer
157import androidx.compose.foundation.layout.fillMaxWidth
16−import androidx.compose.foundation.layout.height
178import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
199import androidx.compose.foundation.layout.widthIn
10+import androidx.compose.foundation.shape.RoundedCornerShape
2011import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.filled.Build
2113import androidx.compose.material.icons.filled.ErrorOutline
22−import androidx.compose.material3.CircularProgressIndicator
14+import androidx.compose.material.icons.filled.Schedule
2315import androidx.compose.material3.Icon
2416import androidx.compose.material3.MaterialTheme
25−import androidx.compose.material3.Surface
2617import androidx.compose.material3.Text
2718import androidx.compose.runtime.Composable
28−import androidx.compose.runtime.getValue
29−import androidx.compose.runtime.remember
3019import androidx.compose.ui.Alignment
3120import androidx.compose.ui.Modifier
32−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
33−import androidx.compose.ui.platform.LocalClipboardManager
34−import androidx.compose.ui.platform.LocalContext
35−import androidx.compose.ui.platform.LocalDensity
36−import androidx.compose.ui.platform.LocalHapticFeedback
37−import androidx.compose.ui.platform.LocalWindowInfo
38−import androidx.compose.ui.res.stringResource
39−import androidx.compose.ui.semantics.LiveRegionMode
40−import androidx.compose.ui.semantics.contentDescription
41−import androidx.compose.ui.semantics.liveRegion
42−import androidx.compose.ui.semantics.semantics
43−import androidx.compose.ui.text.AnnotatedString
21+import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.graphics.Color
4423import androidx.compose.ui.text.font.FontStyle
45−import androidx.compose.ui.tooling.preview.Preview
4624import androidx.compose.ui.unit.dp
47−import com.mikepenz.markdown.m3.Markdown
48−import soy.iko.opencode.R
4925import soy.iko.opencode.core.MessageStatus
5026import soy.iko.opencode.core.MessageView
51−import soy.iko.opencode.ui.theme.AssistantBubbleShape
52−import soy.iko.opencode.ui.theme.Dimens
53−import soy.iko.opencode.ui.theme.OpencodeTheme
54−import soy.iko.opencode.ui.theme.UserBubbleShape
55−import java.util.Date
27+import soy.iko.opencode.core.ToolView
28+import soy.iko.opencode.ui.theme.MonoStyle
5629
57−/**
58− * A single chat message rendered as a left/right-aligned bubble. Assistant
59− * messages are rendered as markdown; user messages as plain text. The bubble
60− * shape carries an iMessage-style "tail" on the speaker's side.
61− *
62− * Accessibility: the whole bubble is merged into one semantics node so
63− * TalkBack reads the role label + content as a single utterance. Assistant
64− * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
65− * updates are announced without stealing focus.
66− *
67− * Long-press anywhere on the bubble copies the raw message text to the
68− * clipboard and emits a long-press haptic, mirroring the standard chat UX.
69− */
70−@OptIn(ExperimentalFoundationApi::class)
7130@Composable
72−fun MessageBubble(
73− message: MessageView,
74− modifier: Modifier = Modifier,
75− onCopied: () -> Unit = {},
76−) {
77− val isUser = message.role == "user"
78− val maxWidth = maxBubbleWidthDp()
79− val roleLabel = if (isUser) {
80− stringResource(R.string.chat_role_user)
81− } else {
82− stringResource(R.string.chat_role_assistant)
83− }
84− val context = LocalContext.current
85− val timeLabel = remember(message.time) { formatTimestamp(message.time, context) }
86− val clipboardManager = LocalClipboardManager.current
87− val hapticFeedback = LocalHapticFeedback.current
88− val onCopiedCallback = onCopied
89− // Build a single TalkBack utterance that includes the role and the
90− // message body. Setting an explicit contentDescription on a merge node
91− // replaces descendant text, so we must include the body ourselves rather
92− // than letting it merge — otherwise TalkBack reads only "User".
93− val announcement = buildString {
94− append(roleLabel)
95− if (message.time > 0uL) {
96− append(", ")
97− append(timeLabel)
98− }
99− append(". ")
100− append(if (message.text.isBlank()) stringResource(R.string.chat_no_content) else message.text)
101− }
31+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier) {
32+ val isUser = message.isUser
33+ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
34+ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
35+ val shape = RoundedCornerShape(
36+ topStart = 16.dp,
37+ topEnd = 16.dp,
38+ bottomStart = if (isUser) 16.dp else 4.dp,
39+ bottomEnd = if (isUser) 4.dp else 16.dp,
40+ )
41+
10242 Row(
103− modifier = modifier
104− .fillMaxWidth()
105− .combinedClickable(
106− onLongClick = {
107− if (message.text.isNotBlank()) {
108− clipboardManager.setText(AnnotatedString(message.text))
109− hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
110− onCopiedCallback()
111− }
112− },
113− onClick = {},
114− )
115− .semantics(mergeDescendants = true) {
116− contentDescription = announcement
117− },
43+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
11844 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
11945 ) {
120− Surface(
121− color = if (isUser) {
122− MaterialTheme.colorScheme.primary
123− } else {
124− MaterialTheme.colorScheme.surfaceVariant
125− },
126− contentColor = if (isUser) {
127− MaterialTheme.colorScheme.onPrimary
128− } else {
129− MaterialTheme.colorScheme.onSurfaceVariant
130− },
131− shape = if (isUser) UserBubbleShape else AssistantBubbleShape,
132− modifier = Modifier.widthIn(max = maxWidth),
46+ Column(
47+ modifier = Modifier
48+ .widthIn(max = 320.dp)
49+ .clip(shape)
50+ .background(bubbleColor)
51+ .padding(horizontal = 14.dp, vertical = 10.dp),
13352 ) {
134− Column(
135− modifier = Modifier.padding(
136− horizontal = Dimens.bubbleHorizontalPadding,
137− vertical = Dimens.bubbleVerticalPadding,
138− ),
139− ) {
140− if (!isUser) {
141− Row(
142− modifier = Modifier.fillMaxWidth(),
143− horizontalArrangement = Arrangement.SpaceBetween,
144− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
145− ) {
146− Text(
147− text = roleLabel,
148− style = MaterialTheme.typography.labelSmall,
149− color = MaterialTheme.colorScheme.primary,
150− )
151− if (message.time > 0uL) {
152− Text(
153− text = timeLabel,
154− style = MaterialTheme.typography.labelSmall,
155− color = MaterialTheme.colorScheme.onSurfaceVariant,
156− )
157− }
158− }
159− Spacer(Modifier.height(Dimens.spaceTiny))
160− } else {
161− // Show a timestamp and status indicator on user bubbles,
162− // aligned to the end so it reads as the message's send time.
163− val showStatus = message.status != MessageStatus.SENT
164− if (message.time > 0uL || showStatus) {
165− Row(
166− modifier = Modifier.fillMaxWidth(),
167− horizontalArrangement = Arrangement.End,
168− verticalAlignment = Alignment.CenterVertically,
169− ) {
170− if (message.status == MessageStatus.PENDING) {
171− CircularProgressIndicator(
172− modifier = Modifier.size(Dimens.iconInlineSpinner),
173− strokeWidth = Dimens.strokeThin,
174− color = MaterialTheme.colorScheme.onPrimary,
175− )
176− Spacer(Modifier.size(Dimens.spaceTiny))
177− } else if (message.status == MessageStatus.FAILED) {
178− Icon(
179− Icons.Default.ErrorOutline,
180− contentDescription = stringResource(R.string.chat_cd_message_status_failed),
181− modifier = Modifier.size(Dimens.iconInlineSpinner),
182− tint = MaterialTheme.colorScheme.onPrimary,
183− )
184− Spacer(Modifier.size(Dimens.spaceTiny))
185− }
186− if (message.time > 0uL) {
187− Text(
188− text = timeLabel,
189− style = MaterialTheme.typography.labelSmall,
190− color = MaterialTheme.colorScheme.onPrimary,
191− )
192− }
193− }
194− Spacer(Modifier.height(Dimens.spaceTiny))
195− }
196− }
197− if (message.text.isBlank()) {
198− Text(
199− text = stringResource(R.string.chat_no_content),
200− style = MaterialTheme.typography.bodyMedium,
201− fontStyle = FontStyle.Italic,
202− )
203− } else if (isUser) {
204− Text(
205− text = message.text,
206− style = MaterialTheme.typography.bodyMedium,
207− )
208− } else {
209− // Assistant replies often contain markdown (code blocks,
210− // lists, etc.) — render them properly.
211− Markdown(
212− content = message.text,
213− modifier = Modifier
214− .fillMaxWidth()
215− .semantics {
216− // Polite live region so streaming edits are
217− // announced without interrupting the user.
218− liveRegion = LiveRegionMode.Polite
219− },
220− )
221− }
222− }
223− }
224− }
225−}
53+ message.reasoning?.let { ReasoningBlock(it) }
22654
227−@Composable
228−fun GeneratingIndicator(modifier: Modifier = Modifier) {
229− val maxWidth = maxBubbleWidthDp()
230− val generatingLabel = stringResource(R.string.chat_generating_label)
231− val cdLabel = stringResource(R.string.chat_cd_generating)
232− val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
233− // Three dots that pulse in sequence: each dot scales/fades with a
234− // staggered delay so the indicator reads as "typing" rather than a
235− // single frozen spinner. One shared infinite transition drives them.
236− val transition = rememberInfiniteTransition(label = "generating-dots")
237− val phase1 by transition.animateFloat(
238− initialValue = 0.3f,
239− targetValue = 1f,
240− animationSpec = infiniteRepeatable(
241− animation = tween(durationMillis = 600, delayMillis = 0),
242− repeatMode = RepeatMode.Reverse,
243− ),
244− label = "dot-1",
245− )
246− val phase2 by transition.animateFloat(
247− initialValue = 0.3f,
248− targetValue = 1f,
249− animationSpec = infiniteRepeatable(
250− animation = tween(durationMillis = 600, delayMillis = 200),
251− repeatMode = RepeatMode.Reverse,
252− ),
253− label = "dot-2",
254− )
255− val phase3 by transition.animateFloat(
256− initialValue = 0.3f,
257− targetValue = 1f,
258− animationSpec = infiniteRepeatable(
259− animation = tween(durationMillis = 600, delayMillis = 400),
260− repeatMode = RepeatMode.Reverse,
261− ),
262− label = "dot-3",
263− )
264− Row(
265− horizontalArrangement = Arrangement.Start,
266− modifier = modifier
267− .fillMaxWidth()
268− .padding(vertical = Dimens.spaceTiny),
269− ) {
270− Surface(
271− color = MaterialTheme.colorScheme.surfaceVariant,
272− contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
273− shape = AssistantBubbleShape,
274− modifier = Modifier.widthIn(max = maxWidth),
275− ) {
276− Row(
277− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
278− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
279− modifier = Modifier
280− .padding(
281− horizontal = Dimens.bubbleHorizontalPadding,
282− vertical = Dimens.bubbleVerticalPadding,
283− )
284− .semantics(mergeDescendants = true) {
285− // Announced when the indicator appears/disappears.
286− liveRegion = LiveRegionMode.Polite
287− contentDescription = cdLabel
288− },
289− ) {
55+ message.tools.forEach { ToolRow(it) }
56+
57+ if (message.text.isNotEmpty()) {
29058 Text(
291− text = generatingLabel,
292− style = MaterialTheme.typography.bodyMedium,
293− color = MaterialTheme.colorScheme.onSurfaceVariant,
59+ text = message.text + if (message.streaming) " ▌" else "",
60+ color = textColor,
61+ style = MaterialTheme.typography.bodyLarge,
29462 )
295− Row(
296− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
297− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
298− ) {
299− GeneratingDot(alpha = phase1, color = dotColor)
300− GeneratingDot(alpha = phase2, color = dotColor)
301− GeneratingDot(alpha = phase3, color = dotColor)
302− }
63+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
64+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
65+ }
66+
67+ if (isUser && message.status != MessageStatus.Sent) {
68+ StatusLine(message.status)
30369 }
30470 }
30571 }
30672}
30773
30874@Composable
309−private fun GeneratingDot(alpha: Float, color: androidx.compose.ui.graphics.Color) {
310− // Scale slightly with alpha so the pulse feels like motion, not just a
311− // dimmer toggle. alpha drives both the dot's alpha and a small scale.
312− val scale = 0.75f + alpha * 0.25f
313− androidx.compose.foundation.Canvas(
314− modifier = Modifier.size(Dimens.iconInlineSpinner),
315− ) {
316− val diameter = size.minDimension * scale
317− drawCircle(
318− color = color.copy(alpha = alpha.coerceIn(0.15f, 1f)),
319− radius = diameter / 2f,
320− center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f),
321− )
322− }
323−}
324−
325−private fun formatTimestamp(epochSeconds: ULong, context: android.content.Context): String {
326− val date = Date(epochSeconds.toLong() * 1000)
327− val formatter = DateFormat.getTimeFormat(context)
328− return formatter.format(date)
75+private fun ReasoningBlock(text: String) {
76+ Text(
77+ text = text,
78+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
79+ color = MaterialTheme.colorScheme.onSurfaceVariant,
80+ modifier = Modifier.padding(bottom = 6.dp),
81+ )
32982}
33083
331−/**
332− * Max chat-bubble width as a fraction of the window width, in dp. Uses
333− * [LocalWindowInfo] (the recommended window-size source) rather than the
334− * deprecated [androidx.compose.ui.platform.LocalConfiguration.screenWidthDp].
335− */
33684@Composable
337−private fun maxBubbleWidthDp(): androidx.compose.ui.unit.Dp {
338− val containerSize = LocalWindowInfo.current.containerSize
339− val density = LocalDensity.current
340− return with(density) {
341− containerSize.width.toDp() * Dimens.bubbleMaxWidthFraction
85+private fun ToolRow(tool: ToolView) {
86+ Row(
87+ verticalAlignment = Alignment.CenterVertically,
88+ modifier = Modifier.padding(bottom = 6.dp),
89+ ) {
90+ Icon(
91+ Icons.Filled.Build,
92+ contentDescription = null,
93+ tint = MaterialTheme.colorScheme.primary,
94+ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
95+ )
96+ Text(
97+ text = buildString {
98+ append(tool.name)
99+ append(" · ")
100+ append(tool.status)
101+ tool.title?.let { append(" — "); append(it) }
102+ },
103+ style = MonoStyle,
104+ color = MaterialTheme.colorScheme.onSurfaceVariant,
105+ maxLines = 3,
106+ )
342107 }
343108}
344109
345−@Preview(showBackground = true)
346−@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
347110@Composable
348−private fun MessageBubbleUserPreview() {
349− OpencodeTheme {
350− Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
351− MessageBubble(
352− message = MessageView(
353− id = "u1",
354− role = "user",
355− text = "Hello! Can you summarize the build steps?",
356− time = 1_700_000_000uL,
357− status = MessageStatus.SENT,
358− ),
359− )
360− MessageBubble(
361− message = MessageView(
362− id = "a1",
363− role = "assistant",
364− text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
365− time = 1_700_000_010uL,
366− status = MessageStatus.SENT,
367− ),
368− )
369− }
111+private fun StatusLine(status: MessageStatus) {
112+ val (icon, label, tint) = when (status) {
113+ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
114+ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
115+ MessageStatus.Sent -> Triple(Icons.Filled.Schedule, "", Color.Unspecified)
370116 }
371−}
372−
373−@Preview(showBackground = true)
374−@Composable
375−private fun GeneratingIndicatorPreview() {
376− OpencodeTheme {
377− GeneratingIndicator()
117+ Row(
118+ verticalAlignment = Alignment.CenterVertically,
119+ horizontalArrangement = Arrangement.End,
120+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
121+ ) {
122+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
123+ Text(
124+ text = label,
125+ style = MaterialTheme.typography.labelSmall,
126+ color = tint,
127+ modifier = Modifier.padding(start = 4.dp),
128+ )
378129 }
379130}
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.ktdeleted−134
@@ -1,134 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Row
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.padding
7−import androidx.compose.material.icons.Icons
8−import androidx.compose.material.icons.filled.CloudOff
9−import androidx.compose.material3.Icon
10−import androidx.compose.material3.MaterialTheme
11−import androidx.compose.material3.Surface
12−import androidx.compose.material3.Text
13−import androidx.compose.material3.TextButton
14−import androidx.compose.runtime.Composable
15−import androidx.compose.ui.Alignment
16−import androidx.compose.ui.Modifier
17−import androidx.compose.ui.semantics.LiveRegionMode
18−import androidx.compose.ui.semantics.contentDescription
19−import androidx.compose.ui.semantics.liveRegion
20−import androidx.compose.ui.semantics.semantics
21−import androidx.compose.ui.tooling.preview.Preview
22−import androidx.compose.ui.unit.dp
23−import soy.iko.opencode.core.SseState
24−import soy.iko.opencode.ui.theme.Dimens
25−import soy.iko.opencode.ui.theme.OpencodeTheme
26−
27−/**
28− * A thin banner shown above the message list when the SSE live stream drops.
29− * Offers a "Reconnect" action and a "Dismiss" action so the user can keep
30− * reading history without the banner persisting. The banner is a polite live
31− * region so screen readers announce the disconnection once.
32− *
33− * Only render this when [SseState] is [SseState.Error] or an unexpected
34− * [SseState.Disconnected] while a chat is active.
35− */
36−@Composable
37−fun SseDisconnectedBanner(
38− message: String,
39− onReconnect: () -> Unit,
40− onDismiss: () -> Unit,
41− modifier: Modifier = Modifier,
42− reconnectLabel: String = "Reconnect",
43− dismissLabel: String = "Dismiss",
44−) {
45− Surface(
46− color = MaterialTheme.colorScheme.errorContainer,
47− contentColor = MaterialTheme.colorScheme.onErrorContainer,
48− modifier = modifier
49− .fillMaxWidth()
50− .semantics(mergeDescendants = true) {
51− liveRegion = LiveRegionMode.Polite
52− contentDescription = message
53− },
54− ) {
55− Row(
56− verticalAlignment = Alignment.CenterVertically,
57− horizontalArrangement = Arrangement.SpaceBetween,
58− modifier = Modifier
59− .fillMaxWidth()
60− .padding(
61− horizontal = Dimens.spaceLarge,
62− vertical = Dimens.spaceSmall,
63− ),
64− ) {
65− Row(
66− verticalAlignment = Alignment.CenterVertically,
67− horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
68− modifier = Modifier.weight(1f),
69− ) {
70− Icon(Icons.Default.CloudOff, contentDescription = null)
71− Text(
72− text = message,
73− style = MaterialTheme.typography.bodySmall,
74− maxLines = 2,
75− )
76− }
77− Row(verticalAlignment = Alignment.CenterVertically) {
78− TextButton(onClick = onDismiss) { Text(dismissLabel) }
79− TextButton(onClick = onReconnect) { Text(reconnectLabel) }
80− }
81− }
82− }
83−}
84−
85−@Preview(showBackground = true)
86−@Composable
87−private fun SseDisconnectedBannerPreview() {
88− OpencodeTheme {
89− SseDisconnectedBanner(
90− message = "Live stream disconnected",
91− onReconnect = {},
92− onDismiss = {},
93− )
94− }
95−}
96−
97−/**
98− * A thin non-actionable status banner (e.g. "Connecting…") shown above the
99− * message list. Uses a neutral color scheme so it doesn't read as an error.
100− */
101−@Composable
102−fun SseStatusBanner(
103− message: String,
104− containerColor: androidx.compose.ui.graphics.Color,
105− contentColor: androidx.compose.ui.graphics.Color,
106− modifier: Modifier = Modifier,
107−) {
108− Surface(
109− color = containerColor,
110− contentColor = contentColor,
111− modifier = modifier
112− .fillMaxWidth()
113− .semantics(mergeDescendants = true) {
114− liveRegion = LiveRegionMode.Polite
115− contentDescription = message
116− },
117− ) {
118− Row(
119− verticalAlignment = Alignment.CenterVertically,
120− horizontalArrangement = Arrangement.Center,
121− modifier = Modifier
122− .fillMaxWidth()
123− .padding(
124− horizontal = Dimens.spaceLarge,
125− vertical = Dimens.spaceSmall,
126− ),
127− ) {
128− Text(
129− text = message,
130− style = MaterialTheme.typography.bodySmall,
131− )
132− }
133− }
134−}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+75−448
@@ -1,17 +1,13 @@
11package soy.iko.opencode.ui.screens
22
3−import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.animation.scaleIn
5−import androidx.compose.animation.scaleOut
6−import androidx.compose.foundation.ExperimentalFoundationApi
7−import androidx.compose.foundation.combinedClickable
83import androidx.compose.foundation.layout.Arrangement
94import androidx.compose.foundation.layout.Box
105import androidx.compose.foundation.layout.Column
11−import androidx.compose.foundation.layout.PaddingValues
126import androidx.compose.foundation.layout.Row
137import androidx.compose.foundation.layout.fillMaxSize
148import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.PaddingValues
10+import androidx.compose.foundation.layout.imePadding
1511import androidx.compose.foundation.layout.padding
1612import androidx.compose.foundation.layout.size
1713import androidx.compose.foundation.lazy.LazyColumn
@@ -19,499 +15,130 @@ import androidx.compose.foundation.lazy.items
1915import androidx.compose.foundation.lazy.rememberLazyListState
2016import androidx.compose.material.icons.Icons
2117import androidx.compose.material.icons.automirrored.filled.ArrowBack
22−import androidx.compose.material.icons.filled.ContentCopy
23−import androidx.compose.material.icons.filled.KeyboardArrowDown
24−import androidx.compose.material.icons.filled.MoreVert
25−import androidx.compose.material.icons.filled.Refresh
26−import androidx.compose.material3.DropdownMenu
27−import androidx.compose.material3.DropdownMenuItem
18+import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.Stop
20+import androidx.compose.material3.CircularProgressIndicator
2821import androidx.compose.material3.ExperimentalMaterial3Api
29−import androidx.compose.material3.ExtendedFloatingActionButton
22+import androidx.compose.material3.FilledIconButton
3023import androidx.compose.material3.Icon
3124import androidx.compose.material3.IconButton
3225import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.OutlinedTextField
3327import androidx.compose.material3.Scaffold
28+import androidx.compose.material3.Surface
3429import androidx.compose.material3.Text
3530import androidx.compose.material3.TopAppBar
3631import androidx.compose.runtime.Composable
3732import androidx.compose.runtime.LaunchedEffect
38−import androidx.compose.runtime.collectAsState
39−import androidx.compose.runtime.derivedStateOf
40−import androidx.compose.runtime.getValue
41−import androidx.compose.runtime.mutableIntStateOf
42−import androidx.compose.runtime.mutableStateOf
43−import androidx.compose.runtime.remember
44−import androidx.compose.runtime.saveable.rememberSaveable
45−import androidx.compose.runtime.setValue
4633import androidx.compose.ui.Alignment
4734import androidx.compose.ui.Modifier
48−import androidx.compose.ui.focus.FocusRequester
49−import androidx.compose.ui.focus.focusRequester
50−import androidx.compose.ui.platform.LocalClipboardManager
51−import androidx.compose.ui.res.stringResource
52−import androidx.compose.ui.semantics.contentDescription
53−import androidx.compose.ui.semantics.semantics
54−import androidx.compose.ui.text.AnnotatedString
5535import androidx.compose.ui.text.style.TextOverflow
56−import androidx.compose.ui.tooling.preview.Preview
5736import androidx.compose.ui.unit.dp
58−import soy.iko.opencode.R
59−import soy.iko.opencode.core.Core
6037import soy.iko.opencode.core.Event
61−import soy.iko.opencode.core.MessageStatus
62−import soy.iko.opencode.core.MessageView
63−import soy.iko.opencode.core.SseState
64−import soy.iko.opencode.ui.components.ChatInputBar
65−import soy.iko.opencode.ui.components.DateSeparator
66−import soy.iko.opencode.ui.components.DualSnackbarHost
67−import soy.iko.opencode.ui.components.EmptyState
68−import soy.iko.opencode.ui.components.ErrorHost
69−import soy.iko.opencode.ui.components.GeneratingIndicator
70−import soy.iko.opencode.ui.components.InfoHost
71−import soy.iko.opencode.ui.components.LoadingPlaceholder
38+import soy.iko.opencode.core.UiState
7239import soy.iko.opencode.ui.components.MessageBubble
73−import soy.iko.opencode.ui.components.SseDisconnectedBanner
74−import soy.iko.opencode.ui.components.SseStatusBanner
75−import soy.iko.opencode.ui.components.dayOfEpoch
76−import soy.iko.opencode.ui.theme.Dimens
77−import soy.iko.opencode.ui.theme.OpencodeTheme
7840
79−@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
41+@OptIn(ExperimentalMaterial3Api::class)
8042@Composable
81−fun ChatScreen(core: Core) {
82− val view by core.view.collectAsState()
83− val sseState by core.sseState.collectAsState()
84− // Wire the draft to the core's ViewModel so it survives rotation and
85− // process death. The core stores draftMessage; we seed local state from
86− // it and write via Event.DraftChanged on every keystroke.
87− var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
43+fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
8844 val listState = rememberLazyListState()
89− var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
90− var menuExpanded by rememberSaveable { mutableStateOf(false) }
91− var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
92− val inputFocusRequester = remember { FocusRequester() }
93− val clipboardManager = LocalClipboardManager.current
94−
95− // Track whether the user is parked at the bottom of the list. We only
96− // auto-scroll when they are, so reading history isn't yanked away as
97− // new messages stream in.
98− val isAtBottom by remember {
99− derivedStateOf {
100− val layoutInfo = listState.layoutInfo
101− val totalItems = layoutInfo.totalItemsCount
102− val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
103− totalItems == 0 || lastVisible >= totalItems - 2
104− }
105− }
10645
107− LaunchedEffect(view.messages.size) {
108− if (view.messages.isNotEmpty() && (isAtBottom || view.messages.size == 1)) {
109− listState.animateScrollToItem(view.messages.size - 1)
46+ // Keep the newest content in view as it streams in.
47+ val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
48+ LaunchedEffect(state.messages.size, lastLen, state.generating) {
49+ if (state.messages.isNotEmpty()) {
50+ listState.animateScrollToItem(state.messages.size - 1)
11051 }
11152 }
11253
113− // Scroll-to-bottom when the user taps the FAB. Keyed on a counter so
114− // repeated taps keep firing.
115− LaunchedEffect(scrollRequest) {
116− if (scrollRequest > 0 && view.messages.isNotEmpty()) {
117− listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1)
118− }
119− }
120−
121− // Auto-focus the input when the chat is empty so the user can start
122− // typing immediately without an extra tap. Only request focus once per
123− // session entry — keyed on the session id so switching sessions retriggers.
124− val sessionId = view.currentSessionId
125− LaunchedEffect(sessionId, view.messages.isEmpty()) {
126− if (sessionId != null && view.messages.isEmpty() && !view.loading && !view.generating) {
127− inputFocusRequester.requestFocus()
128− }
129− }
130−
131− val errorHostState = ErrorHost(
132− core = core,
133− dismissLabel = stringResource(R.string.action_dismiss),
134− retryLabel = stringResource(R.string.action_retry),
135− )
136− val infoHostState = InfoHost(
137− core = core,
138− successConnectedLabel = stringResource(R.string.connect_success),
139− successSessionCreatedLabel = stringResource(R.string.session_created),
140− copiedLabel = stringResource(R.string.chat_copied),
141− sessionDeletedLabel = stringResource(R.string.session_deleted),
142− )
143−
144− val showSseBanner = (sseState is SseState.Error ||
145− (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)) &&
146− !sseBannerDismissed
147−
148− // Reset the dismissed flag whenever a fresh SSE error arrives so the
149− // banner reappears for a new disconnect.
150− LaunchedEffect((sseState as? SseState.Error)?.message) {
151− if (sseState is SseState.Error) sseBannerDismissed = false
152− }
153−
154− // Resolve nullable session fields into locals once per recomposition so we
155− // never smart-cast a delegate-backed collected property across calls.
156− val sessionTitle = view.currentSessionTitle
157− val chatFallback = stringResource(R.string.top_bar_chat_fallback)
158− val idShortTemplate = stringResource(R.string.chat_session_id_short)
159− val title = remember(sessionId, sessionTitle) {
160− when {
161− sessionTitle.isNotEmpty() -> sessionTitle
162− sessionId != null -> {
163− val prefix = sessionId.take(8)
164− if (sessionId.length > prefix.length) "$prefix…" else prefix
165− }
166− else -> chatFallback
167− }
168− }
169− val subtitle = remember(sessionId, sessionTitle) {
170− if (sessionTitle.isNotEmpty() && sessionId != null) {
171− idShortTemplate.format(sessionId.take(8))
172− } else null
173− }
174−
175− val refreshLabel = stringResource(R.string.chat_menu_refresh)
176− val overflowLabel = stringResource(R.string.chat_menu_overflow)
177− val chatEmptyHint = stringResource(R.string.chat_empty_hint)
178− val copySessionIdCd = stringResource(R.string.chat_cd_copy_session_id)
179− val sessionIdCopiedLabel = stringResource(R.string.chat_session_id_copied)
180−
18154 Scaffold(
18255 topBar = {
18356 TopAppBar(
18457 title = {
185− Column(
186− modifier = Modifier.fillMaxWidth(),
187− ) {
188− Text(
189− text = title,
190− maxLines = 1,
191− overflow = TextOverflow.Ellipsis,
192− )
193− if (subtitle != null && sessionId != null) {
194− Row(
195− verticalAlignment = Alignment.CenterVertically,
196− modifier = Modifier
197− .fillMaxWidth()
198− .combinedClickable(
199− onClick = {
200− clipboardManager.setText(AnnotatedString(sessionId))
201− core.notifyCopied()
202− },
203− onLongClick = {
204− clipboardManager.setText(AnnotatedString(sessionId))
205− core.notifyCopied()
206− },
207− )
208− .semantics {
209− contentDescription = copySessionIdCd
210− },
211− ) {
212− Text(
213− text = subtitle,
214− style = MaterialTheme.typography.labelSmall,
215− color = MaterialTheme.colorScheme.onSurfaceVariant,
216− maxLines = 1,
217− )
218− Icon(
219− Icons.Default.ContentCopy,
220− contentDescription = null,
221− modifier = Modifier.size(Dimens.iconInlineSpinner),
222− tint = MaterialTheme.colorScheme.onSurfaceVariant,
223− )
224− }
225− }
226− }
58+ Text(
59+ text = state.currentSessionTitle.ifEmpty { "Chat" },
60+ maxLines = 1,
61+ overflow = TextOverflow.Ellipsis,
62+ )
22763 },
22864 navigationIcon = {
229− IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
230− Icon(
231− Icons.AutoMirrored.Filled.ArrowBack,
232− contentDescription = stringResource(R.string.chat_cd_back),
233− )
65+ IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
66+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
23467 }
23568 },
23669 actions = {
237− // Overflow menu with a "Refresh messages" action. Gives
238− // a discoverable manual reload path (e.g. when the SSE
239− // banner has been dismissed) and a home for future items.
240− Box {
241− IconButton(onClick = { menuExpanded = true }) {
242− Icon(
243− Icons.Default.MoreVert,
244− contentDescription = overflowLabel,
245− )
246− }
247− DropdownMenu(
248− expanded = menuExpanded,
249− onDismissRequest = { menuExpanded = false },
250− ) {
251− DropdownMenuItem(
252− text = { Text(refreshLabel) },
253− leadingIcon = {
254− Icon(Icons.Default.Refresh, contentDescription = null)
255− },
256− onClick = {
257− menuExpanded = false
258− sessionId?.let { core.update(Event.LoadMessages(it)) }
259− },
260− )
261− }
70+ if (state.generating) {
71+ CircularProgressIndicator(
72+ modifier = Modifier.padding(end = 16.dp).size(22.dp),
73+ strokeWidth = 2.dp,
74+ )
26275 }
26376 },
26477 )
26578 },
26679 bottomBar = {
26780 ChatInputBar(
268− text = inputText,
269− onTextChange = {
270− inputText = it
271− core.update(Event.DraftChanged(it))
272− },
273− onSend = {
274− if (inputText.isNotBlank()) {
275− core.update(Event.SendMessage(inputText))
276− inputText = ""
277− core.update(Event.DraftChanged(""))
278− }
279− },
280− // Disable while loading OR while the assistant is generating,
281− // so users can't fire a second message mid-reply.
282− enabled = !view.loading && !view.generating,
283− generating = view.generating,
284− onStop = { core.update(Event.CancelGeneration) },
285− stopContentDescription = stringResource(R.string.chat_cd_stop),
286− placeholder = stringResource(R.string.chat_input_placeholder),
287− sendContentDescription = stringResource(R.string.chat_cd_send),
288− modifier = Modifier.focusRequester(inputFocusRequester),
81+ draft = state.draft,
82+ generating = state.generating,
83+ onDraftChange = { dispatch(Event.DraftChanged(it)) },
84+ onSend = { dispatch(Event.SendMessage(state.draft)) },
85+ onStop = { dispatch(Event.CancelGeneration) },
28986 )
29087 },
291− snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
29288 ) { padding ->
293− Box(
294− modifier = Modifier
295− .fillMaxSize()
296− .padding(padding),
297− ) {
298− Column(modifier = Modifier.fillMaxSize()) {
299− if (showSseBanner) {
300− val bannerMessage = (sseState as? SseState.Error)?.message
301− ?: stringResource(R.string.sse_disconnected)
302− SseDisconnectedBanner(
303− message = bannerMessage,
304− onReconnect = {
305− sseBannerDismissed = false
306− core.reconnectSse()
307− },
308− onDismiss = { sseBannerDismissed = true },
309− reconnectLabel = stringResource(R.string.sse_reconnect),
310− dismissLabel = stringResource(R.string.sse_dismiss),
311− )
312− } else if (sseState == SseState.Connecting && sessionId != null && !view.loading) {
313− // Brief "connecting" indicator while the SSE stream
314− // establishes — not an error, just a transitional state.
315− SseStatusBanner(
316− message = stringResource(R.string.sse_connecting),
317− containerColor = MaterialTheme.colorScheme.surfaceVariant,
318− contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
319− )
320− }
321−
322− when {
323− view.loading && view.messages.isEmpty() -> {
324− LoadingPlaceholder()
325− }
326− view.messages.isEmpty() -> {
327− EmptyState(message = chatEmptyHint)
328− }
329− else -> {
330− // Pull-to-refresh lets the user manually reload
331− // messages, e.g. when the SSE stream stalled and the
332− // banner was dismissed. Mirrors the Sessions screen.
333− androidx.compose.material3.pulltorefresh.PullToRefreshBox(
334− isRefreshing = view.loading,
335− onRefresh = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
336− modifier = Modifier.fillMaxSize(),
337− ) {
338− LazyColumn(
339− state = listState,
340− modifier = Modifier
341− .fillMaxSize()
342− .padding(horizontal = Dimens.listHorizontalPadding),
343− verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
344− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
345− ) {
346− val msgs = view.messages
347− for (i in msgs.indices) {
348− val msg = msgs[i]
349− // Insert a date separator before a message
350− // when its calendar day differs from the
351− // previous message's day (and it has a
352− // valid timestamp).
353− if (msg.time > 0uL) {
354− val prevDay = if (i > 0) dayOfEpoch(msgs[i - 1].time) else Int.MIN_VALUE
355− val curDay = dayOfEpoch(msg.time)
356− if (curDay != prevDay) {
357− item(key = "date_${msg.id}") {
358− DateSeparator(epochSeconds = msg.time)
359− }
360− }
361− }
362− item(key = msg.id) {
363− MessageBubble(
364− message = msg,
365− onCopied = { core.notifyCopied() },
366− )
367− }
368− }
369− // "Thinking" indicator shown after the user sends, while
370− // the assistant reply hasn't arrived yet. The indicator
371− // itself animates (pulsing dots), so we don't wrap it in
372− // AnimatedVisibility — an outer ColumnScope receiver from
373− // the chat Column would shadow the LazyItemScope here and
374− // break AnimatedVisibility overload resolution.
375− if (view.generating) {
376− item(key = "__generating__") {
377− GeneratingIndicator()
378− }
379− }
380− }
381− }
382− }
383− }
89+ Box(Modifier.fillMaxSize().padding(padding)) {
90+ if (state.messages.isEmpty() && !state.loading) {
91+ Text(
92+ text = "Send a message to start the conversation.",
93+ style = MaterialTheme.typography.bodyMedium,
94+ color = MaterialTheme.colorScheme.onSurfaceVariant,
95+ modifier = Modifier.align(Alignment.Center).padding(32.dp),
96+ )
38497 }
385−
386− // Scroll-to-bottom FAB: only when the user has scrolled up away
387− // from the latest message and there's content to scroll to.
388− // Animated so it scales in/out rather than popping.
389− AnimatedVisibility(
390− visible = view.messages.isNotEmpty() && !isAtBottom,
391− enter = scaleIn(),
392− exit = scaleOut(),
393− modifier = Modifier.align(Alignment.BottomEnd),
98+ LazyColumn(
99+ state = listState,
100+ modifier = Modifier.fillMaxSize(),
101+ contentPadding = PaddingValues(vertical = 8.dp),
394102 ) {
395− ExtendedFloatingActionButton(
396− onClick = { scrollRequest++ },
397− icon = {
398− Icon(
399− Icons.Default.KeyboardArrowDown,
400− contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
401− )
402− },
403− text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
404− containerColor = MaterialTheme.colorScheme.primaryContainer,
405− contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
406− modifier = Modifier
407− .padding(
408− end = Dimens.fabEndMargin,
409− bottom = Dimens.fabBottomMargin,
410− ),
411− )
103+ items(state.messages, key = { it.id }) { message ->
104+ MessageBubble(message)
105+ }
412106 }
413107 }
414108 }
415109}
416110
417−@OptIn(ExperimentalMaterial3Api::class)
418−@Preview(showBackground = true)
419−@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
420−@Composable
421−private fun ChatScreenPreview() {
422− OpencodeTheme {
423− PreviewChatScreen(
424− title = "Refactor the parser",
425− sessionId = "abc12345",
426− messages = listOf(
427− MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL, MessageStatus.SENT),
428− MessageView(
429− "a1",
430− "assistant",
431− "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
432− 1_700_000_010uL,
433− MessageStatus.SENT,
434− ),
435− ),
436− generating = false,
437− )
438− }
439−}
440−
441−@OptIn(ExperimentalMaterial3Api::class)
442−@Preview(showBackground = true)
443−@Composable
444−private fun ChatScreenGeneratingPreview() {
445− OpencodeTheme {
446− PreviewChatScreen(
447− title = "New session",
448− sessionId = "xyz12345",
449− messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL, MessageStatus.SENT)),
450− generating = true,
451− )
452− }
453−}
454−
455−@OptIn(ExperimentalMaterial3Api::class)
456−@Preview(showBackground = true)
457−@Composable
458−private fun ChatScreenEmptyPreview() {
459− OpencodeTheme {
460− PreviewChatScreen(title = "", sessionId = null, messages = emptyList(), generating = false)
461− }
462−}
463−
464−@OptIn(ExperimentalMaterial3Api::class)
465111@Composable
466−private fun PreviewChatScreen(
467− title: String,
468− sessionId: String?,
469− messages: List<MessageView>,
112+private fun ChatInputBar(
113+ draft: String,
470114 generating: Boolean,
115+ onDraftChange: (String) -> Unit,
116+ onSend: () -> Unit,
117+ onStop: () -> Unit,
471118) {
472− val resolvedTitle = when {
473− title.isNotEmpty() -> title
474− sessionId != null -> {
475− val prefix = sessionId.take(8)
476− if (sessionId.length > prefix.length) "$prefix…" else prefix
477− }
478− else -> "Chat"
479− }
480− Scaffold(
481− topBar = {
482− TopAppBar(
483− title = {
484− Text(
485− text = resolvedTitle,
486− maxLines = 1,
487− overflow = TextOverflow.Ellipsis,
488− )
489− },
490− navigationIcon = {
491− IconButton(onClick = {}) {
492− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
493− }
494− },
119+ Surface(tonalElevation = 3.dp) {
120+ Row(
121+ modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
122+ verticalAlignment = Alignment.Bottom,
123+ horizontalArrangement = Arrangement.spacedBy(8.dp),
124+ ) {
125+ OutlinedTextField(
126+ value = draft,
127+ onValueChange = onDraftChange,
128+ modifier = Modifier.weight(1f),
129+ placeholder = { Text("Message opencode…") },
130+ maxLines = 5,
495131 )
496− },
497− bottomBar = {
498− ChatInputBar(text = "", onTextChange = {}, onSend = {}, enabled = true)
499− },
500− ) { padding ->
501− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
502− when {
503− messages.isEmpty() -> EmptyState(message = "Send a message to start chatting")
504− else -> {
505− LazyColumn(
506− modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
507− verticalArrangement = Arrangement.spacedBy(8.dp),
508− contentPadding = PaddingValues(vertical = 12.dp),
509− ) {
510− items(messages, key = { it.id }) { MessageBubble(it) }
511− if (generating) {
512− item(key = "__generating__") { GeneratingIndicator() }
513− }
514− }
132+ if (generating) {
133+ FilledIconButton(onClick = onStop) {
134+ Icon(Icons.Filled.Stop, contentDescription = "Stop generating")
135+ }
136+ } else {
137+ FilledIconButton(
138+ onClick = onSend,
139+ enabled = draft.isNotBlank(),
140+ ) {
141+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
515142 }
516143 }
517144 }
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+79−286
@@ -1,332 +1,125 @@
11package soy.iko.opencode.ui.screens
22
3−import androidx.compose.animation.AnimatedVisibility
43import androidx.compose.foundation.layout.Arrangement
54import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
75import androidx.compose.foundation.layout.Spacer
86import androidx.compose.foundation.layout.fillMaxSize
97import androidx.compose.foundation.layout.fillMaxWidth
108import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.imePadding
1110import androidx.compose.foundation.layout.padding
12−import androidx.compose.foundation.layout.size
1311import androidx.compose.foundation.rememberScrollState
1412import androidx.compose.foundation.text.KeyboardActions
1513import androidx.compose.foundation.text.KeyboardOptions
1614import androidx.compose.foundation.verticalScroll
1715import androidx.compose.material.icons.Icons
18−import androidx.compose.material.icons.filled.Dns
1916import androidx.compose.material.icons.filled.Lock
20−import androidx.compose.material.icons.filled.Visibility
21−import androidx.compose.material.icons.filled.VisibilityOff
22−import androidx.compose.material.icons.filled.Warning
17+import androidx.compose.material.icons.filled.Person
2318import androidx.compose.material3.Button
2419import androidx.compose.material3.CircularProgressIndicator
25−import androidx.compose.material3.ExperimentalMaterial3Api
2620import androidx.compose.material3.Icon
27−import androidx.compose.material3.IconButton
2821import androidx.compose.material3.MaterialTheme
2922import androidx.compose.material3.OutlinedTextField
30−import androidx.compose.material3.Scaffold
31−import androidx.compose.material3.SnackbarHostState
3223import androidx.compose.material3.Text
33−import androidx.compose.material3.TextButton
34−import androidx.compose.material3.TopAppBar
3524import androidx.compose.runtime.Composable
36−import androidx.compose.runtime.LaunchedEffect
37−import androidx.compose.runtime.collectAsState
38−import androidx.compose.runtime.getValue
39−import androidx.compose.runtime.mutableStateOf
40−import androidx.compose.runtime.remember
41−import androidx.compose.runtime.saveable.rememberSaveable
42−import androidx.compose.runtime.setValue
4325import androidx.compose.ui.Alignment
4426import androidx.compose.ui.Modifier
45−import androidx.compose.ui.focus.FocusRequester
46−import androidx.compose.ui.focus.focusRequester
47−import androidx.compose.ui.res.pluralStringResource
48−import androidx.compose.ui.res.stringResource
4927import androidx.compose.ui.text.input.ImeAction
5028import androidx.compose.ui.text.input.KeyboardType
5129import androidx.compose.ui.text.input.PasswordVisualTransformation
52−import androidx.compose.ui.text.input.VisualTransformation
53−import androidx.compose.ui.tooling.preview.Preview
30+import androidx.compose.ui.text.style.TextAlign
5431import androidx.compose.ui.unit.dp
55−import soy.iko.opencode.CrashLogger
56−import soy.iko.opencode.R
57−import soy.iko.opencode.core.Core
5832import soy.iko.opencode.core.Event
59−import soy.iko.opencode.ui.components.CrashLogDialog
60−import soy.iko.opencode.ui.components.DualSnackbarHost
61−import soy.iko.opencode.ui.components.ErrorHost
62−import soy.iko.opencode.ui.components.ErrorSnackbarHost
63−import soy.iko.opencode.ui.components.InfoHost
64−import soy.iko.opencode.ui.components.InfoSnackbarHost
65−import soy.iko.opencode.ui.theme.Dimens
33+import soy.iko.opencode.core.UiState
6634
67−@OptIn(ExperimentalMaterial3Api::class)
6835@Composable
69−fun ConnectScreen(core: Core) {
70− val view by core.view.collectAsState()
71− val crashCount by CrashLogger.reportCount.collectAsState()
72− var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
73− var username by remember(view.username) { mutableStateOf(view.username) }
74− var password by remember(view.password) { mutableStateOf(view.password) }
75− var passwordVisible by rememberSaveable { mutableStateOf(false) }
76− var showCrashLogs by rememberSaveable { mutableStateOf(false) }
77− var urlError by rememberSaveable { mutableStateOf(false) }
78− val errorHostState = ErrorHost(
79− core = core,
80− dismissLabel = stringResource(R.string.action_dismiss),
81− retryLabel = stringResource(R.string.action_retry),
82− )
83− val infoHostState = InfoHost(
84− core = core,
85− successConnectedLabel = stringResource(R.string.connect_success),
86− successSessionCreatedLabel = stringResource(R.string.session_created),
87− )
88−
89− // Auto-focus the username field when the server reveals it requires
90− // authentication, so the user doesn't have to tap into it manually.
91− val usernameFocusRequester = remember { FocusRequester() }
92− LaunchedEffect(view.authRequired) {
93− if (view.authRequired) {
94− usernameFocusRequester.requestFocus()
95− }
96− }
36+fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
37+ Column(
38+ modifier = Modifier
39+ .fillMaxSize()
40+ .verticalScroll(rememberScrollState())
41+ .imePadding()
42+ .padding(horizontal = 28.dp),
43+ verticalArrangement = Arrangement.Center,
44+ horizontalAlignment = Alignment.CenterHorizontally,
45+ ) {
46+ Text(
47+ text = "opencode",
48+ style = MaterialTheme.typography.displaySmall,
49+ color = MaterialTheme.colorScheme.primary,
50+ )
51+ Text(
52+ text = "Connect to your opencode server",
53+ style = MaterialTheme.typography.bodyMedium,
54+ color = MaterialTheme.colorScheme.onSurfaceVariant,
55+ textAlign = TextAlign.Center,
56+ )
9757
98− fun isValidUrl(value: String): Boolean {
99− val trimmed = value.trim()
100− if (trimmed.isEmpty()) return false
101− return trimmed.startsWith("http://", ignoreCase = true) ||
102− trimmed.startsWith("https://", ignoreCase = true)
103− }
58+ Spacer(Modifier.height(32.dp))
10459
105− fun submit() {
106− val trimmedUrl = url.trim()
107− if (!isValidUrl(trimmedUrl)) {
108− urlError = true
109− return
110− }
111− urlError = false
112− url = trimmedUrl
113− core.update(Event.ServerUrlChanged(trimmedUrl))
114− if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
115− core.update(Event.UsernameChanged(username.trim()))
116− core.update(Event.PasswordChanged(password))
117− }
118− core.update(Event.Connect)
119− }
60+ OutlinedTextField(
61+ value = state.serverUrl,
62+ onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
63+ label = { Text("Server URL") },
64+ placeholder = { Text("http://192.168.1.10:4096") },
65+ singleLine = true,
66+ modifier = Modifier.fillMaxWidth(),
67+ keyboardOptions = KeyboardOptions(
68+ keyboardType = KeyboardType.Uri,
69+ imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
70+ ),
71+ keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
72+ enabled = !state.loading,
73+ )
12074
121− Scaffold(
122− topBar = {
123− TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
124− },
125− snackbarHost = {
126− DualSnackbarHost(errorHostState, infoHostState)
127− },
128− ) { padding ->
129− Column(
130− horizontalAlignment = Alignment.CenterHorizontally,
131− verticalArrangement = Arrangement.Center,
132− modifier = Modifier
133− .fillMaxSize()
134− .padding(padding)
135− .padding(Dimens.screenPadding)
136− .verticalScroll(rememberScrollState()),
137− ) {
138− Icon(
139− imageVector = Icons.Default.Dns,
140− contentDescription = stringResource(R.string.connect_cd_dns),
141− modifier = Modifier.size(Dimens.iconHero),
142− tint = MaterialTheme.colorScheme.primary,
143− )
144− Spacer(Modifier.height(Dimens.spaceLarge))
145− Text(
146− text = stringResource(R.string.connect_heading),
147− style = MaterialTheme.typography.headlineSmall,
148− )
149− Spacer(Modifier.height(Dimens.spaceXLarge))
75+ if (state.authRequired) {
76+ Spacer(Modifier.height(12.dp))
15077 OutlinedTextField(
151− value = url,
152− onValueChange = {
153− url = it
154− if (urlError) urlError = false
155− },
156− label = { Text(stringResource(R.string.connect_field_server_url)) },
78+ value = state.username,
79+ onValueChange = { dispatch(Event.UsernameChanged(it)) },
80+ label = { Text("Username") },
15781 singleLine = true,
158− isError = urlError,
159− supportingText = if (urlError) {
160− { Text(stringResource(R.string.connect_error_invalid_url)) }
161− } else null,
162− keyboardOptions = KeyboardOptions(
163− keyboardType = KeyboardType.Uri,
164− imeAction = ImeAction.Go,
165− ),
166− keyboardActions = KeyboardActions(onGo = { submit() }),
82+ leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
16783 modifier = Modifier.fillMaxWidth(),
84+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
85+ enabled = !state.loading,
16886 )
169−
170− AnimatedVisibility(visible = view.authRequired) {
171− Column(
172− horizontalAlignment = Alignment.CenterHorizontally,
173− modifier = Modifier.fillMaxWidth().padding(top = Dimens.spaceSmall),
174− ) {
175− Text(
176− text = stringResource(R.string.connect_auth_required),
177− style = MaterialTheme.typography.bodyMedium,
178− color = MaterialTheme.colorScheme.error,
179− )
180− Spacer(Modifier.height(Dimens.spaceSmall))
181− OutlinedTextField(
182− value = username,
183− onValueChange = { username = it },
184− label = { Text(stringResource(R.string.connect_field_username)) },
185− singleLine = true,
186− leadingIcon = {
187− Icon(
188− Icons.Default.Lock,
189− contentDescription = stringResource(R.string.connect_cd_lock),
190− )
191− },
192− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
193− modifier = Modifier
194− .fillMaxWidth()
195− .focusRequester(usernameFocusRequester),
196− )
197− Spacer(Modifier.height(Dimens.spaceSmall))
198− OutlinedTextField(
199− value = password,
200− onValueChange = { password = it },
201− label = { Text(stringResource(R.string.connect_field_password)) },
202− singleLine = true,
203− visualTransformation = if (passwordVisible) {
204− VisualTransformation.None
205− } else {
206− PasswordVisualTransformation()
207− },
208− trailingIcon = {
209− IconButton(onClick = { passwordVisible = !passwordVisible }) {
210− Icon(
211− if (passwordVisible) {
212− Icons.Default.VisibilityOff
213− } else {
214− Icons.Default.Visibility
215− },
216− contentDescription = if (passwordVisible) {
217− stringResource(R.string.connect_password_hide)
218− } else {
219− stringResource(R.string.connect_password_show)
220− },
221− )
222− }
223− },
224− keyboardOptions = KeyboardOptions(
225− keyboardType = KeyboardType.Password,
226− imeAction = ImeAction.Go,
227− ),
228− keyboardActions = KeyboardActions(onGo = { submit() }),
229− modifier = Modifier.fillMaxWidth(),
230− )
231− Spacer(Modifier.height(Dimens.spaceTiny))
232− TextButton(onClick = { core.update(Event.CancelAuth) }) {
233− Text(stringResource(R.string.connect_button_cancel))
234− }
235− }
236− }
237−
238− Spacer(Modifier.height(Dimens.spaceLarge))
239− Button(
240− onClick = { submit() },
241− enabled = !view.loading,
87+ Spacer(Modifier.height(12.dp))
88+ OutlinedTextField(
89+ value = state.password,
90+ onValueChange = { dispatch(Event.PasswordChanged(it)) },
91+ label = { Text("Password") },
92+ singleLine = true,
93+ visualTransformation = PasswordVisualTransformation(),
94+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
24295 modifier = Modifier.fillMaxWidth(),
243− ) {
244− if (view.loading) {
245− CircularProgressIndicator(
246− modifier = Modifier.size(Dimens.iconButtonSpinner),
247− strokeWidth = Dimens.strokeThin,
248− color = MaterialTheme.colorScheme.onPrimary,
249− )
250− } else {
251− Text(stringResource(R.string.connect_button_connect))
252− }
253− }
254−
255− if (crashCount > 0) {
256− Spacer(Modifier.height(Dimens.spaceLarge))
257− Row(verticalAlignment = Alignment.CenterVertically) {
258− Icon(
259− Icons.Default.Warning,
260− contentDescription = stringResource(R.string.connect_cd_warning),
261− tint = MaterialTheme.colorScheme.error,
262− modifier = Modifier.size(Dimens.iconInputLeading),
263− )
264− Spacer(Modifier.size(Dimens.gapTiny))
265− TextButton(onClick = { showCrashLogs = true }) {
266− Text(
267− text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
268− style = MaterialTheme.typography.labelSmall,
269− )
270− }
271− }
272− }
96+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
97+ keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
98+ enabled = !state.loading,
99+ )
100+ Spacer(Modifier.height(4.dp))
101+ Text(
102+ text = "This server requires authentication.",
103+ style = MaterialTheme.typography.bodySmall,
104+ color = MaterialTheme.colorScheme.onSurfaceVariant,
105+ )
273106 }
274− }
275107
276− if (showCrashLogs) {
277− CrashLogDialog(
278− reports = CrashLogger.getReports(),
279− onDismiss = { showCrashLogs = false },
280− onClear = {
281− CrashLogger.clearReports()
282− showCrashLogs = false
283− },
284− title = stringResource(R.string.crash_reports_title),
285− clearLabel = stringResource(R.string.crash_reports_clear),
286− closeLabel = stringResource(R.string.crash_reports_close),
287− cancelLabel = stringResource(R.string.crash_reports_cancel),
288− clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
289− clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
290− )
291− }
292−}
108+ Spacer(Modifier.height(24.dp))
293109
294−@OptIn(ExperimentalMaterial3Api::class)
295−@Preview(showBackground = true)
296−@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
297−@Composable
298−private fun ConnectScreenPreview() {
299− soy.iko.opencode.ui.theme.OpencodeTheme {
300− Scaffold(topBar = { TopAppBar(title = { Text("Opencode") }) }) { padding ->
301− Column(
302− horizontalAlignment = Alignment.CenterHorizontally,
303− verticalArrangement = Arrangement.Center,
304− modifier = Modifier
305− .fillMaxSize()
306− .padding(padding)
307− .padding(24.dp)
308− .verticalScroll(rememberScrollState()),
309− ) {
310− Icon(
311− Icons.Default.Dns,
312− contentDescription = null,
313− modifier = Modifier.size(72.dp),
314− tint = MaterialTheme.colorScheme.primary,
315− )
316− Spacer(Modifier.height(16.dp))
317− Text("Connect to Opencode Server", style = MaterialTheme.typography.headlineSmall)
318− Spacer(Modifier.height(24.dp))
319− OutlinedTextField(
320− value = "http://localhost:4096",
321− onValueChange = {},
322− label = { Text("Server URL") },
323− singleLine = true,
324− modifier = Modifier.fillMaxWidth(),
110+ Button(
111+ onClick = { dispatch(Event.Connect) },
112+ enabled = !state.loading && state.serverUrl.isNotBlank(),
113+ modifier = Modifier.fillMaxWidth(),
114+ ) {
115+ if (state.loading) {
116+ CircularProgressIndicator(
117+ modifier = Modifier.height(20.dp),
118+ strokeWidth = 2.dp,
119+ color = MaterialTheme.colorScheme.onPrimary,
325120 )
326− Spacer(Modifier.height(16.dp))
327− Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
328− Text("Connect")
329− }
121+ } else {
122+ Text(if (state.authRequired) "Sign in" else "Connect")
330123 }
331124 }
332125 }
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+82−242
@@ -1,306 +1,146 @@
11package soy.iko.opencode.ui.screens
22
3−import androidx.compose.foundation.ExperimentalFoundationApi
43import androidx.compose.foundation.clickable
5−import androidx.compose.foundation.combinedClickable
6−import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
76import androidx.compose.foundation.layout.PaddingValues
7+import androidx.compose.foundation.layout.Row
88import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.fillMaxWidth
910import androidx.compose.foundation.layout.padding
1011import androidx.compose.foundation.lazy.LazyColumn
1112import androidx.compose.foundation.lazy.items
1213import androidx.compose.material.icons.Icons
1314import androidx.compose.material.icons.automirrored.filled.ArrowBack
14−import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
1515import androidx.compose.material.icons.filled.Add
16−import androidx.compose.material.icons.filled.ChatBubble
17−import androidx.compose.material.icons.filled.Delete
18−import androidx.compose.material3.AlertDialog
16+import androidx.compose.material.icons.filled.ChatBubbleOutline
17+import androidx.compose.material.icons.filled.DeleteOutline
18+import androidx.compose.material.icons.filled.Refresh
1919import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.ExtendedFloatingActionButton
21+import androidx.compose.material3.HorizontalDivider
2022import androidx.compose.material3.Icon
2123import androidx.compose.material3.IconButton
22−import androidx.compose.material3.ListItem
24+import androidx.compose.material3.LinearProgressIndicator
2325import androidx.compose.material3.MaterialTheme
2426import androidx.compose.material3.Scaffold
2527import androidx.compose.material3.Text
26−import androidx.compose.material3.TextButton
2728import androidx.compose.material3.TopAppBar
28−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
2929import androidx.compose.runtime.Composable
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
30+import androidx.compose.ui.Alignment
3531import androidx.compose.ui.Modifier
36−import androidx.compose.ui.res.stringResource
37−import androidx.compose.ui.semantics.Role
38−import androidx.compose.ui.semantics.contentDescription
39−import androidx.compose.ui.semantics.role
40−import androidx.compose.ui.semantics.semantics
4132import androidx.compose.ui.text.style.TextOverflow
42−import androidx.compose.ui.tooling.preview.Preview
4333import androidx.compose.ui.unit.dp
44−import soy.iko.opencode.R
45−import soy.iko.opencode.core.Core
4634import soy.iko.opencode.core.Event
4735import soy.iko.opencode.core.SessionView
48−import soy.iko.opencode.ui.components.DualSnackbarHost
49−import soy.iko.opencode.ui.components.EmptyState
50−import soy.iko.opencode.ui.components.ErrorHost
51−import soy.iko.opencode.ui.components.InfoHost
52−import soy.iko.opencode.ui.components.LoadingPlaceholder
53−import soy.iko.opencode.ui.theme.Dimens
54−import soy.iko.opencode.ui.theme.OpencodeTheme
36+import soy.iko.opencode.core.UiState
5537
5638@OptIn(ExperimentalMaterial3Api::class)
5739@Composable
58−fun SessionsScreen(core: Core) {
59− val view by core.view.collectAsState()
60− val errorHostState = ErrorHost(
61− core = core,
62− dismissLabel = stringResource(R.string.action_dismiss),
63− retryLabel = stringResource(R.string.action_retry),
64− )
65− val infoHostState = InfoHost(
66− core = core,
67− successConnectedLabel = stringResource(R.string.connect_success),
68− successSessionCreatedLabel = stringResource(R.string.session_created),
69− copiedLabel = stringResource(R.string.chat_copied),
70− sessionDeletedLabel = stringResource(R.string.session_deleted),
71− )
72− val untitledLabel = stringResource(R.string.sessions_untitled)
73− val idShortTemplate = stringResource(R.string.sessions_id_short)
74− val deleteCdLabel = stringResource(R.string.sessions_cd_delete)
75− val disconnectConfirmLabel = stringResource(R.string.sessions_disconnect_confirm)
76− val disconnectYesLabel = stringResource(R.string.sessions_disconnect_confirm_yes)
77−
78− // Track the session pending deletion (for confirmation dialog) and
79− // whether the disconnect-from-server confirmation should show.
80− var pendingDelete by remember { mutableStateOf<SessionView?>(null) }
81− var showDisconnectConfirm by remember { mutableStateOf(false) }
82−
40+fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
8341 Scaffold(
8442 topBar = {
8543 TopAppBar(
86− title = { Text(stringResource(R.string.top_bar_sessions)) },
44+ title = { Text("Sessions") },
8745 navigationIcon = {
88− IconButton(onClick = { showDisconnectConfirm = true }) {
89− Icon(
90− Icons.AutoMirrored.Filled.ArrowBack,
91− contentDescription = stringResource(R.string.sessions_cd_back),
92− )
46+ IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
47+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
9348 }
9449 },
9550 actions = {
96− IconButton(onClick = { core.update(Event.CreateSession) }) {
97− Icon(
98− Icons.Default.Add,
99− contentDescription = stringResource(R.string.sessions_cd_new),
100− )
51+ IconButton(onClick = { dispatch(Event.LoadSessions) }) {
52+ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
10153 }
10254 },
10355 )
10456 },
105− snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
57+ floatingActionButton = {
58+ ExtendedFloatingActionButton(
59+ onClick = { dispatch(Event.CreateSession) },
60+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
61+ text = { Text("New session") },
62+ )
63+ },
10664 ) { padding ->
107− Box(
108− modifier = Modifier
109− .fillMaxSize()
110− .padding(padding),
111− ) {
65+ Column(Modifier.fillMaxSize().padding(padding)) {
66+ if (state.loading) {
67+ LinearProgressIndicator(Modifier.fillMaxWidth())
68+ }
11269 when {
113− view.loading && view.sessions.isEmpty() -> {
114− LoadingPlaceholder()
115− }
116− view.sessions.isEmpty() -> {
117− EmptyState(message = stringResource(R.string.sessions_empty_hint))
118− }
119− else -> {
120− PullToRefreshBox(
121− isRefreshing = view.loading,
122− onRefresh = { core.update(Event.LoadSessions) },
123− modifier = Modifier.fillMaxSize(),
124− ) {
125− LazyColumn(
126− contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
127− ) {
128− items(view.sessions, key = { it.id }) { session ->
129− SessionRow(
130− session = session,
131− untitledLabel = untitledLabel,
132− idShortLabel = idShortTemplate.format(session.id.take(8)),
133− deleteCdLabel = deleteCdLabel,
134− onClick = { core.update(Event.SelectSession(session.id)) },
135− onDelete = { pendingDelete = session },
136− )
137− }
138− }
70+ state.sessions.isEmpty() && !state.loading -> EmptySessions()
71+ else -> LazyColumn(
72+ modifier = Modifier.fillMaxSize(),
73+ contentPadding = PaddingValues(bottom = 88.dp),
74+ ) {
75+ items(state.sessions, key = { it.id }) { session ->
76+ SessionRow(
77+ session = session,
78+ onOpen = { dispatch(Event.SelectSession(session.id)) },
79+ onDelete = { dispatch(Event.DeleteSession(session.id)) },
80+ )
81+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
13982 }
14083 }
14184 }
14285 }
14386 }
144−
145− // Delete confirmation dialog
146− pendingDelete?.let { session ->
147− AlertDialog(
148− onDismissRequest = { pendingDelete = null },
149− title = { Text(session.title.ifEmpty { untitledLabel }) },
150− text = { Text(stringResource(R.string.sessions_delete_confirm)) },
151− confirmButton = {
152− TextButton(onClick = {
153− core.update(Event.DeleteSession(session.id))
154− pendingDelete = null
155− }) {
156− Text(
157− text = stringResource(R.string.sessions_delete_confirm_yes),
158− color = MaterialTheme.colorScheme.error,
159− )
160− }
161− },
162− dismissButton = {
163− TextButton(onClick = { pendingDelete = null }) {
164− Text(stringResource(R.string.crash_reports_cancel))
165− }
166− },
167− )
168− }
169−
170− // Disconnect confirmation dialog — going back to Connect is destructive
171− // (drops the session list and requires a full reconnect), so confirm.
172− if (showDisconnectConfirm) {
173− AlertDialog(
174− onDismissRequest = { showDisconnectConfirm = false },
175− text = { Text(disconnectConfirmLabel) },
176− confirmButton = {
177− TextButton(onClick = {
178− core.update(Event.NavigateToConnect)
179− showDisconnectConfirm = false
180− }) {
181− Text(disconnectYesLabel)
182− }
183− },
184− dismissButton = {
185− TextButton(onClick = { showDisconnectConfirm = false }) {
186− Text(stringResource(R.string.crash_reports_cancel))
187− }
188− },
189− )
190− }
19187}
19288
193−@OptIn(ExperimentalFoundationApi::class)
19489@Composable
195−private fun SessionRow(
196− session: SessionView,
197− untitledLabel: String,
198− idShortLabel: String,
199− deleteCdLabel: String,
200− onClick: () -> Unit,
201− onDelete: () -> Unit,
202−) {
203− val title = session.title.ifEmpty { untitledLabel }
204− ListItem(
205− headlineContent = {
90+private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
91+ Row(
92+ modifier = Modifier
93+ .fillMaxWidth()
94+ .clickable(onClick = onOpen)
95+ .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
96+ verticalAlignment = Alignment.CenterVertically,
97+ ) {
98+ Icon(
99+ Icons.Filled.ChatBubbleOutline,
100+ contentDescription = null,
101+ tint = MaterialTheme.colorScheme.primary,
102+ modifier = Modifier.padding(end = 16.dp),
103+ )
104+ Column(Modifier.weight(1f)) {
206105 Text(
207− text = title,
106+ text = session.title,
107+ style = MaterialTheme.typography.bodyLarge,
208108 maxLines = 1,
209109 overflow = TextOverflow.Ellipsis,
210110 )
211− },
212− supportingContent = {
213111 Text(
214− text = idShortLabel,
112+ text = session.id,
113+ style = MaterialTheme.typography.bodySmall,
114+ color = MaterialTheme.colorScheme.onSurfaceVariant,
215115 maxLines = 1,
116+ overflow = TextOverflow.Ellipsis,
216117 )
217− },
218− leadingContent = {
118+ }
119+ IconButton(onClick = onDelete) {
219120 Icon(
220− Icons.Default.ChatBubble,
221− contentDescription = null,
222− tint = MaterialTheme.colorScheme.primary,
121+ Icons.Filled.DeleteOutline,
122+ contentDescription = "Delete session",
123+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
223124 )
224− },
225− trailingContent = {
226− IconButton(onClick = onDelete) {
227− Icon(
228− Icons.Default.Delete,
229− contentDescription = deleteCdLabel,
230− tint = MaterialTheme.colorScheme.onSurfaceVariant,
231− )
232− }
233− },
234− modifier = Modifier
235− .combinedClickable(
236− onClick = onClick,
237− onLongClick = onDelete,
238− )
239− .semantics(mergeDescendants = true) {
240− // Merge the row into a single TalkBack utterance and announce
241− // it as a button so users know it's actionable.
242− role = Role.Button
243− contentDescription = title
244− },
245− )
246−}
247−
248−@OptIn(ExperimentalMaterial3Api::class)
249−@Preview(showBackground = true)
250−@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
251−@Composable
252−private fun SessionsScreenPreview() {
253− OpencodeTheme {
254− Scaffold(topBar = {
255− TopAppBar(
256− title = { Text("Sessions") },
257− navigationIcon = {
258− IconButton(onClick = {}) {
259− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
260− }
261− },
262− actions = {
263− IconButton(onClick = {}) {
264− Icon(Icons.Default.Add, contentDescription = "New session")
265− }
266− },
267− )
268− }) { padding ->
269− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
270− LazyColumn(
271− contentPadding = PaddingValues(vertical = 12.dp),
272− ) {
273− items(
274− listOf(
275− SessionView("abc12345def", "Refactor the parser"),
276− SessionView("xyz12345abc", ""),
277− ),
278− key = { it.id },
279− ) { session ->
280− SessionRow(
281− session,
282− untitledLabel = "Untitled",
283− idShortLabel = "Session ${session.id.take(8)}",
284− deleteCdLabel = "Delete session",
285− onClick = {},
286− onDelete = {},
287− )
288− }
289− }
290− }
291125 }
292126 }
293127}
294128
295−@OptIn(ExperimentalMaterial3Api::class)
296−@Preview(showBackground = true)
297129@Composable
298−private fun SessionsScreenEmptyPreview() {
299− OpencodeTheme {
300− Scaffold(topBar = { TopAppBar(title = { Text("Sessions") }) }) { padding ->
301− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
302− EmptyState(message = "No sessions yet")
303− }
304− }
130+private fun EmptySessions() {
131+ Column(
132+ modifier = Modifier.fillMaxSize().padding(32.dp),
133+ verticalArrangement = Arrangement.Center,
134+ horizontalAlignment = Alignment.CenterHorizontally,
135+ ) {
136+ Text(
137+ text = "No sessions yet",
138+ style = MaterialTheme.typography.titleMedium,
139+ )
140+ Text(
141+ text = "Tap “New session” to start chatting.",
142+ style = MaterialTheme.typography.bodyMedium,
143+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ )
305145 }
306146}
app/src/main/java/soy/iko/opencode/ui/theme/Color.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.graphics.Color
4+
5+// A calm, terminal-adjacent palette. Used when dynamic color isn't available.
6+val Emerald = Color(0xFF3DDC84)
7+val EmeraldDark = Color(0xFF1B8F52)
8+
9+val DarkBackground = Color(0xFF0F1115)
10+val DarkSurface = Color(0xFF171A21)
11+val DarkSurfaceVariant = Color(0xFF232833)
12+val DarkOnSurface = Color(0xFFE6E9EF)
13+val DarkOutline = Color(0xFF3A4150)
14+
15+val LightBackground = Color(0xFFF7F8FA)
16+val LightSurface = Color(0xFFFFFFFF)
17+val LightSurfaceVariant = Color(0xFFEBEDF2)
18+val LightOnSurface = Color(0xFF1A1C1F)
19+val LightOutline = Color(0xFFC4C9D4)
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.ktdeleted−53
@@ -1,53 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.ui.unit.dp
4−
5−/**
6− * Centralized spacing/dimension tokens. Replaces the ~14 inline magic `dp`
7− * values that were scattered across the screen files. New UI should pull from
8− * here so spacing stays consistent and drift becomes visible.
9− */
10−object Dimens {
11− // Screen-edge padding
12− val screenPadding = 24.dp
13−
14− // List content padding (horizontal gutters + vertical breathing room)
15− val listHorizontalPadding = 12.dp
16− val listVerticalPadding = 12.dp
17− val listItemSpacing = 8.dp
18−
19− // Generic spacers
20− val spaceTiny = 4.dp
21− val spaceSmall = 8.dp
22− val spaceMedium = 12.dp
23− val spaceLarge = 16.dp
24− val spaceXLarge = 24.dp
25−
26− // Inline gaps (not large enough for a named "space" token but still shared)
27− val gapTiny = 6.dp
28−
29− // Bubble internals
30− val bubbleHorizontalPadding = 14.dp
31− val bubbleVerticalPadding = 10.dp
32− val bubbleTailCorner = 4.dp
33− val bubbleMaxWidthFraction = 0.82f
34−
35− // Input bar
36− val inputBarHorizontalPadding = 12.dp
37− val inputBarVerticalPadding = 8.dp
38− val inputBarTonalElevation = 3.dp
39−
40− // Icons
41− val iconHero = 72.dp
42− val iconInputLeading = 16.dp
43− val iconInlineSpinner = 14.dp
44− val iconButtonSpinner = 20.dp
45−
46− // Strokes
47− val strokeThin = 2.dp
48−
49− // Floating action button
50− val fabSize = 40.dp
51− val fabEndMargin = 16.dp
52− val fabBottomMargin = 12.dp
53−}
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktdeleted−33
@@ -1,33 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.foundation.shape.RoundedCornerShape
4−import androidx.compose.material3.Shapes
5−import androidx.compose.ui.unit.dp
6−
7−/**
8− * Centralized shape tokens. The chat-bubble shapes are defined here so the
9− * user/assistant bubbles and the "generating" indicator all share one source
10− * of truth instead of being hand-duplicated at each call site.
11− */
12−val OpencodeShapes = Shapes(
13− extraSmall = RoundedCornerShape(4.dp),
14− small = RoundedCornerShape(8.dp),
15− medium = RoundedCornerShape(12.dp),
16− large = RoundedCornerShape(16.dp),
17− extraLarge = RoundedCornerShape(28.dp),
18−)
19−
20−/** Tail-at-corner shapes for chat bubbles. */
21−val UserBubbleShape = RoundedCornerShape(
22− topStart = 16.dp,
23− topEnd = 16.dp,
24− bottomStart = 16.dp,
25− bottomEnd = 4.dp,
26−)
27−
28−val AssistantBubbleShape = RoundedCornerShape(
29− topStart = 16.dp,
30− topEnd = 16.dp,
31− bottomStart = 4.dp,
32− bottomEnd = 16.dp,
33−)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+26−29
@@ -1,5 +1,6 @@
11package soy.iko.opencode.ui.theme
22
3+import android.app.Activity
34import android.os.Build
45import androidx.compose.foundation.isSystemInDarkTheme
56import androidx.compose.material3.MaterialTheme
@@ -11,50 +12,46 @@ import androidx.compose.runtime.Composable
1112import androidx.compose.ui.graphics.Color
1213import androidx.compose.ui.platform.LocalContext
1314
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(0xFF1A1A1A),
23− onBackground = Color.White,
24− onSurface = Color.White,
15+private val DarkColors = darkColorScheme(
16+ primary = Emerald,
17+ onPrimary = Color(0xFF0F1115),
18+ secondary = EmeraldDark,
19+ background = DarkBackground,
20+ surface = DarkSurface,
21+ surfaceVariant = DarkSurfaceVariant,
22+ onSurface = DarkOnSurface,
23+ outline = DarkOutline,
2524)
2625
27−private val LightColorScheme = lightColorScheme(
28− primary = Color(0xFF1565C0),
29− secondary = Color(0xFF00897B),
30− tertiary = Color(0xFFE65100),
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,
26+private val LightColors = lightColorScheme(
27+ primary = EmeraldDark,
28+ secondary = Emerald,
29+ background = LightBackground,
30+ surface = LightSurface,
31+ surfaceVariant = LightSurfaceVariant,
32+ onSurface = LightOnSurface,
33+ outline = LightOutline,
3834)
3935
36+private val Color0x0F1115 = androidx.compose.ui.graphics.Color(0xFF0F1115)
37+
4038@Composable
4139fun OpencodeTheme(
4240 darkTheme: Boolean = isSystemInDarkTheme(),
4341 dynamicColor: Boolean = true,
4442 content: @Composable () -> Unit,
4543) {
44+ val context = LocalContext.current
4645 val colorScheme = when {
47− dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
48− val context = LocalContext.current
46+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
4947 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
50− }
51− darkTheme -> DarkColorScheme
52− else -> LightColorScheme
48+ darkTheme -> DarkColors
49+ else -> LightColors
5350 }
51+
5452 MaterialTheme(
5553 colorScheme = colorScheme,
56− typography = OpencodeTypography,
57− shapes = OpencodeShapes,
54+ typography = AppTypography,
5855 content = content,
5956 )
6057}
app/src/main/java/soy/iko/opencode/ui/theme/Type.kt+7−62
@@ -3,68 +3,13 @@ package soy.iko.opencode.ui.theme
33import androidx.compose.material3.Typography
44import androidx.compose.ui.text.TextStyle
55import androidx.compose.ui.text.font.FontFamily
6−import androidx.compose.ui.text.font.FontWeight
76import androidx.compose.ui.unit.sp
87
9−/**
10− * Typography scale. Stays close to the Material 3 baseline but pins the values
11− * so that font sizes, weights, and letter spacing don't silently shift when
12− * the Compose BOM updates. The screens reference these via
13− * [androidx.compose.material3.MaterialTheme.typography].
14− */
15−val OpencodeTypography = Typography(
16− headlineSmall = TextStyle(
17− fontFamily = FontFamily.Default,
18− fontWeight = FontWeight.SemiBold,
19− fontSize = 24.sp,
20− lineHeight = 32.sp,
21− letterSpacing = 0.sp,
22− ),
23− titleLarge = TextStyle(
24− fontFamily = FontFamily.Default,
25− fontWeight = FontWeight.SemiBold,
26− fontSize = 22.sp,
27− lineHeight = 28.sp,
28− ),
29− bodyLarge = TextStyle(
30− fontFamily = FontFamily.Default,
31− fontWeight = FontWeight.Normal,
32− fontSize = 16.sp,
33− lineHeight = 24.sp,
34− letterSpacing = 0.5.sp,
35− ),
36− bodyMedium = TextStyle(
37− fontFamily = FontFamily.Default,
38− fontWeight = FontWeight.Normal,
39− fontSize = 14.sp,
40− lineHeight = 20.sp,
41− letterSpacing = 0.25.sp,
42− ),
43− bodySmall = TextStyle(
44− fontFamily = FontFamily.Default,
45− fontWeight = FontWeight.Normal,
46− fontSize = 12.sp,
47− lineHeight = 16.sp,
48− ),
49− labelLarge = TextStyle(
50− fontFamily = FontFamily.Default,
51− fontWeight = FontWeight.Medium,
52− fontSize = 14.sp,
53− lineHeight = 20.sp,
54− letterSpacing = 0.1.sp,
55− ),
56− labelMedium = TextStyle(
57− fontFamily = FontFamily.Default,
58− fontWeight = FontWeight.Medium,
59− fontSize = 12.sp,
60− lineHeight = 16.sp,
61− letterSpacing = 0.5.sp,
62− ),
63− labelSmall = TextStyle(
64− fontFamily = FontFamily.Default,
65− fontWeight = FontWeight.Medium,
66− fontSize = 11.sp,
67− lineHeight = 16.sp,
68− letterSpacing = 0.5.sp,
69− ),
8+private val base = Typography()
9+
10+val AppTypography = base.copy(
11+ bodyLarge = base.bodyLarge.copy(lineHeight = 22.sp),
12+ // Slightly monospaced feel for message bodies is applied inline where needed.
7013)
14+
15+val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/res/values-night-v31/themes.xmldeleted−8
@@ -1,8 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<resources>
3− <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4− <item name="android:windowBackground">@color/splash_background</item>
5− <item name="android:windowSplashScreenBackground">@color/splash_background</item>
6− <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
7− </style>
8−</resources>
app/src/main/res/values-night/colors.xml+1−1
@@ -1,4 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <color name="splash_background">#FF1E1E1E</color>
3+ <color name="window_background">#0F1115</color>
44</resources>
app/src/main/res/values-night/themes.xml+4−2
@@ -1,6 +1,8 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4− <item name="android:windowBackground">@color/splash_background</item>
3+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar">
4+ <item name="android:windowBackground">@color/window_background</item>
5+ <item name="android:statusBarColor">@android:color/transparent</item>
6+ <item name="android:navigationBarColor">@android:color/transparent</item>
57 </style>
68</resources>
app/src/main/res/values-v31/themes.xmldeleted−12
@@ -1,12 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<resources>
3− <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
4− <item name="android:windowBackground">@color/splash_background</item>
5− <!-- Android 12+ system splash screen: use the window background color
6− so the post-splash crossfade into the Compose UI is seamless. The
7− Compose UI paints its own background, so we only set the poster
8− color and a system icon here. -->
9− <item name="android:windowSplashScreenBackground">@color/splash_background</item>
10− <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
11− </style>
12−</resources>
app/src/main/res/values/colors.xml+1−1
@@ -1,4 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <color name="splash_background">#FFF5F5F5</color>
3+ <color name="window_background">#F7F8FA</color>
44</resources>
app/src/main/res/values/strings.xml+2−101
@@ -1,103 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
12<resources>
2− <string name="app_name">Opencode</string>
3−
4− <!-- Top bar titles -->
5− <string name="top_bar_connect">Opencode</string>
6− <string name="top_bar_sessions">Sessions</string>
7− <string name="top_bar_chat_fallback">Chat</string>
8−
9− <!-- Connect screen -->
10− <string name="connect_heading">Connect to Opencode Server</string>
11− <string name="connect_field_server_url">Server URL</string>
12− <string name="connect_field_username">Username</string>
13− <string name="connect_field_password">Password</string>
14− <string name="connect_auth_required">Server requires authentication</string>
15− <string name="connect_password_hide">Hide password</string>
16− <string name="connect_password_show">Show password</string>
17− <string name="connect_button_cancel">Cancel</string>
18− <string name="connect_button_connect">Connect</string>
19− <string name="connect_success">Connected</string>
20− <string name="connect_cd_dns">Connection icon</string>
21− <string name="connect_cd_lock">Credentials icon</string>
22− <string name="connect_cd_warning">Warning icon</string>
23− <string name="connect_error_invalid_url">Enter a valid URL (http:// or https://)</string>
24−
25− <!-- Crash log -->
26− <string name="crash_reports_title">Crash Reports</string>
27− <plurals name="crash_reports_view">
28− <item quantity="one">%d crash report — view</item>
29− <item quantity="other">%d crash reports — view</item>
30− </plurals>
31− <string name="crash_reports_clear">Clear all</string>
32− <string name="crash_reports_close">Close</string>
33− <string name="crash_reports_cancel">Cancel</string>
34− <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
35− <string name="crash_reports_clear_confirm_yes">Clear</string>
36−
37− <!-- Sessions screen -->
38− <string name="sessions_empty_hint">No sessions yet. Tap + to create one.</string>
39− <string name="sessions_cd_back">Change server</string>
40− <string name="sessions_cd_new">New session</string>
41− <string name="sessions_untitled">Untitled</string>
42− <string name="sessions_id_short">Session %s</string>
43− <string name="session_created">Session created</string>
44−
45− <!-- Chat screen -->
46− <string name="chat_empty_hint">Send a message below to start the conversation.</string>
47− <string name="chat_no_content">(no content)</string>
48− <string name="chat_input_placeholder">Message…</string>
49− <string name="chat_cd_back">Back</string>
50− <string name="chat_cd_send">Send</string>
51− <string name="chat_role_user">User</string>
52− <string name="chat_role_assistant">Assistant</string>
53− <string name="chat_cd_generating">Generating reply</string>
54− <string name="chat_generating_label">Thinking…</string>
55− <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
56− <string name="chat_scroll_to_latest">Latest</string>
57− <string name="chat_copied">Copied</string>
58− <string name="chat_cd_stop">Stop generating</string>
59− <string name="chat_session_id_short">Session %s</string>
60− <string name="chat_menu_overflow">More options</string>
61− <string name="chat_menu_refresh">Refresh messages</string>
62−
63− <!-- Error / snackbar -->
64− <string name="action_dismiss">Dismiss</string>
65− <string name="action_retry">Retry</string>
66−
67− <!-- SSE / connectivity -->
68− <string name="sse_disconnected">Live stream disconnected</string>
69− <string name="sse_reconnect">Reconnect</string>
70− <string name="sse_dismiss">Dismiss</string>
71− <string name="sse_connecting">Connecting live stream…</string>
72− <string name="sse_live">Live</string>
73− <string name="press_back_again_to_exit">Press back again to exit</string>
74−
75− <!-- Loading / common -->
76− <string name="loading">Loading</string>
77− <string name="date_today">Today</string>
78− <string name="date_yesterday">Yesterday</string>
79− <string name="session_deleted">Session deleted</string>
80−
81− <!-- Sessions screen: delete / disconnect -->
82− <string name="sessions_cd_delete">Delete session</string>
83− <string name="sessions_delete_confirm">Delete this session? This cannot be undone.</string>
84− <string name="sessions_delete_confirm_yes">Delete</string>
85− <string name="sessions_disconnect_confirm">Disconnect from the server?</string>
86− <string name="sessions_disconnect_confirm_yes">Disconnect</string>
87− <string name="sessions_cd_disconnect">Disconnect from server</string>
88−
89− <!-- Chat: session ID copy / SSE status -->
90− <string name="chat_cd_copy_session_id">Copy session ID</string>
91− <string name="chat_session_id_copied">Session ID copied</string>
92−
93− <!-- Chat: message status -->
94− <string name="chat_message_status_pending">Sending</string>
95− <string name="chat_message_status_failed">Failed to send</string>
96− <string name="chat_cd_message_status_failed">Failed to send. Long-press to retry.</string>
97−
98− <!-- Crash log dialog -->
99− <string name="crash_reports_copy">Copy</string>
100− <string name="crash_reports_share">Share</string>
101− <string name="crash_reports_copied">Crash report copied</string>
102− <string name="crash_reports_empty">No crash reports</string>
3+ <string name="app_name">opencode</string>
1034</resources>
app/src/main/res/values/themes.xml+9−7
@@ -1,11 +1,13 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <!-- Base theme for the Compose app. Uses a neutral NoActionBar parent so
4− the legacy Material (Material1) widget styling doesn't clash with the
5− Compose Material3 UI that paints on top. The window background matches
6− the Compose surface color so the pre-Compose splash crossfade is
7− seamless. -->
8− <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
9− <item name="android:windowBackground">@color/splash_background</item>
3+ <!--
4+ Minimal platform theme. All real theming is done in Compose (OpencodeTheme);
5+ this only removes the action bar and gives a neutral window background so
6+ there's no flash before the first frame.
7+ -->
8+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.Light.NoActionBar">
9+ <item name="android:windowBackground">@color/window_background</item>
10+ <item name="android:statusBarColor">@android:color/transparent</item>
11+ <item name="android:navigationBarColor">@android:color/transparent</item>
1012 </style>
1113</resources>
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.ktdeleted−284
@@ -1,284 +0,0 @@
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−class PureCoreFfiTest {
10−
11− private fun createCore(): PureCoreFfi = PureCoreFfi()
12−
13− private fun PureCoreFfi.currentView(): ViewModel =
14− ViewModel.bincodeDeserialize(view())
15−
16− private fun PureCoreFfi.processEvent(event: Event): Requests {
17− val effects = update(event.bincodeSerialize())
18− return Requests.bincodeDeserialize(effects)
19− }
20−
21− private fun PureCoreFfi.resolveHttp(
22− requestId: UInt,
23− status: Int = 200,
24− body: String = "{}",
25− ): Requests {
26− val response = HttpResponse(
27− status = status.toUShort(),
28− headers = emptyList(),
29− body = com.novi.serde.Bytes(body.toByteArray()),
30− )
31− val result = HttpResult.Ok(response)
32− val effects = resolve(requestId, result.bincodeSerialize())
33− return Requests.bincodeDeserialize(effects)
34− }
35−
36− @Test
37− fun `start event sets default server URL`() {
38− val core = createCore()
39− core.processEvent(Event.Start)
40− val view = core.currentView()
41− assertEquals("http://localhost:4096", view.serverUrl)
42− assertEquals(Screen.CONNECT, view.screen)
43− }
44−
45− @Test
46− fun `server URL change updates view`() {
47− val core = createCore()
48− core.processEvent(Event.Start)
49− core.processEvent(Event.ServerUrlChanged("http://10.0.0.1:4096"))
50− val view = core.currentView()
51− assertEquals("http://10.0.0.1:4096", view.serverUrl)
52− }
53−
54− @Test
55− fun `connect emits HTTP effect`() {
56− val core = createCore()
57− core.processEvent(Event.Start)
58− val requests = core.processEvent(Event.Connect)
59− assertEquals(1, requests.value.size)
60− assertTrue(requests.value[0].effect is Effect.Http)
61− val view = core.currentView()
62− assertTrue(view.loading)
63− }
64−
65− @Test
66− fun `successful connect transitions to sessions screen`() {
67− val core = createCore()
68− core.processEvent(Event.Start)
69− val requests = core.processEvent(Event.Connect)
70− val httpReq = requests.value[0]
71− assertEquals(Effect.Http::class, httpReq.effect::class)
72−
73− // Simulate health check success — triggers LoadSessions
74− val followUpRequests = core.resolveHttp(httpReq.id, body = """{"healthy":true}""")
75− assertEquals(1, followUpRequests.value.size)
76−
77− // Simulate sessions loaded
78− val sessionsBody = """[{"id":"s1","title":"My Session"}]"""
79− val finalRequests = core.resolveHttp(followUpRequests.value[0].id, body = sessionsBody)
80− assertEquals(0, finalRequests.value.size)
81−
82− val view = core.currentView()
83− assertTrue(view.connected)
84− assertEquals(Screen.SESSIONS, view.screen)
85− assertEquals(1, view.sessions.size)
86− assertEquals("s1", view.sessions[0].id)
87− assertEquals("My Session", view.sessions[0].title)
88− }
89−
90− @Test
91− fun `failed connect shows error`() {
92− val core = createCore()
93− core.processEvent(Event.Start)
94− val requests = core.processEvent(Event.Connect)
95−
96− val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
97− val effects = core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
98− Requests.bincodeDeserialize(effects)
99−
100− val view = core.currentView()
101− assertNotNull(view.error)
102− assertFalse(view.loading)
103− }
104−
105− @Test
106− fun `dismiss error clears error`() {
107− val core = createCore()
108− core.processEvent(Event.Start)
109− val requests = core.processEvent(Event.Connect)
110−
111− val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
112− core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
113−
114− core.processEvent(Event.DismissError)
115− val view = core.currentView()
116− assertNull(view.error)
117− }
118−
119− @Test
120− fun `select session loads messages and transitions to chat`() {
121− val core = createCore()
122− core.processEvent(Event.Start)
123− val connectReqs = core.processEvent(Event.Connect)
124− core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
125− core.resolveHttp(0u, body = "[]")
126−
127− val selectReqs = core.processEvent(Event.SelectSession("session-123"))
128− assertTrue(selectReqs.value.isNotEmpty())
129−
130− val view = core.currentView()
131− assertEquals("session-123", view.currentSessionId)
132− assertEquals(Screen.CHAT, view.screen)
133− }
134−
135− @Test
136− fun `401 response sets auth_required in view`() {
137− val core = createCore()
138− core.processEvent(Event.Start)
139− val requests = core.processEvent(Event.Connect)
140−
141− // Simulate 401 — server behind basic auth
142− core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
143−
144− val view = core.currentView()
145− assertTrue(view.authRequired)
146− assertFalse(view.loading)
147− assertEquals(Screen.CONNECT, view.screen)
148− }
149−
150− @Test
151− fun `401 with credentials shows invalid credentials error`() {
152− val core = createCore()
153− core.processEvent(Event.Start)
154− core.processEvent(Event.UsernameChanged("opencode"))
155− core.processEvent(Event.PasswordChanged("wrong"))
156− val requests = core.processEvent(Event.Connect)
157−
158− // Simulate 401 despite credentials — invalid credentials
159− core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
160−
161− val view = core.currentView()
162− assertNotNull(view.error)
163− assertTrue(view.error!!.contains("Invalid credentials"))
164− assertFalse(view.authRequired)
165− }
166−
167− @Test
168− fun `cancel auth clears auth_required and credentials`() {
169− val core = createCore()
170− core.processEvent(Event.Start)
171− val requests = core.processEvent(Event.Connect)
172− core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
173−
174− core.processEvent(Event.CancelAuth)
175− val view = core.currentView()
176− assertFalse(view.authRequired)
177− assertEquals("", view.username)
178− assertEquals("", view.password)
179− }
180−
181− @Test
182− fun `successful connect with credentials marks authed`() {
183− val core = createCore()
184− core.processEvent(Event.Start)
185− core.processEvent(Event.UsernameChanged("opencode"))
186− core.processEvent(Event.PasswordChanged("secret"))
187− val requests = core.processEvent(Event.Connect)
188−
189− // Health check with credentials succeeds
190− val followUp = core.resolveHttp(requests.value[0].id, body = """{"healthy":true}""")
191− assertEquals(1, followUp.value.size)
192−
193− val sessionsBody = """[{"id":"s1","title":"Authed Session"}]"""
194− core.resolveHttp(followUp.value[0].id, body = sessionsBody)
195−
196− val view = core.currentView()
197− assertTrue(view.connected)
198− assertEquals(Screen.SESSIONS, view.screen)
199− }
200−
201− @Test
202− fun `parseMessages extracts text from parts array`() {
203− val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello world"}]}]"""
204− val messages = parseMessages(json)
205− assertEquals(1, messages.size)
206− assertEquals("m1", messages[0].id)
207− assertEquals("user", messages[0].role)
208− assertEquals("Hello world", messages[0].text)
209− assertEquals(100uL, messages[0].time)
210− }
211−
212− @Test
213− fun `parseMessages falls back to top-level text field`() {
214− val json = """[{"id":"m2","role":"assistant","created":200,"text":"Fallback"}]"""
215− val messages = parseMessages(json)
216− assertEquals(1, messages.size)
217− assertEquals("Fallback", messages[0].text)
218− }
219−
220− @Test
221− fun `extractJsonString returns null for missing key`() {
222− assertNull(extractJsonString("""{"a":"b"}""", "id"))
223− }
224−
225− @Test
226− fun `extractJsonNumber returns null for missing key`() {
227− assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
228− }
229−
230− @Test
231− fun `draft changed updates view`() {
232− val core = createCore()
233− core.processEvent(Event.Start)
234− core.processEvent(Event.DraftChanged("in progress"))
235− val view = core.currentView()
236− assertEquals("in progress", view.draftMessage)
237− }
238−
239− @Test
240− fun `cancel generation clears generating flag`() {
241− val core = createCore()
242− core.processEvent(Event.Start)
243− // Simulate connected + session selected
244− val connectReqs = core.processEvent(Event.Connect)
245− val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
246− core.resolveHttp(loadSessionsReqs.value[0].id, body = "[]")
247− val selectReqs = core.processEvent(Event.SelectSession("session-123"))
248− core.resolveHttp(selectReqs.value[0].id, body = "[]")
249−
250− // CancelGeneration is a no-op when not generating, but shouldn't crash
251− core.processEvent(Event.CancelGeneration)
252− val view = core.currentView()
253− assertFalse(view.generating)
254− }
255−
256− @Test
257− fun `delete session removes from list and emits http effect`() {
258− val core = createCore()
259− core.processEvent(Event.Start)
260− val connectReqs = core.processEvent(Event.Connect)
261− // Health check success → triggers LoadSessions
262− val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
263− // Sessions loaded
264− val sessionsBody = """[{"id":"s1","title":"First"},{"id":"s2","title":"Second"}]"""
265− core.resolveHttp(loadSessionsReqs.value[0].id, body = sessionsBody)
266−
267− val viewBefore = core.currentView()
268− assertEquals(2, viewBefore.sessions.size)
269−
270− val deleteReqs = core.processEvent(Event.DeleteSession("s1"))
271− assertTrue(deleteReqs.value.isNotEmpty())
272− val viewAfter = core.currentView()
273− assertEquals(1, viewAfter.sessions.size)
274− assertEquals("s2", viewAfter.sessions[0].id)
275− }
276−
277− @Test
278− fun `parseMessages sets status to sent`() {
279− val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello"}]}]"""
280− val messages = parseMessages(json)
281− assertEquals(1, messages.size)
282− assertEquals(MessageStatus.SENT, messages[0].status)
283− }
284−}
build.gradle.kts−1
@@ -2,6 +2,5 @@ plugins {
22 alias(libs.plugins.android.application) apply false
33 alias(libs.plugins.kotlin.android) apply false
44 alias(libs.plugins.kotlin.compose) apply false
5− alias(libs.plugins.android.library) apply false
65 alias(libs.plugins.kotlin.serialization) apply false
76}
flake.nix+28−17
@@ -1,5 +1,5 @@
11{
2− description = "Dev environment for opencode-android (Crux Rust core + Kotlin / Jetpack Compose shell)";
2+ description = "Dev environment for opencode-android — a Crux (Rust) core + Kotlin / Jetpack Compose shell";
33
44 inputs = {
55 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -9,14 +9,17 @@
99 outputs = { self, nixpkgs, flake-utils }:
1010 flake-utils.lib.eachDefaultSystem (system:
1111 let
12− # Versions kept in sync with the Gradle project (compileSdk / build-tools).
13− platformVersion = "36";
14− buildToolsVersion = "36.0.0";
12+ # Kept in sync with the Gradle project (see gradle/libs.versions.toml and
13+ # app/build.gradle.kts). These are the versions actually provisioned below,
14+ # so `compileSdk` / `buildToolsVersion` must match or Gradle will try to
15+ # download SDK components at build time.
16+ platformVersion = "35";
17+ buildToolsVersion = "35.0.0";
1518
1619 pkgs = import nixpkgs {
1720 inherit system;
1821 config = {
19− allowUnfree = true; # Android SDK is unfree
22+ allowUnfree = true; # the Android SDK is unfree
2023 android_sdk.accept_license = true;
2124 };
2225 };
@@ -28,42 +31,50 @@
2831
2932 includeEmulator = false;
3033 includeSystemImages = false;
31− systemImageTypes = [ "google_apis" ];
32− abiVersions = [ "x86_64" ];
3334
35+ # The Rust core is NOT cross-compiled to a JNI .so in this setup: nixpkgs'
36+ # `rustc` ships no Android std and no NDK is provisioned. The core is built
37+ # and unit-tested natively (`cargo test`), and the Android shell runs a
38+ # faithful Kotlin port of the reducer. Flip `includeNDK = true` and add
39+ # `cargo-ndk` below to build the real .so once a cross-toolchain is present.
3440 includeNDK = false;
3541 };
3642
37− sdk = android.androidsdk;
38− sdkRoot = "${sdk}/libexec/android-sdk";
39−
43+ sdk = android.androidsdk;
44+ sdkRoot = "${sdk}/libexec/android-sdk";
4045 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
4146 in {
4247 devShells.default = pkgs.mkShell {
4348 buildInputs = [
49+ # JVM / Android toolchain
4450 pkgs.jdk17
4551 pkgs.gradle
52+ sdk
53+
54+ # Rust toolchain for the Crux core (build, test, typegen codegen)
4655 pkgs.rustc
4756 pkgs.cargo
48− sdk
4957 ];
5058
5159 ANDROID_HOME = sdkRoot;
5260 ANDROID_SDK_ROOT = sdkRoot;
5361 JAVA_HOME = "${pkgs.jdk17}";
5462
63+ # AGP resolves aapt2 from a Maven artifact by default; point it at the
64+ # SDK-provided binary so the build works fully offline / hermetically.
5565 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
5666
5767 shellHook = ''
5868 echo "opencode-android dev shell"
59− echo " JDK: $(java -version 2>&1 | head -1)"
60− echo " ANDROID_HOME: $ANDROID_HOME"
61− echo " aapt2: ${aapt2Path}"
69+ echo " JDK: $(java -version 2>&1 | head -1)"
6270 echo " Rust: $(rustc --version)"
71+ echo " Gradle: $(gradle --version 2>/dev/null | awk '/^Gradle/{print $2}')"
72+ echo " ANDROID_HOME: $ANDROID_HOME"
6373 echo ""
64− echo "Build: ./gradlew assembleDebug"
65− echo "Core: cd shared && cargo test"
66− echo "Device: adb devices"
74+ echo " Core: (cd shared && cargo test) # Crux core unit tests"
75+ echo " Types: (cd shared && cargo run --bin codegen)"
76+ echo " App: ./gradlew assembleDebug # build debug APK"
77+ echo " Unit: ./gradlew testDebugUnitTest"
6778 '';
6879 };
6980 });
generated/build.gradle.ktsdeleted−21
@@ -1,21 +0,0 @@
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.javanew file+38
@@ -0,0 +1,38 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode;
5+
6+import com.novi.serde.DeserializationError;
7+import com.novi.serde.Slice;
8+import com.novi.serde.BinaryDeserializer;
9+
10+public class BincodeDeserializer extends BinaryDeserializer {
11+ public BincodeDeserializer(byte[] input) {
12+ super(input, Long.MAX_VALUE);
13+ }
14+
15+ public Float deserialize_f32() throws DeserializationError {
16+ return Float.valueOf(getFloat());
17+ }
18+
19+ public Double deserialize_f64() throws DeserializationError {
20+ return Double.valueOf(getDouble());
21+ }
22+
23+ public long deserialize_len() throws DeserializationError {
24+ long value = getLong();
25+ if (value < 0 || value > Integer.MAX_VALUE) {
26+ throw new DeserializationError("Incorrect length value");
27+ }
28+ return value;
29+ }
30+
31+ public int deserialize_variant_index() throws DeserializationError {
32+ return getInt();
33+ }
34+
35+ public void check_that_key_slices_are_increasing(Slice key1, Slice key2) throws DeserializationError {
36+ // Not required by the format.
37+ }
38+}
generated/com/novi/bincode/BincodeDeserializer.ktdeleted−39
@@ -1,39 +0,0 @@
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.javanew file+33
@@ -0,0 +1,33 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode;
5+
6+import com.novi.serde.SerializationError;
7+import com.novi.serde.BinarySerializer;
8+
9+public class BincodeSerializer extends BinarySerializer {
10+ public BincodeSerializer() {
11+ super(Long.MAX_VALUE);
12+ }
13+
14+ public void serialize_f32(Float value) throws SerializationError {
15+ serialize_i32(Integer.valueOf(Float.floatToRawIntBits(value.floatValue())));
16+ }
17+
18+ public void serialize_f64(Double value) throws SerializationError {
19+ serialize_i64(Long.valueOf(Double.doubleToRawLongBits(value.doubleValue())));
20+ }
21+
22+ public void serialize_len(long value) throws SerializationError {
23+ serialize_u64(value);
24+ }
25+
26+ public void serialize_variant_index(int value) throws SerializationError {
27+ serialize_u32(value);
28+ }
29+
30+ public void sort_map_entries(int[] offsets) {
31+ // Not required by the format.
32+ }
33+}
generated/com/novi/bincode/BincodeSerializer.ktdeleted−33
@@ -1,33 +0,0 @@
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/ArrayLen.javanew file+12
@@ -0,0 +1,12 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface ArrayLen {
11+ int length();
12+}
generated/com/novi/serde/BinaryDeserializer.javanew file+195
@@ -0,0 +1,195 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.nio.ByteBuffer;
7+import java.nio.ByteOrder;
8+import java.nio.charset.CharsetDecoder;
9+import java.nio.charset.StandardCharsets;
10+import java.nio.charset.CharacterCodingException;
11+import java.math.BigInteger;
12+
13+public abstract class BinaryDeserializer implements Deserializer {
14+ protected ByteBuffer input;
15+ private long containerDepthBudget;
16+
17+ public BinaryDeserializer(byte[] input, long maxContainerDepth) {
18+ this.input = ByteBuffer.wrap(input);
19+ this.input.order(ByteOrder.LITTLE_ENDIAN);
20+ containerDepthBudget = maxContainerDepth;
21+ }
22+
23+ public void increase_container_depth() throws DeserializationError {
24+ if (containerDepthBudget == 0) {
25+ throw new DeserializationError("Exceeded maximum container depth");
26+ }
27+ containerDepthBudget -= 1;
28+ }
29+
30+ public void decrease_container_depth() {
31+ containerDepthBudget += 1;
32+ }
33+
34+ public String deserialize_str() throws DeserializationError {
35+ long len = deserialize_len();
36+ if (len < 0 || len > Integer.MAX_VALUE) {
37+ throw new DeserializationError("Incorrect length value for Java string");
38+ }
39+ byte[] content = new byte[(int) len];
40+ read(content);
41+ CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
42+ try {
43+ decoder.decode(ByteBuffer.wrap(content));
44+ } catch (CharacterCodingException ex) {
45+ throw new DeserializationError("Incorrect UTF8 string");
46+ }
47+ return new String(content);
48+ }
49+
50+ public Bytes deserialize_bytes() throws DeserializationError {
51+ long len = deserialize_len();
52+ if (len < 0 || len > Integer.MAX_VALUE) {
53+ throw new DeserializationError("Incorrect length value for Java array");
54+ }
55+ byte[] content = new byte[(int) len];
56+ read(content);
57+ return new Bytes(content);
58+ }
59+
60+ public Boolean deserialize_bool() throws DeserializationError {
61+ byte value = getByte();
62+ if (value == 0) {
63+ return Boolean.valueOf(false);
64+ }
65+ if (value == 1) {
66+ return Boolean.valueOf(true);
67+ }
68+ throw new DeserializationError("Incorrect boolean value");
69+ }
70+
71+ public Unit deserialize_unit() throws DeserializationError {
72+ return new Unit();
73+ }
74+
75+ public Character deserialize_char() throws DeserializationError {
76+ throw new DeserializationError("Not implemented: deserialize_char");
77+ }
78+
79+ public @Unsigned Byte deserialize_u8() throws DeserializationError {
80+ return Byte.valueOf(getByte());
81+ }
82+
83+ public @Unsigned Short deserialize_u16() throws DeserializationError {
84+ return Short.valueOf(getShort());
85+ }
86+
87+ public @Unsigned Integer deserialize_u32() throws DeserializationError {
88+ return Integer.valueOf(getInt());
89+ }
90+
91+ public @Unsigned Long deserialize_u64() throws DeserializationError {
92+ return Long.valueOf(getLong());
93+ }
94+
95+ public @Unsigned @Int128 BigInteger deserialize_u128() throws DeserializationError {
96+ BigInteger signed = deserialize_i128();
97+ if (signed.compareTo(BigInteger.ZERO) >= 0) {
98+ return signed;
99+ } else {
100+ return signed.add(BigInteger.ONE.shiftLeft(128));
101+ }
102+ }
103+
104+ public Byte deserialize_i8() throws DeserializationError {
105+ return Byte.valueOf(getByte());
106+ }
107+
108+ public Short deserialize_i16() throws DeserializationError {
109+ return Short.valueOf(getShort());
110+ }
111+
112+ public Integer deserialize_i32() throws DeserializationError {
113+ return Integer.valueOf(getInt());
114+ }
115+
116+ public Long deserialize_i64() throws DeserializationError {
117+ return Long.valueOf(getLong());
118+ }
119+
120+ public @Int128 BigInteger deserialize_i128() throws DeserializationError {
121+ byte[] content = new byte[16];
122+ read(content);
123+ byte[] reversed = new byte[16];
124+ for (int i = 0; i < 16; i++) {
125+ reversed[i] = content[15 - i];
126+ }
127+ return new BigInteger(reversed);
128+ }
129+
130+ public boolean deserialize_option_tag() throws DeserializationError {
131+ return deserialize_bool().booleanValue();
132+ }
133+
134+ public int get_buffer_offset() {
135+ return input.position();
136+ }
137+
138+ static final String INPUT_NOT_LARGE_ENOUGH = "Input is not large enough";
139+
140+ protected byte getByte() throws DeserializationError {
141+ try {
142+ return input.get();
143+ } catch (java.nio.BufferUnderflowException e) {
144+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
145+ }
146+ }
147+
148+ protected short getShort() throws DeserializationError {
149+ try {
150+ return input.getShort();
151+ } catch (java.nio.BufferUnderflowException e) {
152+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
153+ }
154+ }
155+
156+ protected int getInt() throws DeserializationError {
157+ try {
158+ return input.getInt();
159+ } catch (java.nio.BufferUnderflowException e) {
160+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
161+ }
162+ }
163+
164+ protected long getLong() throws DeserializationError {
165+ try {
166+ return input.getLong();
167+ } catch (java.nio.BufferUnderflowException e) {
168+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
169+ }
170+ }
171+
172+ protected float getFloat() throws DeserializationError {
173+ try {
174+ return input.getFloat();
175+ } catch (java.nio.BufferUnderflowException e) {
176+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
177+ }
178+ }
179+
180+ protected double getDouble() throws DeserializationError {
181+ try {
182+ return input.getDouble();
183+ } catch (java.nio.BufferUnderflowException e) {
184+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
185+ }
186+ }
187+
188+ protected void read(byte[] content) throws DeserializationError {
189+ try {
190+ input.get(content);
191+ } catch (java.nio.BufferUnderflowException e) {
192+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
193+ }
194+ }
195+}
generated/com/novi/serde/BinaryDeserializer.ktdeleted−182
@@ -1,182 +0,0 @@
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.javanew file+146
@@ -0,0 +1,146 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public abstract class BinarySerializer implements Serializer {
9+ protected MyByteArrayOutputStream output;
10+ private long containerDepthBudget;
11+
12+ public BinarySerializer(long maxContainerDepth) {
13+ output = new BinarySerializer.MyByteArrayOutputStream();
14+ containerDepthBudget = maxContainerDepth;
15+ }
16+
17+ public void increase_container_depth() throws SerializationError {
18+ if (containerDepthBudget == 0) {
19+ throw new SerializationError("Exceeded maximum container depth");
20+ }
21+ containerDepthBudget -= 1;
22+ }
23+
24+ public void decrease_container_depth() {
25+ containerDepthBudget += 1;
26+ }
27+
28+ public void serialize_str(String value) throws SerializationError {
29+ serialize_bytes(new Bytes(value.getBytes()));
30+ }
31+
32+ public void serialize_bytes(Bytes value) throws SerializationError {
33+ byte[] content = value.content();
34+ serialize_len(content.length);
35+ output.write(content, 0, content.length);
36+ }
37+
38+ public void serialize_bool(Boolean value) throws SerializationError {
39+ output.write((value.booleanValue() ? 1 : 0));
40+ }
41+
42+ public void serialize_unit(Unit value) throws SerializationError {
43+ }
44+
45+ public void serialize_char(Character value) throws SerializationError {
46+ throw new SerializationError("Not implemented: serialize_char");
47+ }
48+
49+ public void serialize_u8(@Unsigned Byte value) throws SerializationError {
50+ output.write(value.byteValue());
51+ }
52+
53+ public void serialize_u16(@Unsigned Short value) throws SerializationError {
54+ short val = value.shortValue();
55+ output.write((byte) (val >>> 0));
56+ output.write((byte) (val >>> 8));
57+ }
58+
59+ public void serialize_u32(@Unsigned Integer value) throws SerializationError {
60+ int val = value.intValue();
61+ output.write((byte) (val >>> 0));
62+ output.write((byte) (val >>> 8));
63+ output.write((byte) (val >>> 16));
64+ output.write((byte) (val >>> 24));
65+ }
66+
67+ public void serialize_u64(@Unsigned Long value) throws SerializationError {
68+ long val = value.longValue();
69+ output.write((byte) (val >>> 0));
70+ output.write((byte) (val >>> 8));
71+ output.write((byte) (val >>> 16));
72+ output.write((byte) (val >>> 24));
73+ output.write((byte) (val >>> 32));
74+ output.write((byte) (val >>> 40));
75+ output.write((byte) (val >>> 48));
76+ output.write((byte) (val >>> 56));
77+ }
78+
79+ public void serialize_u128(@Unsigned @Int128 BigInteger value) throws SerializationError {
80+ if (value.compareTo(BigInteger.ZERO) < 0 || !value.shiftRight(128).equals(BigInteger.ZERO)) {
81+ throw new java.lang.IllegalArgumentException("Invalid value for an unsigned int128");
82+ }
83+ byte[] content = value.toByteArray();
84+ // BigInteger.toByteArray() may add a most-significant zero
85+ // byte for signing purpose: ignore it.
86+ assert content.length <= 16 || content[0] == 0;
87+ int len = Math.min(content.length, 16);
88+ // Write content in little-endian order.
89+ for (int i = 0; i < len; i++) {
90+ output.write(content[content.length - 1 - i]);
91+ }
92+ // Complete with zeros if needed.
93+ for (int i = len; i < 16; i++) {
94+ output.write(0);
95+ }
96+ }
97+
98+ public void serialize_i8(Byte value) throws SerializationError {
99+ serialize_u8(value);
100+ }
101+
102+ public void serialize_i16(Short value) throws SerializationError {
103+ serialize_u16(value);
104+ }
105+
106+ public void serialize_i32(Integer value) throws SerializationError {
107+ serialize_u32(value);
108+ }
109+
110+ public void serialize_i64(Long value) throws SerializationError {
111+ serialize_u64(value);
112+ }
113+
114+ public void serialize_i128(@Int128 BigInteger value) throws SerializationError {
115+ if (value.compareTo(BigInteger.ZERO) >= 0) {
116+ if (!value.shiftRight(127).equals(BigInteger.ZERO)) {
117+ throw new java.lang.IllegalArgumentException("Invalid value for a signed int128");
118+ }
119+ serialize_u128(value);
120+ } else {
121+ if (!value.add(BigInteger.ONE).negate().shiftRight(127).equals(BigInteger.ZERO)) {
122+ throw new java.lang.IllegalArgumentException("Invalid value for a signed int128");
123+ }
124+ serialize_u128(value.add(BigInteger.ONE.shiftLeft(128)));
125+ }
126+ }
127+
128+ public void serialize_option_tag(boolean value) throws SerializationError {
129+ output.write((value ? (byte) 1 : (byte) 0));
130+ }
131+
132+ public int get_buffer_offset() {
133+ return output.size();
134+ }
135+
136+ public byte[] get_bytes() {
137+ return output.toByteArray();
138+ }
139+
140+ // Local extension to provide access to the underlying buffer.
141+ static public class MyByteArrayOutputStream extends java.io.ByteArrayOutputStream {
142+ public byte[] getBuffer() {
143+ return buf;
144+ }
145+ }
146+}
generated/com/novi/serde/BinarySerializer.ktdeleted−122
@@ -1,122 +0,0 @@
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.javanew file+54
@@ -0,0 +1,54 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Arrays;
7+import java.util.Objects;
8+
9+/**
10+ * Immutable wrapper class around byte[].
11+ *
12+ * Enforces value-semantice for `equals` and `hashCode`.
13+ */
14+public final class Bytes {
15+ private final byte[] content;
16+
17+ private static final Bytes EMPTY = new Bytes(new byte[0]);
18+
19+ /// Low-level constructor (use with care).
20+ public Bytes(byte[] content) {
21+ Objects.requireNonNull(content, "content must not be null");
22+ this.content = content;
23+ }
24+
25+ public static Bytes empty() {
26+ return EMPTY;
27+ }
28+
29+ public static Bytes valueOf(byte[] content) {
30+ Objects.requireNonNull(content, "content must not be null");
31+ if (content.length == 0) {
32+ return EMPTY;
33+ } else {
34+ return new Bytes(content.clone());
35+ }
36+ }
37+
38+ public byte[] content() {
39+ return this.content.clone();
40+ }
41+
42+ public boolean equals(Object obj) {
43+ if (this == obj) return true;
44+ if (obj == null) return false;
45+ if (getClass() != obj.getClass()) return false;
46+ Bytes other = (Bytes) obj;
47+ return Arrays.equals(this.content, other.content);
48+ }
49+
50+ public int hashCode() {
51+ return Arrays.hashCode(content);
52+ }
53+
54+}
generated/com/novi/serde/Bytes.ktdeleted−39
@@ -1,39 +0,0 @@
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.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+@SuppressWarnings("serial")
7+public final class DeserializationError extends Exception {
8+ public DeserializationError(String s) {
9+ super(s);
10+ }
11+}
generated/com/novi/serde/DeserializationError.ktdeleted−6
@@ -1,6 +0,0 @@
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.javanew file+56
@@ -0,0 +1,56 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public interface Deserializer {
9+ String deserialize_str() throws DeserializationError;
10+
11+ Bytes deserialize_bytes() throws DeserializationError;
12+
13+ Boolean deserialize_bool() throws DeserializationError;
14+
15+ Unit deserialize_unit() throws DeserializationError;
16+
17+ Character deserialize_char() throws DeserializationError;
18+
19+ Float deserialize_f32() throws DeserializationError;
20+
21+ Double deserialize_f64() throws DeserializationError;
22+
23+ @Unsigned Byte deserialize_u8() throws DeserializationError;
24+
25+ @Unsigned Short deserialize_u16() throws DeserializationError;
26+
27+ @Unsigned Integer deserialize_u32() throws DeserializationError;
28+
29+ @Unsigned Long deserialize_u64() throws DeserializationError;
30+
31+ @Unsigned @Int128 BigInteger deserialize_u128() throws DeserializationError;
32+
33+ Byte deserialize_i8() throws DeserializationError;
34+
35+ Short deserialize_i16() throws DeserializationError;
36+
37+ Integer deserialize_i32() throws DeserializationError;
38+
39+ Long deserialize_i64() throws DeserializationError;
40+
41+ @Int128 BigInteger deserialize_i128() throws DeserializationError;
42+
43+ long deserialize_len() throws DeserializationError;
44+
45+ int deserialize_variant_index() throws DeserializationError;
46+
47+ boolean deserialize_option_tag() throws DeserializationError;
48+
49+ void increase_container_depth() throws DeserializationError;
50+
51+ void decrease_container_depth();
52+
53+ int get_buffer_offset();
54+
55+ void check_that_key_slices_are_increasing(Slice key1, Slice key2) throws DeserializationError;
56+}
generated/com/novi/serde/Deserializer.ktdeleted−76
@@ -1,76 +0,0 @@
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.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface Int128 {
11+}
generated/com/novi/serde/Int128.ktdeleted−6
@@ -1,6 +0,0 @@
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.ktdeleted−48
@@ -1,48 +0,0 @@
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.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+@SuppressWarnings("serial")
7+public final class SerializationError extends Exception {
8+ public SerializationError(String s) {
9+ super(s);
10+ }
11+}
generated/com/novi/serde/SerializationError.ktdeleted−6
@@ -1,6 +0,0 @@
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.javanew file+58
@@ -0,0 +1,58 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public interface Serializer {
9+ void serialize_str(String value) throws SerializationError;
10+
11+ void serialize_bytes(Bytes value) throws SerializationError;
12+
13+ void serialize_bool(Boolean value) throws SerializationError;
14+
15+ void serialize_unit(Unit value) throws SerializationError;
16+
17+ void serialize_char(Character value) throws SerializationError;
18+
19+ void serialize_f32(Float value) throws SerializationError;
20+
21+ void serialize_f64(Double value) throws SerializationError;
22+
23+ void serialize_u8(@Unsigned Byte value) throws SerializationError;
24+
25+ void serialize_u16(@Unsigned Short value) throws SerializationError;
26+
27+ void serialize_u32(@Unsigned Integer value) throws SerializationError;
28+
29+ void serialize_u64(@Unsigned Long value) throws SerializationError;
30+
31+ void serialize_u128(@Unsigned @Int128 BigInteger value) throws SerializationError;
32+
33+ void serialize_i8(Byte value) throws SerializationError;
34+
35+ void serialize_i16(Short value) throws SerializationError;
36+
37+ void serialize_i32(Integer value) throws SerializationError;
38+
39+ void serialize_i64(Long value) throws SerializationError;
40+
41+ void serialize_i128(@Int128 BigInteger value) throws SerializationError;
42+
43+ void serialize_len(long value) throws SerializationError;
44+
45+ void serialize_variant_index(int value) throws SerializationError;
46+
47+ void serialize_option_tag(boolean value) throws SerializationError;
48+
49+ void increase_container_depth() throws SerializationError;
50+
51+ void decrease_container_depth();
52+
53+ int get_buffer_offset();
54+
55+ void sort_map_entries(int[] offsets);
56+
57+ byte[] get_bytes();
58+}
generated/com/novi/serde/Serializer.ktdeleted−77
@@ -1,77 +0,0 @@
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.javanew file+40
@@ -0,0 +1,40 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+public final class Slice {
7+ public final int start;
8+ public final int end;
9+
10+ public Slice(int start, int end) {
11+ this.start = start;
12+ this.end = end;
13+ }
14+
15+ // Lexicographic comparison between the (unsigned!) bytes referenced by `slice1` and `slice2`
16+ // into `content`.
17+ public static int compare_bytes(byte[] content, Slice slice1, Slice slice2) {
18+ int start1 = slice1.start;
19+ int end1 = slice1.end;
20+ int start2 = slice2.start;
21+ int end2 = slice2.end;
22+ for (int i = 0; i < end1 - start1; i++) {
23+ int byte1 = content[start1 + i] & 0xFF;
24+ if (start2 + i >= end2) {
25+ return 1;
26+ }
27+ int byte2 = content[start2 + i] & 0xFF;
28+ if (byte1 > byte2) {
29+ return 1;
30+ }
31+ if (byte1 < byte2) {
32+ return -1;
33+ }
34+ }
35+ if (end2 - start2 > end1 - start1) {
36+ return -1;
37+ }
38+ return 0;
39+ }
40+}
generated/com/novi/serde/Slice.ktdeleted−36
@@ -1,36 +0,0 @@
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/Tuple2.javanew file+40
@@ -0,0 +1,40 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple2<T0, T1> {
9+ public final T0 field0;
10+ public final T1 field1;
11+
12+ public Tuple2(T0 f0, T1 f1) {
13+ Objects.requireNonNull(f0, "f0 must not be null");
14+ Objects.requireNonNull(f1, "f1 must not be null");
15+ this.field0 = f0;
16+ this.field1 = f1;
17+ }
18+
19+ public boolean equals(Object obj) {
20+ if (this == obj) return true;
21+ if (obj == null) return false;
22+ if (getClass() != obj.getClass()) return false;
23+ Tuple2<?,?> other = (Tuple2) obj;
24+ if (!Objects.equals(this.field0, other.field0)) {
25+ return false;
26+ }
27+ if (!Objects.equals(this.field1, other.field1)) {
28+ return false;
29+ }
30+ return true;
31+ }
32+
33+ public int hashCode() {
34+ int value = 7;
35+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
36+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
37+ return value;
38+ }
39+
40+}
generated/com/novi/serde/Tuple3.javanew file+46
@@ -0,0 +1,46 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple3<T0, T1, T2> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+
13+ public Tuple3(T0 f0, T1 f1, T2 f2) {
14+ Objects.requireNonNull(f0, "f0 must not be null");
15+ Objects.requireNonNull(f1, "f1 must not be null");
16+ Objects.requireNonNull(f2, "f2 must not be null");
17+ this.field0 = f0;
18+ this.field1 = f1;
19+ this.field2 = f2;
20+ }
21+
22+ public boolean equals(Object obj) {
23+ if (this == obj) return true;
24+ if (obj == null) return false;
25+ if (getClass() != obj.getClass()) return false;
26+ Tuple3<?,?,?> other = (Tuple3) obj;
27+ if (!Objects.equals(this.field0, other.field0)) {
28+ return false;
29+ }
30+ if (!Objects.equals(this.field1, other.field1)) {
31+ return false;
32+ }
33+ if (!Objects.equals(this.field2, other.field2)) {
34+ return false;
35+ }
36+ return true;
37+ }
38+
39+ public int hashCode() {
40+ int value = 7;
41+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
42+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
43+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
44+ return value;
45+ }
46+}
generated/com/novi/serde/Tuple4.javanew file+54
@@ -0,0 +1,54 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple4<T0, T1, T2, T3> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+
14+ public Tuple4(T0 f0, T1 f1, T2 f2, T3 f3) {
15+ Objects.requireNonNull(f0, "f0 must not be null");
16+ Objects.requireNonNull(f1, "f1 must not be null");
17+ Objects.requireNonNull(f2, "f2 must not be null");
18+ Objects.requireNonNull(f3, "f3 must not be null");
19+ this.field0 = f0;
20+ this.field1 = f1;
21+ this.field2 = f2;
22+ this.field3 = f3;
23+ }
24+
25+ public boolean equals(Object obj) {
26+ if (this == obj) return true;
27+ if (obj == null) return false;
28+ if (getClass() != obj.getClass()) return false;
29+ Tuple4<?,?,?,?> other = (Tuple4) obj;
30+ if (!Objects.equals(this.field0, other.field0)) {
31+ return false;
32+ }
33+ if (!Objects.equals(this.field1, other.field1)) {
34+ return false;
35+ }
36+ if (!Objects.equals(this.field2, other.field2)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field3, other.field3)) {
40+ return false;
41+ }
42+ return true;
43+ }
44+
45+ public int hashCode() {
46+ int value = 7;
47+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
48+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
49+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
50+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
51+ return value;
52+ }
53+
54+}
generated/com/novi/serde/Tuple4.ktdeleted−6
@@ -1,6 +0,0 @@
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.javanew file+61
@@ -0,0 +1,61 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple5<T0, T1, T2, T3, T4> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+ public final T4 field4;
14+
15+ public Tuple5(T0 f0, T1 f1, T2 f2, T3 f3, T4 f4) {
16+ Objects.requireNonNull(f0, "f0 must not be null");
17+ Objects.requireNonNull(f1, "f1 must not be null");
18+ Objects.requireNonNull(f2, "f2 must not be null");
19+ Objects.requireNonNull(f3, "f3 must not be null");
20+ Objects.requireNonNull(f4, "f4 must not be null");
21+ this.field0 = f0;
22+ this.field1 = f1;
23+ this.field2 = f2;
24+ this.field3 = f3;
25+ this.field4 = f4;
26+ }
27+
28+ public boolean equals(Object obj) {
29+ if (this == obj) return true;
30+ if (obj == null) return false;
31+ if (getClass() != obj.getClass()) return false;
32+ Tuple5<?,?,?,?,?> other = (Tuple5) obj;
33+ if (!Objects.equals(this.field0, other.field0)) {
34+ return false;
35+ }
36+ if (!Objects.equals(this.field1, other.field1)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field2, other.field2)) {
40+ return false;
41+ }
42+ if (!Objects.equals(this.field3, other.field3)) {
43+ return false;
44+ }
45+ if (!Objects.equals(this.field4, other.field4)) {
46+ return false;
47+ }
48+ return true;
49+ }
50+
51+ public int hashCode() {
52+ int value = 7;
53+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
54+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
55+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
56+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
57+ value = 31 * value + (this.field4 != null ? this.field4.hashCode() : 0);
58+ return value;
59+ }
60+
61+}
generated/com/novi/serde/Tuple5.ktdeleted−12
@@ -1,12 +0,0 @@
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.javanew file+67
@@ -0,0 +1,67 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple6<T0, T1, T2, T3, T4, T5> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+ public final T4 field4;
14+ public final T5 field5;
15+
16+ public Tuple6(T0 f0, T1 f1, T2 f2, T3 f3, T4 f4, T5 f5) {
17+ Objects.requireNonNull(f0, "f0 must not be null");
18+ Objects.requireNonNull(f1, "f1 must not be null");
19+ Objects.requireNonNull(f2, "f2 must not be null");
20+ Objects.requireNonNull(f3, "f3 must not be null");
21+ Objects.requireNonNull(f4, "f4 must not be null");
22+ Objects.requireNonNull(f5, "f5 must not be null");
23+ this.field0 = f0;
24+ this.field1 = f1;
25+ this.field2 = f2;
26+ this.field3 = f3;
27+ this.field4 = f4;
28+ this.field5 = f5;
29+ }
30+
31+ public boolean equals(Object obj) {
32+ if (this == obj) return true;
33+ if (obj == null) return false;
34+ if (getClass() != obj.getClass()) return false;
35+ Tuple6<?,?,?,?,?,?> other = (Tuple6) obj;
36+ if (!Objects.equals(this.field0, other.field0)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field1, other.field1)) {
40+ return false;
41+ }
42+ if (!Objects.equals(this.field2, other.field2)) {
43+ return false;
44+ }
45+ if (!Objects.equals(this.field3, other.field3)) {
46+ return false;
47+ }
48+ if (!Objects.equals(this.field4, other.field4)) {
49+ return false;
50+ }
51+ if (!Objects.equals(this.field5, other.field5)) {
52+ return false;
53+ }
54+ return true;
55+ }
56+
57+ public int hashCode() {
58+ int value = 7;
59+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
60+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
61+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
62+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
63+ value = 31 * value + (this.field4 != null ? this.field4.hashCode() : 0);
64+ value = 31 * value + (this.field5 != null ? this.field5.hashCode() : 0);
65+ return value;
66+ }
67+}
generated/com/novi/serde/Tuple6.ktdeleted−13
@@ -1,13 +0,0 @@
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.ktdeleted−6
@@ -1,6 +0,0 @@
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/com/novi/serde/Unit.javanew file+20
@@ -0,0 +1,20 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+public final class Unit {
7+ public Unit() {}
8+
9+ public boolean equals(Object obj) {
10+ if (this == obj) return true;
11+ if (obj == null) return false;
12+ if (getClass() != obj.getClass()) return false;
13+ return true;
14+ }
15+
16+ public int hashCode() {
17+ return 7;
18+ }
19+
20+}
generated/com/novi/serde/Unsigned.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface Unsigned {
11+}
generated/soy/iko/opencode/core/Core.ktdeleted−1291
@@ -1,1291 +0,0 @@
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 NavigateToConnect: 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): NavigateToConnect {
374− return NavigateToConnect
375− }
376− }
377−
378− data object DismissError: Event {
379− override fun serialize(serializer: Serializer) {
380− serializer.increase_container_depth()
381− serializer.serialize_variant_index(15)
382− serializer.decrease_container_depth()
383− }
384−
385− fun deserialize(deserializer: Deserializer): DismissError {
386− return DismissError
387− }
388− }
389−
390− data class DraftChanged(
391− val value: String,
392− ) : Event {
393− override fun serialize(serializer: Serializer) {
394− serializer.increase_container_depth()
395− serializer.serialize_variant_index(16)
396− serializer.serialize_str(value)
397− serializer.decrease_container_depth()
398− }
399−
400− companion object {
401− fun deserialize(deserializer: Deserializer): DraftChanged {
402− deserializer.increase_container_depth()
403− val value = deserializer.deserialize_str()
404− deserializer.decrease_container_depth()
405− return DraftChanged(value)
406− }
407− }
408− }
409−
410− data object CancelGeneration: Event {
411− override fun serialize(serializer: Serializer) {
412− serializer.increase_container_depth()
413− serializer.serialize_variant_index(17)
414− serializer.decrease_container_depth()
415− }
416−
417− fun deserialize(deserializer: Deserializer): CancelGeneration {
418− return CancelGeneration
419− }
420− }
421−
422− data class DeleteSession(
423− val value: String,
424− ) : Event {
425− override fun serialize(serializer: Serializer) {
426− serializer.increase_container_depth()
427− serializer.serialize_variant_index(18)
428− serializer.serialize_str(value)
429− serializer.decrease_container_depth()
430− }
431−
432− companion object {
433− fun deserialize(deserializer: Deserializer): DeleteSession {
434− deserializer.increase_container_depth()
435− val value = deserializer.deserialize_str()
436− deserializer.decrease_container_depth()
437− return DeleteSession(value)
438− }
439− }
440− }
441−
442− data class RetryMessage(
443− val value: String,
444− ) : Event {
445− override fun serialize(serializer: Serializer) {
446− serializer.increase_container_depth()
447− serializer.serialize_variant_index(19)
448− serializer.serialize_str(value)
449− serializer.decrease_container_depth()
450− }
451−
452− companion object {
453− fun deserialize(deserializer: Deserializer): RetryMessage {
454− deserializer.increase_container_depth()
455− val value = deserializer.deserialize_str()
456− deserializer.decrease_container_depth()
457− return RetryMessage(value)
458− }
459− }
460− }
461−
462− companion object {
463− @Throws(DeserializationError::class)
464− fun deserialize(deserializer: Deserializer): Event {
465− val index = deserializer.deserialize_variant_index()
466− return when (index) {
467− 0 -> Start.deserialize(deserializer)
468− 1 -> ServerUrlChanged.deserialize(deserializer)
469− 2 -> UsernameChanged.deserialize(deserializer)
470− 3 -> PasswordChanged.deserialize(deserializer)
471− 4 -> Connect.deserialize(deserializer)
472− 5 -> CancelAuth.deserialize(deserializer)
473− 6 -> LoadSessions.deserialize(deserializer)
474− 7 -> SelectSession.deserialize(deserializer)
475− 8 -> CreateSession.deserialize(deserializer)
476− 9 -> LoadMessages.deserialize(deserializer)
477− 10 -> SendMessage.deserialize(deserializer)
478− 11 -> EventReceived.deserialize(deserializer)
479− 12 -> NavigateToChat.deserialize(deserializer)
480− 13 -> NavigateToSessions.deserialize(deserializer)
481− 14 -> NavigateToConnect.deserialize(deserializer)
482− 15 -> DismissError.deserialize(deserializer)
483− 16 -> DraftChanged.deserialize(deserializer)
484− 17 -> CancelGeneration.deserialize(deserializer)
485− 18 -> DeleteSession.deserialize(deserializer)
486− 19 -> RetryMessage.deserialize(deserializer)
487− else -> throw DeserializationError("Unknown variant index for Event: $index")
488− }
489− }
490−
491− @Throws(DeserializationError::class)
492− fun bincodeDeserialize(input: ByteArray?): Event {
493− if (input == null) {
494− throw DeserializationError("Cannot deserialize null array")
495− }
496− val deserializer = BincodeDeserializer(input)
497− val value = deserialize(deserializer)
498− if (deserializer.get_buffer_offset() < input.size) {
499− throw DeserializationError("Some input bytes were not read")
500− }
501− return value
502− }
503− }
504−}
505−
506−/// An error produced when an HTTP request fails.
507−///
508−/// Variants fall into two groups:
509−///
510−/// **Transport errors** — generated by the shell when it cannot complete the HTTP
511−/// exchange. These cross the FFI boundary and are serialized in the protocol:
512−/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
513−///
514−/// **Processing errors** — generated on the Rust side after a response arrives.
515−/// These are never serialized or visible to shells:
516−///
517−/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
518−/// a 4xx or 5xx status. At the *protocol* level these arrive as
519−/// [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
520−/// them here, so app code using `crux_http::Result<Response<T>>` will see them as
521−/// `Err(HttpError::Http { code, .. })`.
522−/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
523−sealed interface HttpError {
524− fun serialize(serializer: Serializer)
525−
526− fun bincodeSerialize(): ByteArray {
527− val serializer = BincodeSerializer()
528− serialize(serializer)
529− return serializer.get_bytes()
530− }
531−
532− /// The request URL could not be parsed.
533− data class Url(
534− val value: String,
535− ) : HttpError {
536− override fun serialize(serializer: Serializer) {
537− serializer.increase_container_depth()
538− serializer.serialize_variant_index(0)
539− serializer.serialize_str(value)
540− serializer.decrease_container_depth()
541− }
542−
543− companion object {
544− fun deserialize(deserializer: Deserializer): Url {
545− deserializer.increase_container_depth()
546− val value = deserializer.deserialize_str()
547− deserializer.decrease_container_depth()
548− return Url(value)
549− }
550− }
551− }
552−
553− /// An IO error prevented the request from completing.
554− data class Io(
555− val value: String,
556− ) : HttpError {
557− override fun serialize(serializer: Serializer) {
558− serializer.increase_container_depth()
559− serializer.serialize_variant_index(1)
560− serializer.serialize_str(value)
561− serializer.decrease_container_depth()
562− }
563−
564− companion object {
565− fun deserialize(deserializer: Deserializer): Io {
566− deserializer.increase_container_depth()
567− val value = deserializer.deserialize_str()
568− deserializer.decrease_container_depth()
569− return Io(value)
570− }
571− }
572− }
573−
574− /// The request timed out before a response was received.
575− data object Timeout: HttpError {
576− override fun serialize(serializer: Serializer) {
577− serializer.increase_container_depth()
578− serializer.serialize_variant_index(2)
579− serializer.decrease_container_depth()
580− }
581−
582− fun deserialize(deserializer: Deserializer): Timeout {
583− return Timeout
584− }
585− }
586−
587− companion object {
588− @Throws(DeserializationError::class)
589− fun deserialize(deserializer: Deserializer): HttpError {
590− val index = deserializer.deserialize_variant_index()
591− return when (index) {
592− 0 -> Url.deserialize(deserializer)
593− 1 -> Io.deserialize(deserializer)
594− 2 -> Timeout.deserialize(deserializer)
595− else -> throw DeserializationError("Unknown variant index for HttpError: $index")
596− }
597− }
598−
599− @Throws(DeserializationError::class)
600− fun bincodeDeserialize(input: ByteArray?): HttpError {
601− if (input == null) {
602− throw DeserializationError("Cannot deserialize null array")
603− }
604− val deserializer = BincodeDeserializer(input)
605− val value = deserialize(deserializer)
606− if (deserializer.get_buffer_offset() < input.size) {
607− throw DeserializationError("Some input bytes were not read")
608− }
609− return value
610− }
611− }
612−}
613−
614−data class HttpHeader(
615− val name: String,
616− val value: String,
617−) {
618− fun serialize(serializer: Serializer) {
619− serializer.increase_container_depth()
620− serializer.serialize_str(name)
621− serializer.serialize_str(value)
622− serializer.decrease_container_depth()
623− }
624−
625− fun bincodeSerialize(): ByteArray {
626− val serializer = BincodeSerializer()
627− serialize(serializer)
628− return serializer.get_bytes()
629− }
630−
631− companion object {
632− fun deserialize(deserializer: Deserializer): HttpHeader {
633− deserializer.increase_container_depth()
634− val name = deserializer.deserialize_str()
635− val value = deserializer.deserialize_str()
636− deserializer.decrease_container_depth()
637− return HttpHeader(name, value)
638− }
639−
640− @Throws(DeserializationError::class)
641− fun bincodeDeserialize(input: ByteArray?): HttpHeader {
642− if (input == null) {
643− throw DeserializationError("Cannot deserialize null array")
644− }
645− val deserializer = BincodeDeserializer(input)
646− val value = deserialize(deserializer)
647− if (deserializer.get_buffer_offset() < input.size) {
648− throw DeserializationError("Some input bytes were not read")
649− }
650− return value
651− }
652− }
653−}
654−
655−/// A raw HTTP request, as sent to the shell over the protocol boundary.
656−///
657−/// # No header validation
658−///
659−/// All fields are plain strings. Header names and values are carried as-is with no
660−/// validation against the HTTP specification. This is intentional: `HttpRequest` is a
661−/// cross-language data carrier deserialised by Swift, Kotlin, and TypeScript shells;
662−/// Rust's `http`-crate validation rules cannot be enforced on the other side of that
663−/// boundary.
664−///
665−/// **Shell authors must not assume that header names or values are well-formed.**
666−/// Pass them to your underlying HTTP client as-is — it will apply its own rules.
667−///
668−/// For the ergonomic Rust-side builder that *does* validate header values (and panics
669−/// on invalid input), see [`crate::command::RequestBuilder`].
670−data class HttpRequest(
671− val method: String,
672− val url: String,
673− val headers: List<soy.iko.opencode.core.HttpHeader>,
674− val body: Bytes,
675−) {
676− fun serialize(serializer: Serializer) {
677− serializer.increase_container_depth()
678− serializer.serialize_str(method)
679− serializer.serialize_str(url)
680− headers.serialize(serializer) {
681− it.serialize(serializer)
682− }
683− serializer.serialize_bytes(body)
684− serializer.decrease_container_depth()
685− }
686−
687− fun bincodeSerialize(): ByteArray {
688− val serializer = BincodeSerializer()
689− serialize(serializer)
690− return serializer.get_bytes()
691− }
692−
693− companion object {
694− fun deserialize(deserializer: Deserializer): HttpRequest {
695− deserializer.increase_container_depth()
696− val method = deserializer.deserialize_str()
697− val url = deserializer.deserialize_str()
698− val headers =
699− deserializer.deserializeListOf {
700− soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
701− }
702− val body = deserializer.deserialize_bytes()
703− deserializer.decrease_container_depth()
704− return HttpRequest(method, url, headers, body)
705− }
706−
707− @Throws(DeserializationError::class)
708− fun bincodeDeserialize(input: ByteArray?): HttpRequest {
709− if (input == null) {
710− throw DeserializationError("Cannot deserialize null array")
711− }
712− val deserializer = BincodeDeserializer(input)
713− val value = deserialize(deserializer)
714− if (deserializer.get_buffer_offset() < input.size) {
715− throw DeserializationError("Some input bytes were not read")
716− }
717− return value
718− }
719− }
720−}
721−
722−data class HttpResponse(
723− val status: UShort,
724− val headers: List<soy.iko.opencode.core.HttpHeader>,
725− val body: Bytes,
726−) {
727− fun serialize(serializer: Serializer) {
728− serializer.increase_container_depth()
729− serializer.serialize_u16(status)
730− headers.serialize(serializer) {
731− it.serialize(serializer)
732− }
733− serializer.serialize_bytes(body)
734− serializer.decrease_container_depth()
735− }
736−
737− fun bincodeSerialize(): ByteArray {
738− val serializer = BincodeSerializer()
739− serialize(serializer)
740− return serializer.get_bytes()
741− }
742−
743− companion object {
744− fun deserialize(deserializer: Deserializer): HttpResponse {
745− deserializer.increase_container_depth()
746− val status = deserializer.deserialize_u16()
747− val headers =
748− deserializer.deserializeListOf {
749− soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
750− }
751− val body = deserializer.deserialize_bytes()
752− deserializer.decrease_container_depth()
753− return HttpResponse(status, headers, body)
754− }
755−
756− @Throws(DeserializationError::class)
757− fun bincodeDeserialize(input: ByteArray?): HttpResponse {
758− if (input == null) {
759− throw DeserializationError("Cannot deserialize null array")
760− }
761− val deserializer = BincodeDeserializer(input)
762− val value = deserialize(deserializer)
763− if (deserializer.get_buffer_offset() < input.size) {
764− throw DeserializationError("Some input bytes were not read")
765− }
766− return value
767− }
768− }
769−}
770−
771−/// The result of an HTTP request, as returned by the shell over the protocol boundary.
772−///
773−/// # Status codes are not errors
774−///
775−/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
776−/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
777−/// reach the server at all) produce [`HttpResult::Err`].
778−///
779−/// To act on an error status, inspect [`HttpResponse::status`]:
780−///
781−/// ```
782−/// # use crux_http::protocol::{HttpResult, HttpResponse};
783−/// # use crux_http::HttpError;
784−/// # fn handle(result: HttpResult) {
785−/// match result {
786−/// HttpResult::Ok(response) if response.status == 200 => { /* success */ }
787−/// HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
788−/// HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
789−/// HttpResult::Ok(_) => { /* other status */ }
790−/// HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
791−/// }
792−/// # }
793−/// ```
794−sealed interface HttpResult {
795− fun serialize(serializer: Serializer)
796−
797− fun bincodeSerialize(): ByteArray {
798− val serializer = BincodeSerializer()
799− serialize(serializer)
800− return serializer.get_bytes()
801− }
802−
803− /// The shell completed the HTTP exchange. The response may carry any status code,
804− /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
805− data class Ok(
806− val value: soy.iko.opencode.core.HttpResponse,
807− ) : HttpResult {
808− override fun serialize(serializer: Serializer) {
809− serializer.increase_container_depth()
810− serializer.serialize_variant_index(0)
811− value.serialize(serializer)
812− serializer.decrease_container_depth()
813− }
814−
815− companion object {
816− fun deserialize(deserializer: Deserializer): Ok {
817− deserializer.increase_container_depth()
818− val value = soy.iko.opencode.core.HttpResponse.deserialize(deserializer)
819− deserializer.decrease_container_depth()
820− return Ok(value)
821− }
822− }
823− }
824−
825− /// The shell could not complete the HTTP exchange due to a transport-level failure.
826− /// See [`HttpError`] for the possible causes.
827− data class Err(
828− val value: soy.iko.opencode.core.HttpError,
829− ) : HttpResult {
830− override fun serialize(serializer: Serializer) {
831− serializer.increase_container_depth()
832− serializer.serialize_variant_index(1)
833− value.serialize(serializer)
834− serializer.decrease_container_depth()
835− }
836−
837− companion object {
838− fun deserialize(deserializer: Deserializer): Err {
839− deserializer.increase_container_depth()
840− val value = soy.iko.opencode.core.HttpError.deserialize(deserializer)
841− deserializer.decrease_container_depth()
842− return Err(value)
843− }
844− }
845− }
846−
847− companion object {
848− @Throws(DeserializationError::class)
849− fun deserialize(deserializer: Deserializer): HttpResult {
850− val index = deserializer.deserialize_variant_index()
851− return when (index) {
852− 0 -> Ok.deserialize(deserializer)
853− 1 -> Err.deserialize(deserializer)
854− else -> throw DeserializationError("Unknown variant index for HttpResult: $index")
855− }
856− }
857−
858− @Throws(DeserializationError::class)
859− fun bincodeDeserialize(input: ByteArray?): HttpResult {
860− if (input == null) {
861− throw DeserializationError("Cannot deserialize null array")
862− }
863− val deserializer = BincodeDeserializer(input)
864− val value = deserialize(deserializer)
865− if (deserializer.get_buffer_offset() < input.size) {
866− throw DeserializationError("Some input bytes were not read")
867− }
868− return value
869− }
870− }
871−}
872−
873−enum class MessageStatus {
874− SENT,
875− PENDING,
876− FAILED;
877−
878− fun serialize(serializer: Serializer) {
879− serializer.increase_container_depth()
880− serializer.serialize_variant_index(ordinal)
881− serializer.decrease_container_depth()
882− }
883−
884− fun bincodeSerialize(): ByteArray {
885− val serializer = BincodeSerializer()
886− serialize(serializer)
887− return serializer.get_bytes()
888− }
889−
890− companion object {
891− @Throws(DeserializationError::class)
892− fun deserialize(deserializer: Deserializer): MessageStatus {
893− deserializer.increase_container_depth()
894− val index = deserializer.deserialize_variant_index()
895− deserializer.decrease_container_depth()
896− return when (index) {
897− 0 -> SENT
898− 1 -> PENDING
899− 2 -> FAILED
900− else -> throw DeserializationError("Unknown variant index for MessageStatus: $index")
901− }
902− }
903−
904− @Throws(DeserializationError::class)
905− fun bincodeDeserialize(input: ByteArray?): MessageStatus {
906− if (input == null) {
907− throw DeserializationError("Cannot deserialize null array")
908− }
909− val deserializer = BincodeDeserializer(input)
910− val value = deserialize(deserializer)
911− if (deserializer.get_buffer_offset() < input.size) {
912− throw DeserializationError("Some input bytes were not read")
913− }
914− return value
915− }
916− }
917−}
918−
919−data class MessageView(
920− val id: String,
921− val role: String,
922− val text: String,
923− val time: ULong,
924− val status: soy.iko.opencode.core.MessageStatus,
925−) {
926− fun serialize(serializer: Serializer) {
927− serializer.increase_container_depth()
928− serializer.serialize_str(id)
929− serializer.serialize_str(role)
930− serializer.serialize_str(text)
931− serializer.serialize_u64(time)
932− status.serialize(serializer)
933− serializer.decrease_container_depth()
934− }
935−
936− fun bincodeSerialize(): ByteArray {
937− val serializer = BincodeSerializer()
938− serialize(serializer)
939− return serializer.get_bytes()
940− }
941−
942− companion object {
943− fun deserialize(deserializer: Deserializer): MessageView {
944− deserializer.increase_container_depth()
945− val id = deserializer.deserialize_str()
946− val role = deserializer.deserialize_str()
947− val text = deserializer.deserialize_str()
948− val time = deserializer.deserialize_u64()
949− val status = soy.iko.opencode.core.MessageStatus.deserialize(deserializer)
950− deserializer.decrease_container_depth()
951− return MessageView(id, role, text, time, status)
952− }
953−
954− @Throws(DeserializationError::class)
955− fun bincodeDeserialize(input: ByteArray?): MessageView {
956− if (input == null) {
957− throw DeserializationError("Cannot deserialize null array")
958− }
959− val deserializer = BincodeDeserializer(input)
960− val value = deserialize(deserializer)
961− if (deserializer.get_buffer_offset() < input.size) {
962− throw DeserializationError("Some input bytes were not read")
963− }
964− return value
965− }
966− }
967−}
968−
969−/// The single operation `Render` implements.
970−data object RenderOperation {
971− fun serialize(serializer: Serializer) {}
972−
973− fun bincodeSerialize(): ByteArray {
974− val serializer = BincodeSerializer()
975− serialize(serializer)
976− return serializer.get_bytes()
977− }
978−
979− fun deserialize(deserializer: Deserializer): RenderOperation {
980− return RenderOperation
981− }
982−
983− @Throws(DeserializationError::class)
984− fun bincodeDeserialize(input: ByteArray?): RenderOperation {
985− if (input == null) {
986− throw DeserializationError("Cannot deserialize null array")
987− }
988− val deserializer = BincodeDeserializer(input)
989− val value = deserialize(deserializer)
990− if (deserializer.get_buffer_offset() < input.size) {
991− throw DeserializationError("Some input bytes were not read")
992− }
993− return value
994− }
995−}
996−
997−/// Request for a side-effect passed from the Core to the Shell.
998−///
999−/// The `EffectId` links the `Request` with the corresponding call to [`Core::resolve`] to pass the data back
1000−/// to the [`App::update`] function (wrapped in the event provided to the capability originating the effect).
1001−data class Request(
1002− val id: UInt,
1003− val effect: soy.iko.opencode.core.Effect,
1004−) {
1005− fun serialize(serializer: Serializer) {
1006− serializer.increase_container_depth()
1007− serializer.serialize_u32(id)
1008− effect.serialize(serializer)
1009− serializer.decrease_container_depth()
1010− }
1011−
1012− fun bincodeSerialize(): ByteArray {
1013− val serializer = BincodeSerializer()
1014− serialize(serializer)
1015− return serializer.get_bytes()
1016− }
1017−
1018− companion object {
1019− fun deserialize(deserializer: Deserializer): Request {
1020− deserializer.increase_container_depth()
1021− val id = deserializer.deserialize_u32()
1022− val effect = soy.iko.opencode.core.Effect.deserialize(deserializer)
1023− deserializer.decrease_container_depth()
1024− return Request(id, effect)
1025− }
1026−
1027− @Throws(DeserializationError::class)
1028− fun bincodeDeserialize(input: ByteArray?): Request {
1029− if (input == null) {
1030− throw DeserializationError("Cannot deserialize null array")
1031− }
1032− val deserializer = BincodeDeserializer(input)
1033− val value = deserialize(deserializer)
1034− if (deserializer.get_buffer_offset() < input.size) {
1035− throw DeserializationError("Some input bytes were not read")
1036− }
1037− return value
1038− }
1039− }
1040−}
1041−
1042−/// A batch of effect requests from the Core to the Shell, as serialised by
1043−/// [`Bridge::update`] and [`Bridge::resolve`].
1044−///
1045−/// The wire format is identical to `Vec<Request<Eff>>` (the newtype is
1046−/// `serde(transparent)`), so existing shell code that already deserialises
1047−/// a `Vec<Request>` remains binary-compatible.
1048−///
1049−/// Registering this type with the type-generation system causes the code
1050−/// generators to emit a `Requests` type (with a `value` field containing the
1051−/// list) together with a top-level `bincodeDeserialize` / `BincodeDeserialize`
1052−/// helper, replacing the hand-written extension files that were previously
1053−/// appended by `add_extensions()`.
1054−data class Requests(
1055− val value: List<soy.iko.opencode.core.Request>,
1056−) {
1057− fun serialize(serializer: Serializer) {
1058− serializer.increase_container_depth()
1059− value.serialize(serializer) {
1060− it.serialize(serializer)
1061− }
1062− serializer.decrease_container_depth()
1063− }
1064−
1065− fun bincodeSerialize(): ByteArray {
1066− val serializer = BincodeSerializer()
1067− serialize(serializer)
1068− return serializer.get_bytes()
1069− }
1070−
1071− companion object {
1072− fun deserialize(deserializer: Deserializer): Requests {
1073− deserializer.increase_container_depth()
1074− val value =
1075− deserializer.deserializeListOf {
1076− soy.iko.opencode.core.Request.deserialize(deserializer)
1077− }
1078− deserializer.decrease_container_depth()
1079− return Requests(value)
1080− }
1081−
1082− @Throws(DeserializationError::class)
1083− fun bincodeDeserialize(input: ByteArray?): Requests {
1084− if (input == null) {
1085− throw DeserializationError("Cannot deserialize null array")
1086− }
1087− val deserializer = BincodeDeserializer(input)
1088− val value = deserialize(deserializer)
1089− if (deserializer.get_buffer_offset() < input.size) {
1090− throw DeserializationError("Some input bytes were not read")
1091− }
1092− return value
1093− }
1094− }
1095−}
1096−
1097−enum class Screen {
1098− CONNECT,
1099− SESSIONS,
1100− CHAT;
1101−
1102− fun serialize(serializer: Serializer) {
1103− serializer.increase_container_depth()
1104− serializer.serialize_variant_index(ordinal)
1105− serializer.decrease_container_depth()
1106− }
1107−
1108− fun bincodeSerialize(): ByteArray {
1109− val serializer = BincodeSerializer()
1110− serialize(serializer)
1111− return serializer.get_bytes()
1112− }
1113−
1114− companion object {
1115− @Throws(DeserializationError::class)
1116− fun deserialize(deserializer: Deserializer): Screen {
1117− deserializer.increase_container_depth()
1118− val index = deserializer.deserialize_variant_index()
1119− deserializer.decrease_container_depth()
1120− return when (index) {
1121− 0 -> CONNECT
1122− 1 -> SESSIONS
1123− 2 -> CHAT
1124− else -> throw DeserializationError("Unknown variant index for Screen: $index")
1125− }
1126− }
1127−
1128− @Throws(DeserializationError::class)
1129− fun bincodeDeserialize(input: ByteArray?): Screen {
1130− if (input == null) {
1131− throw DeserializationError("Cannot deserialize null array")
1132− }
1133− val deserializer = BincodeDeserializer(input)
1134− val value = deserialize(deserializer)
1135− if (deserializer.get_buffer_offset() < input.size) {
1136− throw DeserializationError("Some input bytes were not read")
1137− }
1138− return value
1139− }
1140− }
1141−}
1142−
1143−data class SessionView(
1144− val id: String,
1145− val title: String,
1146−) {
1147− fun serialize(serializer: Serializer) {
1148− serializer.increase_container_depth()
1149− serializer.serialize_str(id)
1150− serializer.serialize_str(title)
1151− serializer.decrease_container_depth()
1152− }
1153−
1154− fun bincodeSerialize(): ByteArray {
1155− val serializer = BincodeSerializer()
1156− serialize(serializer)
1157− return serializer.get_bytes()
1158− }
1159−
1160− companion object {
1161− fun deserialize(deserializer: Deserializer): SessionView {
1162− deserializer.increase_container_depth()
1163− val id = deserializer.deserialize_str()
1164− val title = deserializer.deserialize_str()
1165− deserializer.decrease_container_depth()
1166− return SessionView(id, title)
1167− }
1168−
1169− @Throws(DeserializationError::class)
1170− fun bincodeDeserialize(input: ByteArray?): SessionView {
1171− if (input == null) {
1172− throw DeserializationError("Cannot deserialize null array")
1173− }
1174− val deserializer = BincodeDeserializer(input)
1175− val value = deserialize(deserializer)
1176− if (deserializer.get_buffer_offset() < input.size) {
1177− throw DeserializationError("Some input bytes were not read")
1178− }
1179− return value
1180− }
1181− }
1182−}
1183−
1184−data class ViewModel(
1185− val screen: soy.iko.opencode.core.Screen,
1186− val serverUrl: String,
1187− val username: String,
1188− val password: String,
1189− val authRequired: Boolean,
1190− val connected: Boolean,
1191− val loading: Boolean,
1192− val error: String? = null,
1193− val sessions: List<soy.iko.opencode.core.SessionView>,
1194− val currentSessionId: String? = null,
1195− val currentSessionTitle: String,
1196− val messages: List<soy.iko.opencode.core.MessageView>,
1197− val draftMessage: String,
1198− /// True while waiting for the assistant to reply after the user sends.
1199− val generating: Boolean,
1200− val crashLogCount: UInt,
1201− val latestCrashLog: String? = null,
1202−) {
1203− fun serialize(serializer: Serializer) {
1204− serializer.increase_container_depth()
1205− screen.serialize(serializer)
1206− serializer.serialize_str(serverUrl)
1207− serializer.serialize_str(username)
1208− serializer.serialize_str(password)
1209− serializer.serialize_bool(authRequired)
1210− serializer.serialize_bool(connected)
1211− serializer.serialize_bool(loading)
1212− error.serializeOptionOf(serializer) {
1213− serializer.serialize_str(it)
1214− }
1215− sessions.serialize(serializer) {
1216− it.serialize(serializer)
1217− }
1218− currentSessionId.serializeOptionOf(serializer) {
1219− serializer.serialize_str(it)
1220− }
1221− serializer.serialize_str(currentSessionTitle)
1222− messages.serialize(serializer) {
1223− it.serialize(serializer)
1224− }
1225− serializer.serialize_str(draftMessage)
1226− serializer.serialize_bool(generating)
1227− serializer.serialize_u32(crashLogCount)
1228− latestCrashLog.serializeOptionOf(serializer) {
1229− serializer.serialize_str(it)
1230− }
1231− serializer.decrease_container_depth()
1232− }
1233−
1234− fun bincodeSerialize(): ByteArray {
1235− val serializer = BincodeSerializer()
1236− serialize(serializer)
1237− return serializer.get_bytes()
1238− }
1239−
1240− companion object {
1241− fun deserialize(deserializer: Deserializer): ViewModel {
1242− deserializer.increase_container_depth()
1243− val screen = soy.iko.opencode.core.Screen.deserialize(deserializer)
1244− val serverUrl = deserializer.deserialize_str()
1245− val username = deserializer.deserialize_str()
1246− val password = deserializer.deserialize_str()
1247− val authRequired = deserializer.deserialize_bool()
1248− val connected = deserializer.deserialize_bool()
1249− val loading = deserializer.deserialize_bool()
1250− val error =
1251− deserializer.deserializeOptionOf {
1252− deserializer.deserialize_str()
1253− }
1254− val sessions =
1255− deserializer.deserializeListOf {
1256− soy.iko.opencode.core.SessionView.deserialize(deserializer)
1257− }
1258− val currentSessionId =
1259− deserializer.deserializeOptionOf {
1260− deserializer.deserialize_str()
1261− }
1262− val currentSessionTitle = deserializer.deserialize_str()
1263− val messages =
1264− deserializer.deserializeListOf {
1265− soy.iko.opencode.core.MessageView.deserialize(deserializer)
1266− }
1267− val draftMessage = deserializer.deserialize_str()
1268− val generating = deserializer.deserialize_bool()
1269− val crashLogCount = deserializer.deserialize_u32()
1270− val latestCrashLog =
1271− deserializer.deserializeOptionOf {
1272− deserializer.deserialize_str()
1273− }
1274− deserializer.decrease_container_depth()
1275− return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, currentSessionTitle, messages, draftMessage, generating, crashLogCount, latestCrashLog)
1276− }
1277−
1278− @Throws(DeserializationError::class)
1279− fun bincodeDeserialize(input: ByteArray?): ViewModel {
1280− if (input == null) {
1281− throw DeserializationError("Cannot deserialize null array")
1282− }
1283− val deserializer = BincodeDeserializer(input)
1284− val value = deserialize(deserializer)
1285− if (deserializer.get_buffer_offset() < input.size) {
1286− throw DeserializationError("Some input bytes were not read")
1287− }
1288− return value
1289− }
1290− }
1291−}
generated/soy/iko/opencode/shared/MessageStatus.javanew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public abstract class MessageStatus {
5+
6+ abstract public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError;
7+
8+ public static MessageStatus deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
9+ int index = deserializer.deserialize_variant_index();
10+ switch (index) {
11+ case 0: return Sent.load(deserializer);
12+ case 1: return Pending.load(deserializer);
13+ case 2: return Failed.load(deserializer);
14+ default: throw new com.novi.serde.DeserializationError("Unknown variant index for MessageStatus: " + index);
15+ }
16+ }
17+
18+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
19+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
20+ serialize(serializer);
21+ return serializer.get_bytes();
22+ }
23+
24+ public static MessageStatus bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
25+ if (input == null) {
26+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
27+ }
28+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
29+ MessageStatus value = deserialize(deserializer);
30+ if (deserializer.get_buffer_offset() < input.length) {
31+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
32+ }
33+ return value;
34+ }
35+
36+ public static final class Sent extends MessageStatus {
37+ public Sent() {
38+ }
39+
40+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
41+ serializer.increase_container_depth();
42+ serializer.serialize_variant_index(0);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ static Sent load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
47+ deserializer.increase_container_depth();
48+ Builder builder = new Builder();
49+ deserializer.decrease_container_depth();
50+ return builder.build();
51+ }
52+
53+ public boolean equals(Object obj) {
54+ if (this == obj) return true;
55+ if (obj == null) return false;
56+ if (getClass() != obj.getClass()) return false;
57+ Sent other = (Sent) obj;
58+ return true;
59+ }
60+
61+ public int hashCode() {
62+ int value = 7;
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public Sent build() {
68+ return new Sent(
69+ );
70+ }
71+ }
72+ }
73+
74+ public static final class Pending extends MessageStatus {
75+ public Pending() {
76+ }
77+
78+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
79+ serializer.increase_container_depth();
80+ serializer.serialize_variant_index(1);
81+ serializer.decrease_container_depth();
82+ }
83+
84+ static Pending load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
85+ deserializer.increase_container_depth();
86+ Builder builder = new Builder();
87+ deserializer.decrease_container_depth();
88+ return builder.build();
89+ }
90+
91+ public boolean equals(Object obj) {
92+ if (this == obj) return true;
93+ if (obj == null) return false;
94+ if (getClass() != obj.getClass()) return false;
95+ Pending other = (Pending) obj;
96+ return true;
97+ }
98+
99+ public int hashCode() {
100+ int value = 7;
101+ return value;
102+ }
103+
104+ public static final class Builder {
105+ public Pending build() {
106+ return new Pending(
107+ );
108+ }
109+ }
110+ }
111+
112+ public static final class Failed extends MessageStatus {
113+ public Failed() {
114+ }
115+
116+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
117+ serializer.increase_container_depth();
118+ serializer.serialize_variant_index(2);
119+ serializer.decrease_container_depth();
120+ }
121+
122+ static Failed load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
123+ deserializer.increase_container_depth();
124+ Builder builder = new Builder();
125+ deserializer.decrease_container_depth();
126+ return builder.build();
127+ }
128+
129+ public boolean equals(Object obj) {
130+ if (this == obj) return true;
131+ if (obj == null) return false;
132+ if (getClass() != obj.getClass()) return false;
133+ Failed other = (Failed) obj;
134+ return true;
135+ }
136+
137+ public int hashCode() {
138+ int value = 7;
139+ return value;
140+ }
141+
142+ public static final class Builder {
143+ public Failed build() {
144+ return new Failed(
145+ );
146+ }
147+ }
148+ }
149+}
150+
generated/soy/iko/opencode/shared/MessageView.javanew file+131
@@ -0,0 +1,131 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class MessageView {
5+ public final String id;
6+ public final String role;
7+ public final String text;
8+ public final java.util.Optional<String> reasoning;
9+ public final java.util.List<ToolView> tools;
10+ public final @com.novi.serde.Unsigned Long time;
11+ public final MessageStatus status;
12+ public final Boolean streaming;
13+
14+ public MessageView(String id, String role, String text, java.util.Optional<String> reasoning, java.util.List<ToolView> tools, @com.novi.serde.Unsigned Long time, MessageStatus status, Boolean streaming) {
15+ java.util.Objects.requireNonNull(id, "id must not be null");
16+ java.util.Objects.requireNonNull(role, "role must not be null");
17+ java.util.Objects.requireNonNull(text, "text must not be null");
18+ java.util.Objects.requireNonNull(reasoning, "reasoning must not be null");
19+ java.util.Objects.requireNonNull(tools, "tools must not be null");
20+ java.util.Objects.requireNonNull(time, "time must not be null");
21+ java.util.Objects.requireNonNull(status, "status must not be null");
22+ java.util.Objects.requireNonNull(streaming, "streaming must not be null");
23+ this.id = id;
24+ this.role = role;
25+ this.text = text;
26+ this.reasoning = reasoning;
27+ this.tools = tools;
28+ this.time = time;
29+ this.status = status;
30+ this.streaming = streaming;
31+ }
32+
33+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
34+ serializer.increase_container_depth();
35+ serializer.serialize_str(id);
36+ serializer.serialize_str(role);
37+ serializer.serialize_str(text);
38+ TraitHelpers.serialize_option_str(reasoning, serializer);
39+ TraitHelpers.serialize_vector_ToolView(tools, serializer);
40+ serializer.serialize_u64(time);
41+ status.serialize(serializer);
42+ serializer.serialize_bool(streaming);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
47+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
48+ serialize(serializer);
49+ return serializer.get_bytes();
50+ }
51+
52+ public static MessageView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
53+ deserializer.increase_container_depth();
54+ Builder builder = new Builder();
55+ builder.id = deserializer.deserialize_str();
56+ builder.role = deserializer.deserialize_str();
57+ builder.text = deserializer.deserialize_str();
58+ builder.reasoning = TraitHelpers.deserialize_option_str(deserializer);
59+ builder.tools = TraitHelpers.deserialize_vector_ToolView(deserializer);
60+ builder.time = deserializer.deserialize_u64();
61+ builder.status = MessageStatus.deserialize(deserializer);
62+ builder.streaming = deserializer.deserialize_bool();
63+ deserializer.decrease_container_depth();
64+ return builder.build();
65+ }
66+
67+ public static MessageView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
68+ if (input == null) {
69+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
70+ }
71+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
72+ MessageView value = deserialize(deserializer);
73+ if (deserializer.get_buffer_offset() < input.length) {
74+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
75+ }
76+ return value;
77+ }
78+
79+ public boolean equals(Object obj) {
80+ if (this == obj) return true;
81+ if (obj == null) return false;
82+ if (getClass() != obj.getClass()) return false;
83+ MessageView other = (MessageView) obj;
84+ if (!java.util.Objects.equals(this.id, other.id)) { return false; }
85+ if (!java.util.Objects.equals(this.role, other.role)) { return false; }
86+ if (!java.util.Objects.equals(this.text, other.text)) { return false; }
87+ if (!java.util.Objects.equals(this.reasoning, other.reasoning)) { return false; }
88+ if (!java.util.Objects.equals(this.tools, other.tools)) { return false; }
89+ if (!java.util.Objects.equals(this.time, other.time)) { return false; }
90+ if (!java.util.Objects.equals(this.status, other.status)) { return false; }
91+ if (!java.util.Objects.equals(this.streaming, other.streaming)) { return false; }
92+ return true;
93+ }
94+
95+ public int hashCode() {
96+ int value = 7;
97+ value = 31 * value + (this.id != null ? this.id.hashCode() : 0);
98+ value = 31 * value + (this.role != null ? this.role.hashCode() : 0);
99+ value = 31 * value + (this.text != null ? this.text.hashCode() : 0);
100+ value = 31 * value + (this.reasoning != null ? this.reasoning.hashCode() : 0);
101+ value = 31 * value + (this.tools != null ? this.tools.hashCode() : 0);
102+ value = 31 * value + (this.time != null ? this.time.hashCode() : 0);
103+ value = 31 * value + (this.status != null ? this.status.hashCode() : 0);
104+ value = 31 * value + (this.streaming != null ? this.streaming.hashCode() : 0);
105+ return value;
106+ }
107+
108+ public static final class Builder {
109+ public String id;
110+ public String role;
111+ public String text;
112+ public java.util.Optional<String> reasoning;
113+ public java.util.List<ToolView> tools;
114+ public @com.novi.serde.Unsigned Long time;
115+ public MessageStatus status;
116+ public Boolean streaming;
117+
118+ public MessageView build() {
119+ return new MessageView(
120+ id,
121+ role,
122+ text,
123+ reasoning,
124+ tools,
125+ time,
126+ status,
127+ streaming
128+ );
129+ }
130+ }
131+}
generated/soy/iko/opencode/shared/Requests.javanew file+27
@@ -0,0 +1,27 @@
1+package soy.iko.opencode.shared;
2+
3+public final class Requests {
4+
5+ public static java.util.List<Request> bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
6+ if (input == null) {
7+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
8+ }
9+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
10+ deserializer.increase_container_depth();
11+
12+ long length = deserializer.deserialize_len();
13+
14+ java.util.List<Request> value = new java.util.ArrayList<>();
15+
16+ for (int i = 0; i < length; i++) {
17+ value.add(Request.deserialize(deserializer));
18+ }
19+
20+ deserializer.decrease_container_depth();
21+
22+ if (deserializer.get_buffer_offset() < input.length) {
23+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
24+ }
25+ return value;
26+ }
27+}
generated/soy/iko/opencode/shared/Screen.javanew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public abstract class Screen {
5+
6+ abstract public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError;
7+
8+ public static Screen deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
9+ int index = deserializer.deserialize_variant_index();
10+ switch (index) {
11+ case 0: return Connect.load(deserializer);
12+ case 1: return Sessions.load(deserializer);
13+ case 2: return Chat.load(deserializer);
14+ default: throw new com.novi.serde.DeserializationError("Unknown variant index for Screen: " + index);
15+ }
16+ }
17+
18+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
19+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
20+ serialize(serializer);
21+ return serializer.get_bytes();
22+ }
23+
24+ public static Screen bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
25+ if (input == null) {
26+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
27+ }
28+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
29+ Screen value = deserialize(deserializer);
30+ if (deserializer.get_buffer_offset() < input.length) {
31+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
32+ }
33+ return value;
34+ }
35+
36+ public static final class Connect extends Screen {
37+ public Connect() {
38+ }
39+
40+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
41+ serializer.increase_container_depth();
42+ serializer.serialize_variant_index(0);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ static Connect load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
47+ deserializer.increase_container_depth();
48+ Builder builder = new Builder();
49+ deserializer.decrease_container_depth();
50+ return builder.build();
51+ }
52+
53+ public boolean equals(Object obj) {
54+ if (this == obj) return true;
55+ if (obj == null) return false;
56+ if (getClass() != obj.getClass()) return false;
57+ Connect other = (Connect) obj;
58+ return true;
59+ }
60+
61+ public int hashCode() {
62+ int value = 7;
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public Connect build() {
68+ return new Connect(
69+ );
70+ }
71+ }
72+ }
73+
74+ public static final class Sessions extends Screen {
75+ public Sessions() {
76+ }
77+
78+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
79+ serializer.increase_container_depth();
80+ serializer.serialize_variant_index(1);
81+ serializer.decrease_container_depth();
82+ }
83+
84+ static Sessions load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
85+ deserializer.increase_container_depth();
86+ Builder builder = new Builder();
87+ deserializer.decrease_container_depth();
88+ return builder.build();
89+ }
90+
91+ public boolean equals(Object obj) {
92+ if (this == obj) return true;
93+ if (obj == null) return false;
94+ if (getClass() != obj.getClass()) return false;
95+ Sessions other = (Sessions) obj;
96+ return true;
97+ }
98+
99+ public int hashCode() {
100+ int value = 7;
101+ return value;
102+ }
103+
104+ public static final class Builder {
105+ public Sessions build() {
106+ return new Sessions(
107+ );
108+ }
109+ }
110+ }
111+
112+ public static final class Chat extends Screen {
113+ public Chat() {
114+ }
115+
116+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
117+ serializer.increase_container_depth();
118+ serializer.serialize_variant_index(2);
119+ serializer.decrease_container_depth();
120+ }
121+
122+ static Chat load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
123+ deserializer.increase_container_depth();
124+ Builder builder = new Builder();
125+ deserializer.decrease_container_depth();
126+ return builder.build();
127+ }
128+
129+ public boolean equals(Object obj) {
130+ if (this == obj) return true;
131+ if (obj == null) return false;
132+ if (getClass() != obj.getClass()) return false;
133+ Chat other = (Chat) obj;
134+ return true;
135+ }
136+
137+ public int hashCode() {
138+ int value = 7;
139+ return value;
140+ }
141+
142+ public static final class Builder {
143+ public Chat build() {
144+ return new Chat(
145+ );
146+ }
147+ }
148+ }
149+}
150+
generated/soy/iko/opencode/shared/SessionView.javanew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class SessionView {
5+ public final String id;
6+ public final String title;
7+
8+ public SessionView(String id, String title) {
9+ java.util.Objects.requireNonNull(id, "id must not be null");
10+ java.util.Objects.requireNonNull(title, "title must not be null");
11+ this.id = id;
12+ this.title = title;
13+ }
14+
15+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
16+ serializer.increase_container_depth();
17+ serializer.serialize_str(id);
18+ serializer.serialize_str(title);
19+ serializer.decrease_container_depth();
20+ }
21+
22+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
23+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
24+ serialize(serializer);
25+ return serializer.get_bytes();
26+ }
27+
28+ public static SessionView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
29+ deserializer.increase_container_depth();
30+ Builder builder = new Builder();
31+ builder.id = deserializer.deserialize_str();
32+ builder.title = deserializer.deserialize_str();
33+ deserializer.decrease_container_depth();
34+ return builder.build();
35+ }
36+
37+ public static SessionView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
38+ if (input == null) {
39+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
40+ }
41+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
42+ SessionView value = deserialize(deserializer);
43+ if (deserializer.get_buffer_offset() < input.length) {
44+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
45+ }
46+ return value;
47+ }
48+
49+ public boolean equals(Object obj) {
50+ if (this == obj) return true;
51+ if (obj == null) return false;
52+ if (getClass() != obj.getClass()) return false;
53+ SessionView other = (SessionView) obj;
54+ if (!java.util.Objects.equals(this.id, other.id)) { return false; }
55+ if (!java.util.Objects.equals(this.title, other.title)) { return false; }
56+ return true;
57+ }
58+
59+ public int hashCode() {
60+ int value = 7;
61+ value = 31 * value + (this.id != null ? this.id.hashCode() : 0);
62+ value = 31 * value + (this.title != null ? this.title.hashCode() : 0);
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public String id;
68+ public String title;
69+
70+ public SessionView build() {
71+ return new SessionView(
72+ id,
73+ title
74+ );
75+ }
76+ }
77+}
generated/soy/iko/opencode/shared/ToolView.javanew file+86
@@ -0,0 +1,86 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class ToolView {
5+ public final String name;
6+ public final String status;
7+ public final java.util.Optional<String> title;
8+
9+ public ToolView(String name, String status, java.util.Optional<String> title) {
10+ java.util.Objects.requireNonNull(name, "name must not be null");
11+ java.util.Objects.requireNonNull(status, "status must not be null");
12+ java.util.Objects.requireNonNull(title, "title must not be null");
13+ this.name = name;
14+ this.status = status;
15+ this.title = title;
16+ }
17+
18+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
19+ serializer.increase_container_depth();
20+ serializer.serialize_str(name);
21+ serializer.serialize_str(status);
22+ TraitHelpers.serialize_option_str(title, serializer);
23+ serializer.decrease_container_depth();
24+ }
25+
26+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
27+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
28+ serialize(serializer);
29+ return serializer.get_bytes();
30+ }
31+
32+ public static ToolView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
33+ deserializer.increase_container_depth();
34+ Builder builder = new Builder();
35+ builder.name = deserializer.deserialize_str();
36+ builder.status = deserializer.deserialize_str();
37+ builder.title = TraitHelpers.deserialize_option_str(deserializer);
38+ deserializer.decrease_container_depth();
39+ return builder.build();
40+ }
41+
42+ public static ToolView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
43+ if (input == null) {
44+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
45+ }
46+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
47+ ToolView value = deserialize(deserializer);
48+ if (deserializer.get_buffer_offset() < input.length) {
49+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
50+ }
51+ return value;
52+ }
53+
54+ public boolean equals(Object obj) {
55+ if (this == obj) return true;
56+ if (obj == null) return false;
57+ if (getClass() != obj.getClass()) return false;
58+ ToolView other = (ToolView) obj;
59+ if (!java.util.Objects.equals(this.name, other.name)) { return false; }
60+ if (!java.util.Objects.equals(this.status, other.status)) { return false; }
61+ if (!java.util.Objects.equals(this.title, other.title)) { return false; }
62+ return true;
63+ }
64+
65+ public int hashCode() {
66+ int value = 7;
67+ value = 31 * value + (this.name != null ? this.name.hashCode() : 0);
68+ value = 31 * value + (this.status != null ? this.status.hashCode() : 0);
69+ value = 31 * value + (this.title != null ? this.title.hashCode() : 0);
70+ return value;
71+ }
72+
73+ public static final class Builder {
74+ public String name;
75+ public String status;
76+ public java.util.Optional<String> title;
77+
78+ public ToolView build() {
79+ return new ToolView(
80+ name,
81+ status,
82+ title
83+ );
84+ }
85+ }
86+}
generated/soy/iko/opencode/shared/TraitHelpers.javanew file+71
@@ -0,0 +1,71 @@
1+package soy.iko.opencode.shared;
2+
3+final class TraitHelpers {
4+ static void serialize_option_str(java.util.Optional<String> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
5+ if (value.isPresent()) {
6+ serializer.serialize_option_tag(true);
7+ serializer.serialize_str(value.get());
8+ } else {
9+ serializer.serialize_option_tag(false);
10+ }
11+ }
12+
13+ static java.util.Optional<String> deserialize_option_str(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
14+ boolean tag = deserializer.deserialize_option_tag();
15+ if (!tag) {
16+ return java.util.Optional.empty();
17+ } else {
18+ return java.util.Optional.of(deserializer.deserialize_str());
19+ }
20+ }
21+
22+ static void serialize_vector_MessageView(java.util.List<MessageView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
23+ serializer.serialize_len(value.size());
24+ for (MessageView item : value) {
25+ item.serialize(serializer);
26+ }
27+ }
28+
29+ static java.util.List<MessageView> deserialize_vector_MessageView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
30+ long length = deserializer.deserialize_len();
31+ java.util.List<MessageView> obj = new java.util.ArrayList<MessageView>((int) length);
32+ for (long i = 0; i < length; i++) {
33+ obj.add(MessageView.deserialize(deserializer));
34+ }
35+ return obj;
36+ }
37+
38+ static void serialize_vector_SessionView(java.util.List<SessionView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
39+ serializer.serialize_len(value.size());
40+ for (SessionView item : value) {
41+ item.serialize(serializer);
42+ }
43+ }
44+
45+ static java.util.List<SessionView> deserialize_vector_SessionView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
46+ long length = deserializer.deserialize_len();
47+ java.util.List<SessionView> obj = new java.util.ArrayList<SessionView>((int) length);
48+ for (long i = 0; i < length; i++) {
49+ obj.add(SessionView.deserialize(deserializer));
50+ }
51+ return obj;
52+ }
53+
54+ static void serialize_vector_ToolView(java.util.List<ToolView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
55+ serializer.serialize_len(value.size());
56+ for (ToolView item : value) {
57+ item.serialize(serializer);
58+ }
59+ }
60+
61+ static java.util.List<ToolView> deserialize_vector_ToolView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
62+ long length = deserializer.deserialize_len();
63+ java.util.List<ToolView> obj = new java.util.ArrayList<ToolView>((int) length);
64+ for (long i = 0; i < length; i++) {
65+ obj.add(ToolView.deserialize(deserializer));
66+ }
67+ return obj;
68+ }
69+
70+}
71+
generated/soy/iko/opencode/shared/ViewModel.javanew file+185
@@ -0,0 +1,185 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class ViewModel {
5+ public final Screen screen;
6+ public final String server_url;
7+ public final String username;
8+ public final String password;
9+ public final Boolean auth_required;
10+ public final Boolean connected;
11+ public final Boolean loading;
12+ public final java.util.Optional<String> error;
13+ public final java.util.List<SessionView> sessions;
14+ public final java.util.Optional<String> current_session_id;
15+ public final String current_session_title;
16+ public final java.util.List<MessageView> messages;
17+ public final String draft;
18+ public final Boolean generating;
19+
20+ public ViewModel(Screen screen, String server_url, String username, String password, Boolean auth_required, Boolean connected, Boolean loading, java.util.Optional<String> error, java.util.List<SessionView> sessions, java.util.Optional<String> current_session_id, String current_session_title, java.util.List<MessageView> messages, String draft, Boolean generating) {
21+ java.util.Objects.requireNonNull(screen, "screen must not be null");
22+ java.util.Objects.requireNonNull(server_url, "server_url must not be null");
23+ java.util.Objects.requireNonNull(username, "username must not be null");
24+ java.util.Objects.requireNonNull(password, "password must not be null");
25+ java.util.Objects.requireNonNull(auth_required, "auth_required must not be null");
26+ java.util.Objects.requireNonNull(connected, "connected must not be null");
27+ java.util.Objects.requireNonNull(loading, "loading must not be null");
28+ java.util.Objects.requireNonNull(error, "error must not be null");
29+ java.util.Objects.requireNonNull(sessions, "sessions must not be null");
30+ java.util.Objects.requireNonNull(current_session_id, "current_session_id must not be null");
31+ java.util.Objects.requireNonNull(current_session_title, "current_session_title must not be null");
32+ java.util.Objects.requireNonNull(messages, "messages must not be null");
33+ java.util.Objects.requireNonNull(draft, "draft must not be null");
34+ java.util.Objects.requireNonNull(generating, "generating must not be null");
35+ this.screen = screen;
36+ this.server_url = server_url;
37+ this.username = username;
38+ this.password = password;
39+ this.auth_required = auth_required;
40+ this.connected = connected;
41+ this.loading = loading;
42+ this.error = error;
43+ this.sessions = sessions;
44+ this.current_session_id = current_session_id;
45+ this.current_session_title = current_session_title;
46+ this.messages = messages;
47+ this.draft = draft;
48+ this.generating = generating;
49+ }
50+
51+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
52+ serializer.increase_container_depth();
53+ screen.serialize(serializer);
54+ serializer.serialize_str(server_url);
55+ serializer.serialize_str(username);
56+ serializer.serialize_str(password);
57+ serializer.serialize_bool(auth_required);
58+ serializer.serialize_bool(connected);
59+ serializer.serialize_bool(loading);
60+ TraitHelpers.serialize_option_str(error, serializer);
61+ TraitHelpers.serialize_vector_SessionView(sessions, serializer);
62+ TraitHelpers.serialize_option_str(current_session_id, serializer);
63+ serializer.serialize_str(current_session_title);
64+ TraitHelpers.serialize_vector_MessageView(messages, serializer);
65+ serializer.serialize_str(draft);
66+ serializer.serialize_bool(generating);
67+ serializer.decrease_container_depth();
68+ }
69+
70+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
71+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
72+ serialize(serializer);
73+ return serializer.get_bytes();
74+ }
75+
76+ public static ViewModel deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
77+ deserializer.increase_container_depth();
78+ Builder builder = new Builder();
79+ builder.screen = Screen.deserialize(deserializer);
80+ builder.server_url = deserializer.deserialize_str();
81+ builder.username = deserializer.deserialize_str();
82+ builder.password = deserializer.deserialize_str();
83+ builder.auth_required = deserializer.deserialize_bool();
84+ builder.connected = deserializer.deserialize_bool();
85+ builder.loading = deserializer.deserialize_bool();
86+ builder.error = TraitHelpers.deserialize_option_str(deserializer);
87+ builder.sessions = TraitHelpers.deserialize_vector_SessionView(deserializer);
88+ builder.current_session_id = TraitHelpers.deserialize_option_str(deserializer);
89+ builder.current_session_title = deserializer.deserialize_str();
90+ builder.messages = TraitHelpers.deserialize_vector_MessageView(deserializer);
91+ builder.draft = deserializer.deserialize_str();
92+ builder.generating = deserializer.deserialize_bool();
93+ deserializer.decrease_container_depth();
94+ return builder.build();
95+ }
96+
97+ public static ViewModel bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
98+ if (input == null) {
99+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
100+ }
101+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
102+ ViewModel value = deserialize(deserializer);
103+ if (deserializer.get_buffer_offset() < input.length) {
104+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
105+ }
106+ return value;
107+ }
108+
109+ public boolean equals(Object obj) {
110+ if (this == obj) return true;
111+ if (obj == null) return false;
112+ if (getClass() != obj.getClass()) return false;
113+ ViewModel other = (ViewModel) obj;
114+ if (!java.util.Objects.equals(this.screen, other.screen)) { return false; }
115+ if (!java.util.Objects.equals(this.server_url, other.server_url)) { return false; }
116+ if (!java.util.Objects.equals(this.username, other.username)) { return false; }
117+ if (!java.util.Objects.equals(this.password, other.password)) { return false; }
118+ if (!java.util.Objects.equals(this.auth_required, other.auth_required)) { return false; }
119+ if (!java.util.Objects.equals(this.connected, other.connected)) { return false; }
120+ if (!java.util.Objects.equals(this.loading, other.loading)) { return false; }
121+ if (!java.util.Objects.equals(this.error, other.error)) { return false; }
122+ if (!java.util.Objects.equals(this.sessions, other.sessions)) { return false; }
123+ if (!java.util.Objects.equals(this.current_session_id, other.current_session_id)) { return false; }
124+ if (!java.util.Objects.equals(this.current_session_title, other.current_session_title)) { return false; }
125+ if (!java.util.Objects.equals(this.messages, other.messages)) { return false; }
126+ if (!java.util.Objects.equals(this.draft, other.draft)) { return false; }
127+ if (!java.util.Objects.equals(this.generating, other.generating)) { return false; }
128+ return true;
129+ }
130+
131+ public int hashCode() {
132+ int value = 7;
133+ value = 31 * value + (this.screen != null ? this.screen.hashCode() : 0);
134+ value = 31 * value + (this.server_url != null ? this.server_url.hashCode() : 0);
135+ value = 31 * value + (this.username != null ? this.username.hashCode() : 0);
136+ value = 31 * value + (this.password != null ? this.password.hashCode() : 0);
137+ value = 31 * value + (this.auth_required != null ? this.auth_required.hashCode() : 0);
138+ value = 31 * value + (this.connected != null ? this.connected.hashCode() : 0);
139+ value = 31 * value + (this.loading != null ? this.loading.hashCode() : 0);
140+ value = 31 * value + (this.error != null ? this.error.hashCode() : 0);
141+ value = 31 * value + (this.sessions != null ? this.sessions.hashCode() : 0);
142+ value = 31 * value + (this.current_session_id != null ? this.current_session_id.hashCode() : 0);
143+ value = 31 * value + (this.current_session_title != null ? this.current_session_title.hashCode() : 0);
144+ value = 31 * value + (this.messages != null ? this.messages.hashCode() : 0);
145+ value = 31 * value + (this.draft != null ? this.draft.hashCode() : 0);
146+ value = 31 * value + (this.generating != null ? this.generating.hashCode() : 0);
147+ return value;
148+ }
149+
150+ public static final class Builder {
151+ public Screen screen;
152+ public String server_url;
153+ public String username;
154+ public String password;
155+ public Boolean auth_required;
156+ public Boolean connected;
157+ public Boolean loading;
158+ public java.util.Optional<String> error;
159+ public java.util.List<SessionView> sessions;
160+ public java.util.Optional<String> current_session_id;
161+ public String current_session_title;
162+ public java.util.List<MessageView> messages;
163+ public String draft;
164+ public Boolean generating;
165+
166+ public ViewModel build() {
167+ return new ViewModel(
168+ screen,
169+ server_url,
170+ username,
171+ password,
172+ auth_required,
173+ connected,
174+ loading,
175+ error,
176+ sessions,
177+ current_session_id,
178+ current_session_title,
179+ messages,
180+ draft,
181+ generating
182+ );
183+ }
184+ }
185+}
gradle.properties+7−2
@@ -1,5 +1,10 @@
11org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+org.gradle.caching=true
3+org.gradle.configuration-cache=true
4+
5+# AndroidX
26android.useAndroidX=true
3−kotlin.code.style=official
47android.nonTransitiveRClass=true
5−android.suppressUnsupportedCompileSdk=37.0
8+
9+# Kotlin
10+kotlin.code.style=official
gradle/libs.versions.toml+23−30
@@ -1,51 +1,44 @@
11[versions]
2−agp = "8.13.2"
3−kotlin = "2.3.0"
4−coreKtx = "1.17.0"
2+agp = "8.7.3"
3+kotlin = "2.0.21"
4+coreKtx = "1.15.0"
5+lifecycle = "2.8.7"
6+activityCompose = "1.9.3"
7+composeBom = "2024.10.01"
8+coroutines = "1.9.0"
9+serialization = "1.7.3"
10+okhttp = "4.12.0"
511junit = "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−markdownRenderer = "0.43.0"
12+androidxJunit = "1.2.1"
13+
1514[libraries]
1615androidx-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" }
16+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
17+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
18+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
19+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
2120androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21+
2222androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
2323androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
2424androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
2525androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
2626androidx-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" }
2927androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
3028androidx-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" }
3229
33−markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
34−markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
30+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
31+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
3532
36−ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
37−ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
38−ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
39−ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
40−ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
41−ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
33+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
34+okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
4235
43−kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
44−kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
36+junit = { group = "junit", name = "junit", version.ref = "junit" }
37+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
38+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
4539
4640[plugins]
4741android-application = { id = "com.android.application", version.ref = "agp" }
48−android-library = { id = "com.android.library", version.ref = "agp" }
4942kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
5043kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
5144kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
settings.gradle.kts+3−1
@@ -21,5 +21,7 @@ dependencyResolutionManagement {
2121}
2222
2323rootProject.name = "opencode-android"
24+
25+// The Crux core lives in `shared/` and is a pure Rust crate (built and tested with
26+// cargo, not Gradle), so the only Gradle module is the Android app shell.
2427include(":app")
25−include(":shared")
shared/Cargo.lock+4−362
@@ -2,71 +2,12 @@
22# It is not intended for manual editing.
33version = 4
44
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−
145[[package]]
156name = "allocator-api2"
167version = "0.2.21"
178source = "registry+https://github.com/rust-lang/crates.io-index"
189checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
1910
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−
7011[[package]]
7112name = "anyhow"
7213version = "1.0.103"
@@ -105,89 +46,6 @@ dependencies = [
10546 "serde",
10647]
10748
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−
19149[[package]]
19250name = "bumpalo"
19351version = "3.20.3"
@@ -196,9 +54,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
19654
19755[[package]]
19856name = "bytes"
199−version = "1.12.0"
57+version = "1.12.1"
20058source = "registry+https://github.com/rust-lang/crates.io-index"
201−checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
59+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
20260
20361[[package]]
20462name = "cfg-if"
@@ -206,52 +64,6 @@ version = "1.0.4"
20664source = "registry+https://github.com/rust-lang/crates.io-index"
20765checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
20866
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−
25567[[package]]
25668name = "const-fnv1a-hash"
25769version = "1.1.0"
@@ -284,10 +96,8 @@ dependencies = [
28496 "crossbeam-channel",
28597 "crux_macros",
28698 "facet",
287− "facet_generate",
28899 "futures",
289100 "include_dir 0.7.4",
290− "log",
291101 "serde",
292102 "serde-generate",
293103 "serde-reflection",
@@ -539,26 +349,6 @@ dependencies = [
539349 "unsynn",
540350]
541351
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−
562352[[package]]
563353name = "fnv"
564354version = "1.0.7"
@@ -890,27 +680,6 @@ dependencies = [
890680 "quote",
891681]
892682
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−
914683[[package]]
915684name = "itoa"
916685version = "1.0.18"
@@ -940,12 +709,6 @@ version = "0.8.2"
940709source = "registry+https://github.com/rust-lang/crates.io-index"
941710checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
942711
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−
949712[[package]]
950713name = "memchr"
951714version = "2.8.3"
@@ -970,12 +733,6 @@ version = "1.21.4"
970733source = "registry+https://github.com/rust-lang/crates.io-index"
971734checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
972735
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−
979736[[package]]
980737name = "percent-encoding"
981738version = "2.3.2"
@@ -1128,35 +885,6 @@ dependencies = [
1128885 "getrandom",
1129886]
1130887
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−
1160888[[package]]
1161889name = "rustc-hash"
1162890version = "2.1.3"
@@ -1175,15 +903,6 @@ version = "1.0.23"
1175903source = "registry+https://github.com/rust-lang/crates.io-index"
1176904checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1177905
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−
1187906[[package]]
1188907name = "serde"
1189908version = "1.0.228"
@@ -1205,7 +924,7 @@ dependencies = [
1205924 "phf",
1206925 "serde",
1207926 "serde-reflection",
1208− "textwrap 0.13.4",
927+ "textwrap",
1209928]
1210929
1211930[[package]]
@@ -1280,12 +999,8 @@ version = "0.1.0"
1280999dependencies = [
12811000 "anyhow",
12821001 "base64",
1283− "bincode",
1284− "boltffi",
1285− "clap",
12861002 "crux_core",
12871003 "crux_http",
1288− "facet",
12891004 "serde",
12901005 "serde_json",
12911006]
@@ -1366,18 +1081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
13661081checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
13671082dependencies = [
13681083 "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",
1084+ "unicode-width",
13811085]
13821086
13831087[[package]]
@@ -1436,12 +1140,6 @@ version = "1.0.24"
14361140source = "registry+https://github.com/rust-lang/crates.io-index"
14371141checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
14381142
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−
14451143[[package]]
14461144name = "unicode-segmentation"
14471145version = "1.13.3"
@@ -1454,12 +1152,6 @@ version = "0.1.14"
14541152source = "registry+https://github.com/rust-lang/crates.io-index"
14551153checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
14561154
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−
14631155[[package]]
14641156name = "unsynn"
14651157version = "0.3.0"
@@ -1489,38 +1181,12 @@ version = "1.0.4"
14891181source = "registry+https://github.com/rust-lang/crates.io-index"
14901182checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
14911183
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−
15081184[[package]]
15091185name = "version_check"
15101186version = "0.9.5"
15111187source = "registry+https://github.com/rust-lang/crates.io-index"
15121188checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
15131189
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−
15241190[[package]]
15251191name = "wasi"
15261192version = "0.11.1+wasi-snapshot-preview1"
@@ -1582,30 +1248,6 @@ dependencies = [
15821248 "wasm-bindgen",
15831249]
15841250
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−
16091251[[package]]
16101252name = "writeable"
16111253version = "0.6.3"
shared/Cargo.toml+8−11
@@ -1,34 +1,31 @@
11[package]
22name = "shared"
33version = "0.1.0"
4−edition = "2024"
5−rust-version = "1.90"
4+edition = "2021"
5+rust-version = "1.85"
66license = "Apache-2.0"
7+description = "Crux core for the opencode Android client"
78
89[lib]
9−crate-type = ["cdylib", "lib", "staticlib"]
10+# `lib` for native cargo test + typegen; cdylib/staticlib keep the door open for a
11+# real JNI build once an Android cross-toolchain (NDK + rust-std) is available.
12+crate-type = ["lib", "staticlib", "cdylib"]
1013
1114[[bin]]
1215name = "codegen"
13−required-features = ["codegen"]
16+required-features = ["typegen"]
1417
1518[features]
16−facet_typegen = ["crux_core/facet_typegen"]
17−codegen = ["facet_typegen", "dep:clap", "dep:anyhow", "crux_core/typegen"]
19+typegen = ["crux_core/typegen", "dep:anyhow"]
1820
1921[dependencies]
20−facet = "=0.44"
21−boltffi = "0.27"
2222crux_core = "0.19"
2323crux_http = "0.19"
2424serde = { version = "1", features = ["derive"] }
2525serde_json = "1"
2626base64 = "0.22"
2727
28−# Optional codegen deps
2928anyhow = { version = "1", optional = true }
30−clap = { version = "4", optional = true, features = ["derive"] }
3129
3230[dev-dependencies]
33−bincode = "1"
3431crux_core = { version = "0.19", features = ["testing"] }
shared/build.gradle.ktsdeleted−39
@@ -1,39 +0,0 @@
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 = 37
12− buildToolsVersion = "37.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.rs+690−692
@@ -1,137 +1,203 @@
1+//! The Crux `App`: model, events, effects, update logic and view.
2+//!
3+//! Streaming works like this: after a successful connect the shell opens the
4+//! `/event` SSE stream and forwards every decoded event as
5+//! [`Event::ServerEvent`]. The core parses those and mutates the chat
6+//! incrementally — creating the assistant message on `message.updated`, growing
7+//! its text on `message.part.updated`, and clearing the "generating" flag on
8+//! `session.idle`. User messages are shown optimistically the moment they're
9+//! sent, so the stream only ever *adds* assistant content.
10+
111use base64::Engine;
212use crux_core::{
3− App, Command,
413 macros::effect,
5− render::{RenderOperation, render},
14+ render::{render, RenderOperation},
15+ App, Command,
616};
717use crux_http::command::{Http, RequestBuilder};
818use crux_http::protocol::HttpRequest;
9−use facet::Facet;
1019use serde::{Deserialize, Serialize};
1120
12−// ─── Events ────────────────────────────────────────────────────────────────
21+use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
1322
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. ──────────────────────────────────────
23+/// Result of an HTTP effect handed back to the core.
24+pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
25+
26+// ─── Events ──────────────────────────────────────────────────────────────────
2127
22− // Lifecycle
28+pub enum Event {
29+ // Lifecycle / connect
2330 Start,
2431 ServerUrlChanged(String),
2532 UsernameChanged(String),
2633 PasswordChanged(String),
2734 Connect,
28− CancelAuth,
2935
30− // Session list
36+ // Sessions
3137 LoadSessions,
3238 SelectSession(String),
3339 CreateSession,
40+ DeleteSession(String),
3441
3542 // Chat
3643 LoadMessages(String),
44+ DraftChanged(String),
3745 SendMessage(String),
46+ CancelGeneration,
3847
39− // SSE event received
40− EventReceived(String),
48+ // A raw event line from the shell's `/event` SSE subscription.
49+ ServerEvent(String),
4150
42− // Navigation
43− NavigateToChat(String),
51+ // Navigation / misc
4452 NavigateToSessions,
4553 NavigateToConnect,
46−
47− // Errors
4854 DismissError,
4955
50− // Draft / composer
51− DraftChanged(String),
52− // Cancel waiting for the assistant reply (client-side stop).
53− CancelGeneration,
54−
55− // Session management
56− DeleteSession(String),
57− // Retry sending a failed message by its (local) message id.
58− RetryMessage(String),
59−
60− // ── Internal events (skipped from typegen, sent only via then_send).
61− // These occupy higher discriminant indices that are never
62− // produced by the shell, so the index gap is harmless. ─────────
63−
64− #[serde(skip)]
65− #[facet(skip)]
66− SessionsLoaded(#[facet(opaque)] HttpResult<String>),
67− #[serde(skip)]
68− #[facet(skip)]
69− SessionCreated(#[facet(opaque)] HttpResult<String>),
70− #[serde(skip)]
71− #[facet(skip)]
72− MessagesLoaded(#[facet(opaque)] HttpResult<String>),
73− #[serde(skip)]
74− #[facet(skip)]
75− MessageSent(#[facet(opaque)] HttpResult<String>),
76− #[serde(skip)]
77− #[facet(skip)]
78− CrashLog(String),
79− #[serde(skip)]
80− #[facet(skip)]
81− HealthProbed(#[facet(opaque)] HttpResult<String>),
82− #[serde(skip)]
83− #[facet(skip)]
84− SessionDeleted(#[facet(opaque)] HttpResult<String>),
56+ // ── Internal: results of HTTP effects (never sent by the shell) ──────────
57+ HealthProbed(HttpResult),
58+ SessionsLoaded(HttpResult),
59+ SessionCreated(HttpResult),
60+ MessagesLoaded(String, HttpResult),
61+ PromptSent(HttpResult),
62+ SessionDeleted(HttpResult),
63+ Aborted(HttpResult),
8564}
8665
87−// ─── Effects ───────────────────────────────────────────────────────────────
66+// ─── Effects ─────────────────────────────────────────────────────────────────
8867
89−#[effect(facet_typegen)]
68+#[effect]
9069#[derive(Debug)]
9170pub enum Effect {
9271 Render(RenderOperation),
9372 Http(HttpRequest),
9473}
9574
96−// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
97−
98−pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
99−
100−// ─── Model ─────────────────────────────────────────────────────────────────
75+// ─── Model ───────────────────────────────────────────────────────────────────
10176
10277#[derive(Default)]
10378pub struct Model {
10479 server_url: String,
10580 username: String,
10681 password: String,
107− /// Set once the server replies 401 to an unauthenticated probe.
108− /// The view surfaces this so the shell can show credential fields.
82+ /// The server answered 401 to an unauthenticated probe — show credentials.
10983 auth_required: bool,
110− /// Set once a request with credentials succeeds. All subsequent
111− /// requests carry the Authorization header.
84+ /// Credentials were accepted; attach the auth header to every request.
11285 authed: bool,
11386 connected: bool,
11487 loading: bool,
11588 error: Option<String>,
116− sessions: Vec<SessionSummary>,
89+ screen: Screen,
90+
91+ sessions: Vec<SessionView>,
11792 current_session_id: Option<String>,
11893 current_session_title: String,
119− messages: Vec<MessageView>,
120− draft_message: String,
121− /// True between sending a message and the assistant reply arriving.
94+
95+ messages: Vec<MsgState>,
96+ draft: String,
12297 generating: bool,
123− /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
124− crash_logs: Vec<String>,
98+
99+ /// Monotonic counter for optimistic (client-side) user-message ids.
100+ local_seq: u64,
101+}
102+
103+impl Model {
104+ fn message_mut(&mut self, id: &str) -> Option<&mut MsgState> {
105+ self.messages.iter_mut().find(|m| m.id == id)
106+ }
107+
108+ fn has_credentials(&self) -> bool {
109+ !self.username.is_empty() || !self.password.is_empty()
110+ }
111+
112+ fn is_current(&self, session_id: &str) -> bool {
113+ self.current_session_id.as_deref() == Some(session_id)
114+ }
115+}
116+
117+/// Internal per-message state. Parts are tracked by id so streaming updates can
118+/// upsert them; the [`MessageView`] handed to the shell is derived from these.
119+struct MsgState {
120+ id: String,
121+ role: String,
122+ time: u64,
123+ status: MessageStatus,
124+ streaming: bool,
125+ text_parts: Vec<(String, String)>,
126+ reasoning_parts: Vec<(String, String)>,
127+ tool_parts: Vec<(String, ToolView)>,
128+}
129+
130+impl MsgState {
131+ fn new(id: String, role: String, time: u64) -> Self {
132+ Self {
133+ id,
134+ role,
135+ time,
136+ status: MessageStatus::Sent,
137+ streaming: false,
138+ text_parts: Vec::new(),
139+ reasoning_parts: Vec::new(),
140+ tool_parts: Vec::new(),
141+ }
142+ }
143+
144+ fn upsert_part(parts: &mut Vec<(String, String)>, id: &str, text: String) {
145+ if let Some(slot) = parts.iter_mut().find(|(pid, _)| pid == id) {
146+ slot.1 = text;
147+ } else {
148+ parts.push((id.to_string(), text));
149+ }
150+ }
151+
152+ fn upsert_tool(&mut self, id: &str, view: ToolView) {
153+ if let Some(slot) = self.tool_parts.iter_mut().find(|(pid, _)| pid == id) {
154+ slot.1 = view;
155+ } else {
156+ self.tool_parts.push((id.to_string(), view));
157+ }
158+ }
159+
160+ fn remove_part(&mut self, part_id: &str) {
161+ self.text_parts.retain(|(pid, _)| pid != part_id);
162+ self.reasoning_parts.retain(|(pid, _)| pid != part_id);
163+ self.tool_parts.retain(|(pid, _)| pid != part_id);
164+ }
165+
166+ fn to_view(&self) -> MessageView {
167+ let join = |parts: &[(String, String)]| {
168+ parts.iter().map(|(_, t)| t.as_str()).collect::<String>()
169+ };
170+ let reasoning = join(&self.reasoning_parts);
171+ MessageView {
172+ id: self.id.clone(),
173+ role: self.role.clone(),
174+ text: join(&self.text_parts),
175+ reasoning: (!reasoning.is_empty()).then_some(reasoning),
176+ tools: self.tool_parts.iter().map(|(_, t)| t.clone()).collect(),
177+ time: self.time,
178+ status: self.status.clone(),
179+ streaming: self.streaming,
180+ }
181+ }
125182}
126183
127−#[derive(Clone, Debug, Default)]
128−pub struct SessionSummary {
184+// ─── View model ──────────────────────────────────────────────────────────────
185+
186+#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
187+pub enum Screen {
188+ #[default]
189+ Connect,
190+ Sessions,
191+ Chat,
192+}
193+
194+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195+pub struct SessionView {
129196 pub id: String,
130197 pub title: String,
131198}
132199
133−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
134−#[repr(C)]
200+#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
135201pub enum MessageStatus {
136202 #[default]
137203 Sent,
@@ -139,18 +205,26 @@ pub enum MessageStatus {
139205 Failed,
140206}
141207
142−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
208+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
209+pub struct ToolView {
210+ pub name: String,
211+ pub status: String,
212+ pub title: Option<String>,
213+}
214+
215+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
143216pub struct MessageView {
144217 pub id: String,
145218 pub role: String,
146219 pub text: String,
220+ pub reasoning: Option<String>,
221+ pub tools: Vec<ToolView>,
147222 pub time: u64,
148223 pub status: MessageStatus,
224+ pub streaming: bool,
149225}
150226
151−// ─── ViewModel ─────────────────────────────────────────────────────────────
152−
153−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
227+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
154228pub struct ViewModel {
155229 pub screen: Screen,
156230 pub server_url: String,
@@ -164,70 +238,42 @@ pub struct ViewModel {
164238 pub current_session_id: Option<String>,
165239 pub current_session_title: String,
166240 pub messages: Vec<MessageView>,
167− pub draft_message: String,
168− /// True while waiting for the assistant to reply after the user sends.
241+ pub draft: String,
169242 pub generating: bool,
170− pub crash_log_count: u32,
171− pub latest_crash_log: Option<String>,
172−}
173−
174−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
175−#[repr(C)]
176−pub enum Screen {
177− #[default]
178− Connect,
179− Sessions,
180− Chat,
181243}
182244
183−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
184−pub struct SessionView {
185− pub id: String,
186− pub title: String,
187−}
188−
189−// ─── App ───────────────────────────────────────────────────────────────────
245+// ─── App ─────────────────────────────────────────────────────────────────────
190246
191247#[derive(Default)]
192248pub struct OpencodeApp;
193249
194250impl OpencodeApp {
195− /// Build a GET request to `path`, adding the Basic-Auth header when the
196− /// model has credentials. The header is always added once `authed` is true
197− /// (i.e. after the server has accepted credentials), and also during the
198− /// credential-retry attempt itself.
199251 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
200252 let url = format!("{}{path}", model.server_url);
201− let req = Http::get(&url).expect_string();
202− self.with_auth(req, model)
253+ self.with_auth(Http::get(&url).expect_string(), model)
203254 }
204255
205− /// Build a POST request to `path` with the given body bytes.
206− fn post(&self, model: &Model, path: &str, body: Vec<u8>) -> RequestBuilder<Effect, Event, String> {
256+ fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
207257 let url = format!("{}{path}", model.server_url);
208− let req = Http::post(&url).expect_string().body_bytes(body);
258+ let bytes = serde_json::to_vec(body).unwrap_or_default();
259+ let req = Http::post(&url)
260+ .expect_string()
261+ .header("Content-Type", "application/json")
262+ .body_bytes(bytes);
209263 self.with_auth(req, model)
210264 }
211265
212− /// Build a DELETE request to `path`.
213266 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
214267 let url = format!("{}{path}", model.server_url);
215− let req = Http::delete(&url).expect_string();
216− self.with_auth(req, model)
268+ self.with_auth(Http::delete(&url).expect_string(), model)
217269 }
218270
219− /// Attach the Basic-Auth `Authorization` header if the model carries
220− /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
221− /// `username:password`.
222− fn with_auth<E>(
223− &self,
224− req: RequestBuilder<Effect, Event, E>,
225− model: &Model,
226− ) -> RequestBuilder<Effect, Event, E>
271+ /// Attach an RFC 7617 Basic-Auth header when the model carries credentials.
272+ fn with_auth<E>(&self, req: RequestBuilder<Effect, Event, E>, model: &Model) -> RequestBuilder<Effect, Event, E>
227273 where
228274 E: 'static,
229275 {
230− if !model.username.is_empty() || !model.password.is_empty() {
276+ if model.has_credentials() {
231277 let creds = format!("{}:{}", model.username, model.password);
232278 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
233279 req.header("Authorization", format!("Basic {encoded}"))
@@ -235,13 +281,6 @@ impl OpencodeApp {
235281 req
236282 }
237283 }
238−
239− /// Render helper that also stamps the crash-log summary into the view.
240− fn render_with_crashes(&self, _model: &Model) -> Command<Effect, Event> {
241− // render() is the base; the view() function reads the model directly
242− // for crash-log fields, so a plain render suffices.
243− render()
244− }
245284}
246285
247286impl App for OpencodeApp {
@@ -252,292 +291,238 @@ impl App for OpencodeApp {
252291
253292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
254293 match event {
255− Event::Start => {
256− // Don't reset server_url here — the shell restores it from
257− // persisted storage via ServerUrlChanged before any connect.
258− render()
259− }
294+ // ── Lifecycle / connect ─────────────────────────────────────────
295+ Event::Start => render(),
260296
261297 Event::ServerUrlChanged(url) => {
262− model.server_url = url;
298+ model.server_url = normalize_url(&url);
263299 render()
264300 }
265−
266301 Event::UsernameChanged(u) => {
267302 model.username = u;
268303 render()
269304 }
270−
271305 Event::PasswordChanged(p) => {
272306 model.password = p;
273307 render()
274308 }
275309
276310 Event::Connect => {
311+ if model.server_url.is_empty() {
312+ model.error = Some("Enter a server URL".to_string());
313+ return render();
314+ }
277315 model.loading = true;
278316 model.error = None;
279− // Probe health. `with_auth` attaches credentials if present,
280− // so a retry after a 401 carries the Authorization header.
281− let req = self.get(model, "/global/health");
282− req.build().then_send(|result| Event::HealthProbed(result))
283− }
284−
285− Event::CancelAuth => {
286317 model.auth_required = false;
287− model.username.clear();
288− model.password.clear();
289− model.loading = false;
290− render()
318+ render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
291319 }
292320
321+ Event::HealthProbed(result) => self.on_health_probed(model, result),
322+
323+ // ── Sessions ────────────────────────────────────────────────────
293324 Event::LoadSessions => {
294325 model.loading = true;
295− self.get(model, "/session").build().then_send(Event::SessionsLoaded)
326+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
296327 }
297328
298329 Event::SessionsLoaded(result) => {
299330 model.loading = false;
300− match result {
301− Ok(mut response) => {
302− let body = response.take_body().unwrap_or_default();
303− model.sessions = parse_sessions(&body);
304− model.connected = true;
305− // Refresh the current session's title in case it was
306− // populated after creation.
307− if let Some(id) = model.current_session_id.clone() {
308− if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
309− model.current_session_title = s.title.clone();
310− }
311− }
312− render()
313− }
314− Err(e) => {
315− model.error = Some(format!("Failed to load sessions: {e}"));
316− render()
331+ match ok_body(result) {
332+ Ok(body) => {
333+ model.sessions = protocol::parse_sessions(&body)
334+ .into_iter()
335+ .map(|s| SessionView {
336+ id: s.id,
337+ title: display_title(&s.title),
338+ })
339+ .collect();
317340 }
341+ Err(e) => model.error = Some(e),
318342 }
343+ render()
319344 }
320345
321346 Event::SelectSession(id) => {
322− model.current_session_id = Some(id.clone());
323347 model.current_session_title = model
324348 .sessions
325349 .iter()
326350 .find(|s| s.id == id)
327351 .map(|s| s.title.clone())
328352 .unwrap_or_default();
329− Command::event(Event::NavigateToChat(id))
330− }
331−
332− Event::NavigateToChat(id) => {
333353 model.current_session_id = Some(id.clone());
334− model.current_session_title = model
335− .sessions
336− .iter()
337− .find(|s| s.id == id)
338− .map(|s| s.title.clone())
339− .unwrap_or_default();
340354 model.messages.clear();
355+ model.screen = Screen::Chat;
356+ model.loading = true;
357+ model.error = None;
341358 render().and(Command::event(Event::LoadMessages(id)))
342359 }
343360
344− Event::NavigateToSessions => {
345− model.current_session_id = None;
346− model.current_session_title.clear();
347− model.messages.clear();
348− model.generating = false;
349− render()
361+ Event::CreateSession => {
362+ model.loading = true;
363+ render().and(
364+ self.post_json(model, "/session", &serde_json::json!({}))
365+ .build()
366+ .then_send(Event::SessionCreated),
367+ )
350368 }
351369
352− Event::NavigateToConnect => {
353− // Return to the connect screen to switch servers / credentials.
354− // Keep the sessions list so a re-connect can refresh in place.
355− model.current_session_id = None;
356− model.current_session_title.clear();
357− model.messages.clear();
358− model.generating = false;
359− model.connected = false;
360− model.auth_required = false;
370+ Event::SessionCreated(result) => {
361371 model.loading = false;
372+ match ok_body(result) {
373+ Ok(body) => {
374+ if let Some(s) = protocol::parse_session(&body) {
375+ let view = SessionView {
376+ id: s.id.clone(),
377+ title: display_title(&s.title),
378+ };
379+ if !model.sessions.iter().any(|e| e.id == view.id) {
380+ model.sessions.insert(0, view);
381+ }
382+ return render().and(Command::event(Event::SelectSession(s.id)));
383+ }
384+ model.error = Some("Malformed session response".to_string());
385+ }
386+ Err(e) => model.error = Some(e),
387+ }
362388 render()
363389 }
364390
365− Event::CreateSession => {
366− model.loading = true;
367− self.post(model, "/session", Vec::new())
368− .build()
369− .then_send(Event::SessionCreated)
391+ Event::DeleteSession(id) => {
392+ model.sessions.retain(|s| s.id != id);
393+ let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
394+ if model.is_current(&id) {
395+ model.current_session_id = None;
396+ model.messages.clear();
397+ model.screen = Screen::Sessions;
398+ }
399+ cmd = render().and(cmd);
400+ cmd
370401 }
371402
372− Event::SessionCreated(result) => {
373− model.loading = false;
374− match result {
375− Ok(mut response) => {
376− let body = response.take_body().unwrap_or_default();
377− let id = parse_session_id(&body);
378− // Some servers echo the title in the created payload;
379− // fall back to "New session" if absent.
380− let title = extract_string(&body, "title")
381− .filter(|t| !t.is_empty())
382− .unwrap_or_else(|| "New session".to_string());
383− model.current_session_title = title.clone();
384− model.sessions.insert(
385− 0,
386− SessionSummary { id: id.clone(), title },
387− );
388− Command::event(Event::NavigateToChat(id))
389− }
390− Err(e) => {
391− model.error = Some(format!("Failed to create session: {e}"));
392− render()
393− }
403+ Event::SessionDeleted(result) => {
404+ if let Err(e) = ok_body(result) {
405+ model.error = Some(e);
394406 }
407+ render()
395408 }
396409
410+ // ── Chat ────────────────────────────────────────────────────────
397411 Event::LoadMessages(id) => {
398− model.loading = true;
399− let path = format!("/session/{id}/message");
400− self.get(model, &path).build().then_send(Event::MessagesLoaded)
412+ render().and(
413+ self.get(model, &format!("/session/{id}/message"))
414+ .build()
415+ .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
416+ )
401417 }
402418
403− Event::MessagesLoaded(result) => {
419+ Event::MessagesLoaded(session_id, result) => {
420+ if !model.is_current(&session_id) {
421+ return render();
422+ }
404423 model.loading = false;
405− model.generating = false;
406− match result {
407− Ok(mut response) => {
408− let body = response.take_body().unwrap_or_default();
409− model.messages = parse_messages(&body);
410− render()
411− }
412− Err(e) => {
413− model.error = Some(format!("Failed to load messages: {e}"));
414− render()
424+ match ok_body(result) {
425+ Ok(body) => {
426+ model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
427+ model.generating = model.messages.iter().any(|m| m.streaming);
415428 }
429+ Err(e) => model.error = Some(e),
416430 }
431+ render()
432+ }
433+
434+ Event::DraftChanged(text) => {
435+ model.draft = text;
436+ render()
417437 }
418438
419439 Event::SendMessage(text) => {
440+ let text = text.trim().to_string();
420441 let Some(session_id) = model.current_session_id.clone() else {
421442 return render();
422443 };
423− model.draft_message.clear();
444+ if text.is_empty() {
445+ return render();
446+ }
447+ model.local_seq += 1;
448+ let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
449+ user.status = MessageStatus::Pending;
450+ MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
451+ model.messages.push(user);
452+ model.draft.clear();
424453 model.generating = true;
425− let body = serde_json::json!({
426− "sessionID": session_id,
427− "parts": [{ "type": "text", "text": text }]
428− })
429− .to_string()
430− .into_bytes();
431− let path = format!("/session/{session_id}/prompt_async");
432− self.post(model, &path, body)
433− .build()
434− .then_send(Event::MessageSent)
435− }
436−
437− Event::MessageSent(result) => {
438− match result {
439− Ok(_) => {
440− // Reload messages to pick up the assistant reply.
441− if let Some(session_id) = model.current_session_id.clone() {
442− Command::event(Event::LoadMessages(session_id))
443− } else {
444− model.generating = false;
445− render()
446− }
447− }
454+ model.error = None;
455+
456+ let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
457+ render().and(
458+ self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
459+ .build()
460+ .then_send(Event::PromptSent),
461+ )
462+ }
463+
464+ Event::PromptSent(result) => {
465+ match ok_body(result) {
466+ Ok(_) => set_last_pending(model, MessageStatus::Sent),
448467 Err(e) => {
468+ set_last_pending(model, MessageStatus::Failed);
449469 model.generating = false;
450− model.error = Some(format!("Failed to send message: {e}"));
451− render()
470+ model.error = Some(e);
452471 }
453472 }
473+ render()
454474 }
455475
456− Event::EventReceived(_data) => {
457− // SSE event — reload messages for the current session.
458− if let Some(session_id) = model.current_session_id.clone() {
459− Command::event(Event::LoadMessages(session_id))
460− } else {
461− render()
476+ Event::CancelGeneration => {
477+ model.generating = false;
478+ match model.current_session_id.clone() {
479+ Some(id) => render().and(
480+ self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
481+ .build()
482+ .then_send(Event::Aborted),
483+ ),
484+ None => render(),
462485 }
463486 }
464487
465− Event::CrashLog(entry) => {
466− model.crash_logs.push(entry);
467− self.render_with_crashes(model)
468− }
488+ Event::Aborted(_) => render(),
469489
470− Event::DismissError => {
471− model.error = None;
472− render()
473− }
474−
475− Event::DraftChanged(text) => {
476− model.draft_message = text;
490+ // ── SSE ─────────────────────────────────────────────────────────
491+ Event::ServerEvent(json) => {
492+ if let Some(event) = protocol::parse_event(&json) {
493+ apply_server_event(model, event);
494+ }
477495 render()
478496 }
479497
480− Event::CancelGeneration => {
481− // Client-side stop: the server-side generation continues, but
482− // we stop showing the "generating" state and return to ready.
483− // A subsequent SSE event / manual refresh will pick up the
484− // assistant reply when it arrives.
498+ // ── Navigation / misc ───────────────────────────────────────────
499+ Event::NavigateToSessions => {
500+ model.screen = Screen::Sessions;
501+ model.current_session_id = None;
502+ model.messages.clear();
485503 model.generating = false;
486− render()
487− }
488−
489− Event::DeleteSession(id) => {
490− // Optimistically remove the session from the list. If the
491− // server delete fails, we reload the session list to restore.
492− model.sessions.retain(|s| s.id != id);
493− if model.current_session_id.as_deref() == Some(id.as_str()) {
494− model.current_session_id = None;
495− model.current_session_title.clear();
496− model.messages.clear();
497− model.generating = false;
498− }
499− let path = format!("/session/{id}");
500− self.delete(model, &path).build().then_send(Event::SessionDeleted)
504+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
501505 }
502506
503− Event::RetryMessage(_msg_id) => {
504− // No persisted failed-message body to re-send in the core, so
505− // retry is a no-op here. The shell can surface a hint. This
506− // event exists so the UI has a stable retry path and so the
507− // core can grow real per-message retry later without a
508− // typegen change.
507+ Event::NavigateToConnect => {
508+ model.screen = Screen::Connect;
509+ model.connected = false;
510+ model.sessions.clear();
511+ model.messages.clear();
512+ model.current_session_id = None;
509513 render()
510514 }
511515
512− // ── Internal events (not type-generated, sent via then_send) ───
513− Event::HealthProbed(result) => handle_health_probed(self, model, result),
514− Event::SessionDeleted(result) => {
515− match result {
516− Ok(_) => {
517− render()
518− }
519− Err(e) => {
520− // Optimistic delete failed — reload sessions to
521− // restore the list, and surface the error.
522− model.error = Some(format!("Failed to delete session: {e}"));
523− self.get(model, "/session").build().then_send(Event::SessionsLoaded)
524− }
525− }
516+ Event::DismissError => {
517+ model.error = None;
518+ render()
526519 }
527520 }
528521 }
529522
530− fn view(&self, model: &Self::Model) -> Self::ViewModel {
531− let screen = if model.current_session_id.is_some() {
532− Screen::Chat
533− } else if model.connected {
534− Screen::Sessions
535− } else {
536− Screen::Connect
537− };
538−
523+ fn view(&self, model: &Model) -> ViewModel {
539524 ViewModel {
540− screen,
525+ screen: model.screen.clone(),
541526 server_url: model.server_url.clone(),
542527 username: model.username.clone(),
543528 password: model.password.clone(),
@@ -545,459 +530,472 @@ impl App for OpencodeApp {
545530 connected: model.connected,
546531 loading: model.loading,
547532 error: model.error.clone(),
548− sessions: model
549− .sessions
550− .iter()
551− .map(|s| SessionView {
552− id: s.id.clone(),
553− title: s.title.clone(),
554− })
555− .collect(),
533+ sessions: model.sessions.clone(),
556534 current_session_id: model.current_session_id.clone(),
557535 current_session_title: model.current_session_title.clone(),
558− messages: model.messages.clone(),
559− draft_message: model.draft_message.clone(),
536+ messages: model.messages.iter().map(MsgState::to_view).collect(),
537+ draft: model.draft.clone(),
560538 generating: model.generating,
561− crash_log_count: model.crash_logs.len() as u32,
562− latest_crash_log: model.crash_logs.last().cloned(),
563539 }
564540 }
565541}
566542
567−// ─── Basic-auth detection ──────────────────────────────────────────────────
568−//
569−// The opencode server uses HTTP Basic Auth when OPENCODE_SERVER_PASSWORD is set
570−// (username defaults to "opencode"). We detect this by probing /global/health
571−// without credentials:
572−//
573−// - 200 → server is open; proceed to load sessions.
574−// - 401 → server is behind basic auth; surface `auth_required` and wait for
575−// the user to supply credentials. The user then re-issues Connect,
576−// which now attaches the Authorization header to the same probe.
577−// A 200 on that retry flips `authed` and proceeds.
578−// - Other non-2xx → surface as a connection error.
579−// - Transport error → surface as a connection error.
580−
581−fn handle_health_probed(
582− app: &OpencodeApp,
583− model: &mut Model,
584− result: HttpResult<String>,
585−) -> Command<Effect, Event> {
586− let has_creds = !model.username.is_empty() || !model.password.is_empty();
587− match result {
588− Ok(response) => {
589− let status = response.status().as_u16();
590− if status == 401 { model.loading = false;
591− if has_creds {
592− // We sent credentials but the server still rejected them.
593− model.error = Some("Invalid credentials".to_string());
594− } else {
595− // No credentials were sent — server is behind basic auth.
596− // Surface the credential fields and wait for the user.
597− model.auth_required = true;
543+impl OpencodeApp {
544+ fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
545+ let has_creds = model.has_credentials();
546+ match result {
547+ Ok(response) => {
548+ let status = response.status().as_u16();
549+ if status == 401 {
550+ model.loading = false;
551+ if has_creds {
552+ model.error = Some("Invalid credentials".to_string());
553+ } else {
554+ model.auth_required = true;
555+ }
556+ return render();
598557 }
599− return render();
600− }
601− if (200..300).contains(&status) {
602− if has_creds {
603− model.authed = true;
558+ if (200..300).contains(&status) {
559+ if has_creds {
560+ model.authed = true;
561+ }
562+ model.connected = true;
563+ model.auth_required = false;
564+ model.error = None;
565+ model.screen = Screen::Sessions;
566+ model.loading = true;
567+ return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
604568 }
605− model.loading = true;
606− return app
607− .get(model, "/session")
608− .build()
609− .then_send(Event::SessionsLoaded);
610− }
611− // Other status codes — treat as connection error.
612− model.loading = false;
613− model.error = Some(format!("Server returned status {status}"));
614− render()
615− }
616− Err(e) => {
617− model.loading = false;
618− model.error = Some(format!("Connection failed: {e}"));
619− render()
569+ model.loading = false;
570+ model.error = Some(format!("Server returned status {status}"));
571+ render()
572+ }
573+ Err(e) => {
574+ model.loading = false;
575+ model.error = Some(format!("Connection failed: {e}"));
576+ render()
577+ }
620578 }
621579 }
622580}
623581
624−// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
625−
626−fn parse_sessions(body: &str) -> Vec<SessionSummary> {
627− let mut sessions = Vec::new();
628− let mut depth = 0i32;
629− let mut start = 0usize;
630− for (i, ch) in body.char_indices() {
631− match ch {
632− '{' => {
633− if depth == 0 {
634− start = i;
582+// ─── SSE application ─────────────────────────────────────────────────────────
583+
584+fn apply_server_event(model: &mut Model, event: ServerEvent) {
585+ match event {
586+ // Only assistant messages are created from the stream — the user's own
587+ // message is already shown optimistically, so ignoring the server's copy
588+ // avoids a duplicate bubble.
589+ ServerEvent::MessageUpdated { properties } => {
590+ let info = properties.info;
591+ if info.role != "assistant" || !model.is_current(&info.session_id) {
592+ return;
593+ }
594+ let streaming = info.time.completed.is_none() && info.error.is_none();
595+ if let Some(msg) = model.message_mut(&info.id) {
596+ msg.streaming = streaming;
597+ if msg.time == 0 {
598+ msg.time = info.time.created;
635599 }
636− depth += 1;
637− }
638− '}' => {
639− depth -= 1;
640− if depth == 0 {
641− let obj = &body[start..=i];
642− let id = extract_string(obj, "id").unwrap_or_default();
643− let title = extract_string(obj, "title").unwrap_or_default();
644− if !id.is_empty() {
645− sessions.push(SessionSummary {
646− id,
647− title: if title.is_empty() {
648− "Untitled".to_string()
649− } else {
650− title
651− },
652− });
600+ } else {
601+ let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
602+ msg.streaming = streaming;
603+ model.messages.push(msg);
604+ }
605+ if let Some(err) = info.error {
606+ model.error = Some(err.message());
607+ model.generating = false;
608+ }
609+ }
610+
611+ // Apply only to a message we already track (an assistant message from the
612+ // event above). Parts for unknown ids are ignored — see module docs.
613+ ServerEvent::MessagePartUpdated { properties } => {
614+ let part = properties.part;
615+ let delta = properties.delta;
616+ let msg_id = part.message_id().to_string();
617+ let Some(msg) = model.message_mut(&msg_id) else {
618+ return;
619+ };
620+ match part {
621+ WirePart::Text { id, text, synthetic, .. } => {
622+ if synthetic {
623+ return;
653624 }
625+ let value = pick_text(text, &delta, &msg.text_parts, &id);
626+ MsgState::upsert_part(&mut msg.text_parts, &id, value);
627+ }
628+ WirePart::Reasoning { id, text, .. } => {
629+ let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
630+ MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
631+ }
632+ WirePart::Tool { id, tool, state, .. } => {
633+ let sv = protocol::tool_state_view(&state);
634+ msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
635+ }
636+ WirePart::Other => {}
637+ }
638+ }
639+
640+ ServerEvent::MessagePartRemoved { properties } => {
641+ if let Some(msg) = model.message_mut(&properties.message_id) {
642+ msg.remove_part(&properties.part_id);
643+ }
644+ }
645+
646+ ServerEvent::MessageRemoved { properties } => {
647+ model.messages.retain(|m| m.id != properties.message_id);
648+ }
649+
650+ ServerEvent::SessionIdle { properties } => {
651+ if model.is_current(&properties.session_id) {
652+ model.generating = false;
653+ for m in &mut model.messages {
654+ m.streaming = false;
655+ }
656+ }
657+ }
658+
659+ ServerEvent::SessionError { properties } => {
660+ let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
661+ if scoped {
662+ if let Some(err) = properties.error {
663+ model.error = Some(err.message());
654664 }
665+ model.generating = false;
655666 }
656− _ => {}
657667 }
668+
669+ ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
670+ let s = properties.info;
671+ let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
672+ if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
673+ existing.title = view.title.clone();
674+ } else {
675+ model.sessions.insert(0, view);
676+ }
677+ if model.is_current(&s.id) {
678+ model.current_session_title = display_title(&s.title);
679+ }
680+ }
681+
682+ ServerEvent::SessionDeleted { properties } => {
683+ let id = properties.session_id.or(properties.info.map(|i| i.id));
684+ if let Some(id) = id {
685+ model.sessions.retain(|s| s.id != id);
686+ if model.is_current(&id) {
687+ model.current_session_id = None;
688+ model.messages.clear();
689+ model.screen = Screen::Sessions;
690+ }
691+ }
692+ }
693+
694+ ServerEvent::Other => {}
658695 }
659− sessions
660696}
661697
662−fn parse_session_id(body: &str) -> String {
663− extract_string(body, "id").unwrap_or_default()
698+// ─── Helpers ─────────────────────────────────────────────────────────────────
699+
700+/// `part.text` is the full current text when present; otherwise fall back to
701+/// accumulating the incremental `delta` onto whatever we already have.
702+fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
703+ if !text.is_empty() {
704+ return text;
705+ }
706+ let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
707+ match delta {
708+ Some(d) => format!("{prior}{d}"),
709+ None => prior,
710+ }
664711}
665712
666−fn parse_messages(body: &str) -> Vec<MessageView> {
667− let mut messages = Vec::new();
668− let mut depth = 0i32;
669− let mut start = 0usize;
670− let mut i = 0usize;
671− let bytes = body.as_bytes();
672− while i < body.len() {
673− let ch = bytes[i] as char;
674− match ch {
675− '{' => {
676− if depth == 0 {
677− start = i;
678− }
679− depth += 1;
680− }
681− '}' => {
682− depth -= 1;
683− if depth == 0 {
684− let obj = &body[start..=i];
685− let id = extract_string(obj, "id").unwrap_or_default();
686− let role = extract_string(obj, "role").unwrap_or_default();
687− let time = extract_number(obj, "created").unwrap_or(0);
688− if !id.is_empty() {
689− let text = extract_message_text(obj);
690− messages.push(MessageView { id, role, text, time, status: MessageStatus::Sent });
691− }
713+fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
714+ let info = &env.info;
715+ let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
716+ msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
717+ for part in &env.parts {
718+ match part {
719+ WirePart::Text { id, text, synthetic, .. } => {
720+ if !synthetic {
721+ MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
692722 }
693723 }
694− _ => {}
724+ WirePart::Reasoning { id, text, .. } => {
725+ MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
726+ }
727+ WirePart::Tool { id, tool, state, .. } => {
728+ let sv = protocol::tool_state_view(state);
729+ msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
730+ }
731+ WirePart::Other => {}
695732 }
696− i += 1;
697733 }
698− messages
734+ msg
699735}
700736
701−/// Extract the text from a message part. opencode messages nest the user/assistant
702−/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
703−/// first `"parts"` key.
704−fn extract_message_text(obj: &str) -> String {
705− if let Some(parts_idx) = obj.find("\"parts\"") {
706− let rest = &obj[parts_idx..];
707− if let Some(text) = extract_string(rest, "text") {
708− return text;
709− }
737+fn set_last_pending(model: &mut Model, status: MessageStatus) {
738+ if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
739+ msg.status = status;
710740 }
711− extract_string(obj, "text").unwrap_or_default()
712741}
713742
714−fn extract_string(json: &str, key: &str) -> Option<String> {
715− let pattern = format!("\"{key}\"");
716− let idx = json.find(&pattern)?;
717− let rest = &json[idx + pattern.len()..];
718− let colon = rest.find(':')?;
719− let rest = &rest[colon + 1..];
720− let quote = rest.find('"')?;
721− let rest = &rest[quote + 1..];
722− let end = rest.find('"')?;
723− Some(rest[..end].to_string())
743+/// Trim whitespace and any trailing `/` so paths concatenate cleanly.
744+fn normalize_url(url: &str) -> String {
745+ url.trim().trim_end_matches('/').to_string()
746+}
747+
748+fn display_title(title: &str) -> String {
749+ let t = title.trim();
750+ if t.is_empty() {
751+ "Untitled".to_string()
752+ } else {
753+ t.to_string()
754+ }
724755}
725756
726−fn extract_number(json: &str, key: &str) -> Option<u64> {
727− let pattern = format!("\"{key}\"");
728− let idx = json.find(&pattern)?;
729− let rest = &json[idx + pattern.len()..];
730− let colon = rest.find(':')?;
731− let rest = &rest[colon + 1..].trim_start();
732− let end = rest
733− .find(|c: char| !c.is_ascii_digit())
734− .unwrap_or(rest.len());
735− rest[..end].parse().ok()
757+/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
758+fn ok_body(result: HttpResult) -> Result<String, String> {
759+ match result {
760+ Ok(mut response) => {
761+ let status = response.status().as_u16();
762+ if (200..300).contains(&status) {
763+ Ok(response.take_body().unwrap_or_default())
764+ } else {
765+ Err(format!("Server returned status {status}"))
766+ }
767+ }
768+ Err(e) => Err(format!("Request failed: {e}")),
769+ }
736770}
737771
738−// ─── Tests ─────────────────────────────────────────────────────────────────
772+// ─── Tests ───────────────────────────────────────────────────────────────────
739773
740774#[cfg(test)]
741−mod test {
775+mod tests {
742776 use super::*;
743777
744− #[test]
745− fn start_does_not_reset_server_url() {
746− // Start no longer overwrites server_url — the shell restores the
747− // persisted value via ServerUrlChanged so the user's last server
748− // survives a relaunch.
749− let app = OpencodeApp;
750− let mut model = Model::default();
751− model.server_url = "http://10.0.0.1:4096".to_string();
752− app.update(Event::Start, &mut model).expect_only_render();
753− assert_eq!(model.server_url, "http://10.0.0.1:4096");
754− }
755−
756− #[test]
757− fn server_url_change_updates_model() {
758− let app = OpencodeApp;
759− let mut model = Model::default();
760− app.update(Event::Start, &mut model);
761− app.update(
762− Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
763− &mut model,
764− )
765− .expect_only_render();
766− assert_eq!(model.server_url, "http://10.0.0.1:4096");
778+ fn app() -> OpencodeApp {
779+ OpencodeApp::default()
767780 }
768781
769− #[test]
770− fn connect_emits_http_effect() {
771− let app = OpencodeApp;
782+ /// Put the model into a connected Chat view for the given session.
783+ fn in_chat(session_id: &str) -> Model {
772784 let mut model = Model::default();
773785 model.server_url = "http://localhost:4096".to_string();
774− app.update(Event::Start, &mut model);
775− let mut cmd = app.update(Event::Connect, &mut model);
776− assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
777− assert!(model.loading);
786+ model.connected = true;
787+ model.screen = Screen::Chat;
788+ model.current_session_id = Some(session_id.to_string());
789+ model
778790 }
779791
780− #[test]
781− fn parse_sessions_extracts_id_and_title() {
782− let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
783− let sessions = parse_sessions(json);
784− assert_eq!(sessions.len(), 2);
785− assert_eq!(sessions[0].id, "abc123");
786− assert_eq!(sessions[0].title, "My Session");
787− assert_eq!(sessions[1].id, "def456");
792+ fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
793+ let _ = app.update(Event::ServerEvent(json.to_string()), model);
788794 }
789795
790796 #[test]
791− fn parse_session_id_extracts_id() {
792− let json = r#"{"id":"xyz789","title":"New"}"#;
793− let id = parse_session_id(json);
794− assert_eq!(id, "xyz789");
797+ fn server_url_is_normalized() {
798+ let app = app();
799+ let mut model = Model::default();
800+ let _ = app.update(Event::ServerUrlChanged(" http://host:4096/ ".into()), &mut model);
801+ assert_eq!(app.view(&model).server_url, "http://host:4096");
795802 }
796803
797804 #[test]
798− fn extract_string_finds_nested_keys() {
799− let json = r#"{"a":"b","id":"target","c":"d"}"#;
800− assert_eq!(extract_string(json, "id"), Some("target".to_string()));
801− assert_eq!(extract_string(json, "missing"), None);
805+ fn connect_requires_a_url() {
806+ let app = app();
807+ let mut model = Model::default();
808+ let _ = app.update(Event::Connect, &mut model);
809+ assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
802810 }
803811
804812 #[test]
805− fn extract_number_parses_values() {
806− let json = r#"{"created":1234567890}"#;
807− assert_eq!(extract_number(json, "created"), Some(1234567890));
813+ fn sending_adds_optimistic_user_message_and_sets_generating() {
814+ let app = app();
815+ let mut model = in_chat("ses_1");
816+ let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
817+ let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
818+
819+ let view = app.view(&model);
820+ assert!(view.generating);
821+ assert!(view.draft.is_empty());
822+ assert_eq!(view.messages.len(), 1);
823+ assert_eq!(view.messages[0].role, "user");
824+ assert_eq!(view.messages[0].text, "hello there");
825+ assert_eq!(view.messages[0].status, MessageStatus::Pending);
808826 }
809827
810828 #[test]
811− fn navigate_to_sessions_clears_state() {
812− let app = OpencodeApp;
813− let mut model = Model::default();
814− model.current_session_id = Some("test".to_string());
815− model.current_session_title = "Test".to_string();
816− model.generating = true;
817− model.messages.push(MessageView {
818− id: "m1".to_string(),
819− role: "user".to_string(),
820− text: "hello".to_string(),
821− time: 0,
822− status: MessageStatus::Sent,
823− });
824− app.update(Event::NavigateToSessions, &mut model);
825− assert!(model.current_session_id.is_none());
826− assert!(model.current_session_title.is_empty());
827− assert!(model.messages.is_empty());
828− assert!(!model.generating);
829+ fn empty_send_is_ignored() {
830+ let app = app();
831+ let mut model = in_chat("ses_1");
832+ let _ = app.update(Event::SendMessage(" ".into()), &mut model);
833+ assert!(app.view(&model).messages.is_empty());
834+ assert!(!app.view(&model).generating);
829835 }
830836
831837 #[test]
832− fn select_session_records_title_from_sessions_list() {
833− let app = OpencodeApp;
834− let mut model = Model::default();
835− model.sessions = vec![
836− SessionSummary { id: "s1".to_string(), title: "First".to_string() },
837− SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
838− ];
839− app.update(Event::SelectSession("s2".to_string()), &mut model);
840− assert_eq!(model.current_session_id.as_deref(), Some("s2"));
841− assert_eq!(model.current_session_title, "Second");
838+ fn assistant_reply_streams_in_from_sse() {
839+ let app = app();
840+ let mut model = in_chat("ses_1");
841+
842+ // Assistant message shell arrives first.
843+ feed(&app, &mut model, serde_json::json!({
844+ "type": "message.updated",
845+ "properties": { "info": {
846+ "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
847+ "time": { "created": 100 }
848+ }}
849+ }));
850+ assert_eq!(app.view(&model).messages.len(), 1);
851+ assert!(app.view(&model).messages[0].streaming);
852+
853+ // Two text deltas for the same part accumulate into full text.
854+ feed(&app, &mut model, serde_json::json!({
855+ "type": "message.part.updated",
856+ "properties": { "part": {
857+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
858+ "type": "text", "text": "Hello"
859+ }}
860+ }));
861+ feed(&app, &mut model, serde_json::json!({
862+ "type": "message.part.updated",
863+ "properties": { "part": {
864+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
865+ "type": "text", "text": "Hello, world"
866+ }}
867+ }));
868+
842869 let view = app.view(&model);
843− assert_eq!(view.current_session_title, "Second");
870+ assert_eq!(view.messages[0].role, "assistant");
871+ assert_eq!(view.messages[0].text, "Hello, world");
844872 }
845873
846874 #[test]
847− fn send_message_sets_generating_until_messages_loaded() {
848− let app = OpencodeApp;
849− let mut model = Model::default();
850− model.server_url = "http://localhost:4096".to_string();
851− model.current_session_id = Some("s1".to_string());
852− app.update(Event::SendMessage("hello".to_string()), &mut model);
853− assert!(model.generating);
854− // Simulate the MessagesLoaded internal event clearing it.
855− let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
856− let result: HttpResult<String> = Ok(ok_resp);
857− app.update(Event::MessagesLoaded(result), &mut model);
858− assert!(!model.generating);
875+ fn text_delta_accumulates_when_full_text_absent() {
876+ let app = app();
877+ let mut model = in_chat("ses_1");
878+ feed(&app, &mut model, serde_json::json!({
879+ "type": "message.updated",
880+ "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
881+ }));
882+ for delta in ["Wor", "ld"] {
883+ feed(&app, &mut model, serde_json::json!({
884+ "type": "message.part.updated",
885+ "properties": {
886+ "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
887+ "delta": delta
888+ }
889+ }));
890+ }
891+ assert_eq!(app.view(&model).messages[0].text, "World");
859892 }
860893
861894 #[test]
862− fn failed_send_clears_generating() {
863− let app = OpencodeApp;
864− let mut model = Model::default();
865− model.server_url = "http://localhost:4096".to_string();
866− model.current_session_id = Some("s1".to_string());
867− app.update(Event::SendMessage("hello".to_string()), &mut model);
868− assert!(model.generating);
869− let err = crux_http::HttpError::Io("boom".to_string());
870− app.update(Event::MessageSent(Err(err)), &mut model);
871− assert!(!model.generating);
872− assert!(model.error.is_some());
895+ fn user_message_from_sse_is_not_duplicated() {
896+ let app = app();
897+ let mut model = in_chat("ses_1");
898+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
899+ // Server echoes the user message on the stream — must be ignored.
900+ feed(&app, &mut model, serde_json::json!({
901+ "type": "message.updated",
902+ "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
903+ }));
904+ let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
905+ assert_eq!(users, 1);
873906 }
874907
875908 #[test]
876− fn cancel_generation_clears_generating() {
877− let app = OpencodeApp;
878− let mut model = Model::default();
879− model.server_url = "http://localhost:4096".to_string();
880− model.current_session_id = Some("s1".to_string());
881− app.update(Event::SendMessage("hello".to_string()), &mut model);
882− assert!(model.generating);
883− app.update(Event::CancelGeneration, &mut model);
884− assert!(!model.generating);
909+ fn session_idle_clears_generating() {
910+ let app = app();
911+ let mut model = in_chat("ses_1");
912+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
913+ assert!(app.view(&model).generating);
914+ feed(&app, &mut model, serde_json::json!({
915+ "type": "session.idle", "properties": { "sessionID": "ses_1" }
916+ }));
917+ assert!(!app.view(&model).generating);
885918 }
886919
887920 #[test]
888− fn draft_changed_updates_model() {
889− let app = OpencodeApp;
890− let mut model = Model::default();
891− app.update(Event::DraftChanged("in progress".to_string()), &mut model);
892− assert_eq!(model.draft_message, "in progress");
893− let view = app.view(&model);
894− assert_eq!(view.draft_message, "in progress");
921+ fn idle_for_other_session_is_ignored() {
922+ let app = app();
923+ let mut model = in_chat("ses_1");
924+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
925+ feed(&app, &mut model, serde_json::json!({
926+ "type": "session.idle", "properties": { "sessionID": "ses_other" }
927+ }));
928+ assert!(app.view(&model).generating);
895929 }
896930
897931 #[test]
898− fn delete_session_optimistically_removes_from_list() {
899− let app = OpencodeApp;
900− let mut model = Model::default();
901− model.server_url = "http://localhost:4096".to_string();
902− model.sessions = vec![
903− SessionSummary { id: "s1".to_string(), title: "First".to_string() },
904− SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
905− ];
906− let mut cmd = app.update(Event::DeleteSession("s1".to_string()), &mut model);
907− // Session removed optimistically
908− assert_eq!(model.sessions.len(), 1);
909− assert_eq!(model.sessions[0].id, "s2");
910− // HTTP effect emitted for the server-side delete
911− assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
932+ fn reasoning_and_tool_parts_are_surfaced() {
933+ let app = app();
934+ let mut model = in_chat("ses_1");
935+ feed(&app, &mut model, serde_json::json!({
936+ "type": "message.updated",
937+ "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
938+ }));
939+ feed(&app, &mut model, serde_json::json!({
940+ "type": "message.part.updated",
941+ "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
942+ }));
943+ feed(&app, &mut model, serde_json::json!({
944+ "type": "message.part.updated",
945+ "properties": { "part": {
946+ "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
947+ "state": { "status": "completed", "title": "ls -la" }
948+ }}
949+ }));
950+ let msg = &app.view(&model).messages[0];
951+ assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
952+ assert_eq!(msg.tools.len(), 1);
953+ assert_eq!(msg.tools[0].name, "bash");
954+ assert_eq!(msg.tools[0].status, "completed");
955+ assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
912956 }
913957
914958 #[test]
915− fn delete_current_session_clears_chat_state() {
916− let app = OpencodeApp;
917− let mut model = Model::default();
918− model.server_url = "http://localhost:4096".to_string();
919− model.current_session_id = Some("s1".to_string());
920− model.current_session_title = "First".to_string();
921− model.generating = true;
922− model.messages.push(MessageView::default());
923− app.update(Event::DeleteSession("s1".to_string()), &mut model);
924− assert!(model.current_session_id.is_none());
925− assert!(model.current_session_title.is_empty());
926− assert!(model.messages.is_empty());
927− assert!(!model.generating);
959+ fn session_created_event_updates_list() {
960+ let app = app();
961+ let mut model = in_chat("ses_1");
962+ feed(&app, &mut model, serde_json::json!({
963+ "type": "session.created",
964+ "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
965+ }));
966+ assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
928967 }
929968
930969 #[test]
931− fn auth_header_built_from_credentials() {
932− let app = OpencodeApp;
933− let mut model = Model::default();
934− model.server_url = "http://localhost:4096".to_string();
935− model.username = "opencode".to_string();
936− model.password = "secret".to_string();
937− // Verify the base64 encoding matches RFC 7617.
938− let creds = format!("{}:{}", model.username, model.password);
939− let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
940− assert_eq!(encoded, "b3BlbmNvZGU6c2VjcmV0");
941− // Building a request should not panic.
942− let _ = app.get(&model, "/global/health");
970+ fn unknown_event_types_are_ignored() {
971+ let app = app();
972+ let mut model = in_chat("ses_1");
973+ feed(&app, &mut model, serde_json::json!({
974+ "type": "pty.created", "properties": { "whatever": true }
975+ }));
976+ feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
977+ // No panic, no state change.
978+ assert!(app.view(&model).messages.is_empty());
943979 }
944980
945981 #[test]
946− fn crash_log_event_accumulates() {
947− let app = OpencodeApp;
948− let mut model = Model::default();
949− app.update(Event::CrashLog("boom".to_string()), &mut model);
950− assert_eq!(model.crash_logs.len(), 1);
951− let view = app.view(&model);
952− assert_eq!(view.crash_log_count, 1);
953− assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
954− }
982+ fn messages_loaded_parses_info_and_parts() {
983+ let app = app();
984+ let mut model = in_chat("ses_1");
985+ let body = serde_json::json!([
986+ { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
987+ "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
988+ { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
989+ "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
990+ ]).to_string();
991+ let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
992+ let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
955993
956− /// Verify that the bincode discriminant indices the shell sends match
957− /// the Rust enum variant positions — the type generator skips
958− /// `#[facet(skip)]` variants and renumbers the remaining ones, so the
959− /// shell's index 7 (SelectSession) must land on Rust's SelectSession,
960− /// not on a skipped variant.
961− #[test]
962− fn event_discriminants_match_generated_indices() {
963− // The generated Kotlin Event enum (see generated/.../Core.kt) maps:
964− // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
965− // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
966− // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
967− // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
968− // 15=DismissError, 16=DraftChanged, 17=CancelGeneration,
969− // 18=DeleteSession, 19=RetryMessage
970− //
971− // Serialize each Rust variant and check the leading u32 index.
972− let cases: &[(Event, u32)] = &[
973− (Event::Start, 0),
974− (Event::ServerUrlChanged(String::new()), 1),
975− (Event::UsernameChanged(String::new()), 2),
976− (Event::PasswordChanged(String::new()), 3),
977− (Event::Connect, 4),
978− (Event::CancelAuth, 5),
979− (Event::LoadSessions, 6),
980− (Event::SelectSession(String::new()), 7),
981− (Event::CreateSession, 8),
982− (Event::LoadMessages(String::new()), 9),
983− (Event::SendMessage(String::new()), 10),
984− (Event::EventReceived(String::new()), 11),
985− (Event::NavigateToChat(String::new()), 12),
986− (Event::NavigateToSessions, 13),
987− (Event::NavigateToConnect, 14),
988− (Event::DismissError, 15),
989− (Event::DraftChanged(String::new()), 16),
990− (Event::CancelGeneration, 17),
991− (Event::DeleteSession(String::new()), 18),
992− (Event::RetryMessage(String::new()), 19),
993− ];
994− for (event, expected) in cases {
995− let bytes = bincode::serialize(event).unwrap();
996− let idx = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
997− assert_eq!(
998− idx, *expected,
999− "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
1000− );
1001− }
994+ let view = app.view(&model);
995+ assert_eq!(view.messages.len(), 2);
996+ assert_eq!(view.messages[0].text, "Question?");
997+ assert_eq!(view.messages[1].text, "Answer.");
998+ assert!(!view.messages[1].streaming);
999+ assert!(!view.generating);
10021000 }
10031001}
shared/src/bin/codegen.rs+27−32
@@ -1,40 +1,35 @@
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−}
1+//! Crux type-generation for the shared domain (built with `--features typegen`).
2+//!
3+//! Demonstrates the Crux type-sharing pipeline: the core's view types are the
4+//! single source of truth, and this binary emits matching JVM type definitions
5+//! for the shell to consume across an FFI boundary. In this NDK-less build the
6+//! Android shell hand-mirrors these types (see `app/.../core/ViewModel.kt`);
7+//! wiring a real `.so` would have it consume the generated output instead.
8+//!
9+//! Run: `cd shared && cargo run --features typegen --bin codegen`
10+//! Output: `generated/` (JVM/Java sources + the bincode serde runtime).
1111
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−}
12+use std::path::PathBuf;
2013
21−fn main() -> anyhow::Result<()> {
22− let args = Args::parse();
14+use anyhow::Result;
15+use crux_core::typegen::TypeGen;
16+use shared::{MessageStatus, MessageView, Screen, SessionView, ToolView, ViewModel};
2317
24− let typegen_app = TypeRegistry::new()
25− .register_app::<OpencodeApp>()?
26− .build()?;
18+fn main() -> Result<()> {
19+ let mut gen = TypeGen::new();
2720
28− let name = match args.language {
29− Language::Kotlin => "soy.iko.opencode.core",
30− };
31− let config = Config::builder(name, &args.output_dir).build();
21+ // Register leaf types before the aggregates that reference them.
22+ gen.register_type::<Screen>()?;
23+ gen.register_type::<MessageStatus>()?;
24+ gen.register_type::<ToolView>()?;
25+ gen.register_type::<SessionView>()?;
26+ gen.register_type::<MessageView>()?;
27+ gen.register_type::<ViewModel>()?;
3228
33− match args.language {
34− Language::Kotlin => {
35− typegen_app.kotlin(&config)?;
36− }
37− }
29+ let out = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../generated");
30+ std::fs::create_dir_all(&out)?;
31+ gen.java("soy.iko.opencode.shared", &out)?;
3832
33+ println!("Generated shared JVM types into {}", out.display());
3934 Ok(())
4035}
shared/src/ffi.rsdeleted−53
@@ -1,53 +0,0 @@
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.rs+13−2
@@ -1,6 +1,17 @@
1−#![allow(clippy::used_underscore_items)]
1+//! Crux core for the opencode Android client.
2+//!
3+//! The core owns all state and logic (connect/auth, session list, chat, and the
4+//! streaming of assistant replies from the SSE event stream). The platform shell
5+//! (Android / Jetpack Compose) is a thin driver: it renders [`ViewModel`], turns
6+//! user actions into [`Event`]s, performs the effects the core asks for (HTTP
7+//! requests, and — outside the core — the `/event` SSE subscription), and feeds
8+//! server-sent events back in as [`Event::ServerEvent`].
9+//!
10+//! Because HTTP request/response is the only network capability Crux models,
11+//! the long-lived SSE connection lives in the shell; each decoded event is
12+//! handed to the core verbatim as a JSON string.
213
314mod app;
4−pub mod ffi;
15+mod protocol;
516
617pub use app::*;
shared/src/protocol.rsnew file+248
@@ -0,0 +1,248 @@
1+//! Wire types for the opencode HTTP + SSE API.
2+//!
3+//! These mirror the JSON shapes the opencode server emits (`sst/opencode`).
4+//! They are deliberately lenient: every unknown field is ignored and every
5+//! discriminated union has a catch-all `Other` arm so that new server versions
6+//! (which add event / part / tool-state variants over time) never break the
7+//! stream parser.
8+
9+use serde::Deserialize;
10+
11+// ─── Sessions ────────────────────────────────────────────────────────────────
12+
13+#[derive(Deserialize, Debug, Clone)]
14+pub struct WireSession {
15+ pub id: String,
16+ #[serde(default)]
17+ pub title: String,
18+}
19+
20+// ─── Messages ────────────────────────────────────────────────────────────────
21+
22+/// One element of `GET /session/{id}/message`: `{ info, parts }`.
23+#[derive(Deserialize, Debug)]
24+pub struct WireMessageEnvelope {
25+ pub info: WireMessageInfo,
26+ #[serde(default)]
27+ pub parts: Vec<WirePart>,
28+}
29+
30+#[derive(Deserialize, Debug, Clone)]
31+pub struct WireMessageInfo {
32+ pub id: String,
33+ #[serde(default)]
34+ pub role: String,
35+ #[serde(default, rename = "sessionID")]
36+ pub session_id: String,
37+ #[serde(default)]
38+ pub time: WireTime,
39+ #[serde(default)]
40+ pub error: Option<WireError>,
41+}
42+
43+#[derive(Deserialize, Debug, Clone, Default)]
44+pub struct WireTime {
45+ #[serde(default)]
46+ pub created: u64,
47+ /// Present once an assistant message has finished generating.
48+ #[serde(default)]
49+ pub completed: Option<u64>,
50+}
51+
52+/// Message / part content. Only the variants a chat UI renders are modelled;
53+/// everything else (`file`, `agent`, `step-start`, `step-finish`, `snapshot`,
54+/// `patch`, …) falls through to `Other`.
55+#[derive(Deserialize, Debug, Clone)]
56+#[serde(tag = "type", rename_all = "lowercase")]
57+pub enum WirePart {
58+ Text {
59+ #[serde(default)]
60+ id: String,
61+ #[serde(default, rename = "messageID")]
62+ message_id: String,
63+ #[serde(default)]
64+ text: String,
65+ #[serde(default)]
66+ synthetic: bool,
67+ },
68+ Reasoning {
69+ #[serde(default)]
70+ id: String,
71+ #[serde(default, rename = "messageID")]
72+ message_id: String,
73+ #[serde(default)]
74+ text: String,
75+ },
76+ Tool {
77+ #[serde(default)]
78+ id: String,
79+ #[serde(default, rename = "messageID")]
80+ message_id: String,
81+ #[serde(default)]
82+ tool: String,
83+ #[serde(default)]
84+ state: serde_json::Value,
85+ },
86+ #[serde(other)]
87+ Other,
88+}
89+
90+impl WirePart {
91+ pub fn message_id(&self) -> &str {
92+ match self {
93+ WirePart::Text { message_id, .. }
94+ | WirePart::Reasoning { message_id, .. }
95+ | WirePart::Tool { message_id, .. } => message_id,
96+ WirePart::Other => "",
97+ }
98+ }
99+}
100+
101+/// Compact view of a `tool` part's state (the union keyed on `status`).
102+pub struct ToolStateView {
103+ pub status: String,
104+ pub title: Option<String>,
105+}
106+
107+/// Pull the `status` and a human title/output out of a tool part's `state` blob.
108+pub fn tool_state_view(state: &serde_json::Value) -> ToolStateView {
109+ let status = state
110+ .get("status")
111+ .and_then(|v| v.as_str())
112+ .unwrap_or("pending")
113+ .to_string();
114+ let title = state
115+ .get("title")
116+ .and_then(|v| v.as_str())
117+ .or_else(|| state.get("error").and_then(|v| v.as_str()))
118+ .map(str::to_string);
119+ ToolStateView { status, title }
120+}
121+
122+// ─── Errors ──────────────────────────────────────────────────────────────────
123+
124+#[derive(Deserialize, Debug, Clone)]
125+pub struct WireError {
126+ #[serde(default)]
127+ pub name: String,
128+ #[serde(default)]
129+ pub data: serde_json::Value,
130+}
131+
132+impl WireError {
133+ /// Best human-readable message: `data.message` if present, else the error name.
134+ pub fn message(&self) -> String {
135+ self.data
136+ .get("message")
137+ .and_then(|v| v.as_str())
138+ .map(str::to_string)
139+ .filter(|s| !s.is_empty())
140+ .unwrap_or_else(|| {
141+ if self.name.is_empty() {
142+ "Unknown error".to_string()
143+ } else {
144+ self.name.clone()
145+ }
146+ })
147+ }
148+}
149+
150+// ─── SSE `/event` stream ─────────────────────────────────────────────────────
151+
152+/// The SSE envelope: `{ "type": "...", "properties": { ... } }`. Unknown event
153+/// types decode to `Other` and are ignored.
154+#[derive(Deserialize, Debug)]
155+#[serde(tag = "type")]
156+pub enum ServerEvent {
157+ #[serde(rename = "message.updated")]
158+ MessageUpdated { properties: MessageUpdatedProps },
159+ #[serde(rename = "message.part.updated")]
160+ MessagePartUpdated { properties: PartUpdatedProps },
161+ #[serde(rename = "message.part.removed")]
162+ MessagePartRemoved { properties: PartRemovedProps },
163+ #[serde(rename = "message.removed")]
164+ MessageRemoved { properties: MessageRemovedProps },
165+ #[serde(rename = "session.idle")]
166+ SessionIdle { properties: SessionIdProps },
167+ #[serde(rename = "session.error")]
168+ SessionError { properties: SessionErrorProps },
169+ #[serde(rename = "session.updated")]
170+ SessionUpdated { properties: SessionInfoProps },
171+ #[serde(rename = "session.created")]
172+ SessionCreated { properties: SessionInfoProps },
173+ #[serde(rename = "session.deleted")]
174+ SessionDeleted { properties: SessionDeletedProps },
175+ #[serde(other)]
176+ Other,
177+}
178+
179+#[derive(Deserialize, Debug)]
180+pub struct MessageUpdatedProps {
181+ pub info: WireMessageInfo,
182+}
183+
184+#[derive(Deserialize, Debug)]
185+pub struct PartUpdatedProps {
186+ pub part: WirePart,
187+ #[serde(default)]
188+ pub delta: Option<String>,
189+}
190+
191+#[derive(Deserialize, Debug)]
192+pub struct PartRemovedProps {
193+ #[serde(default, rename = "messageID")]
194+ pub message_id: String,
195+ #[serde(default, rename = "partID")]
196+ pub part_id: String,
197+}
198+
199+#[derive(Deserialize, Debug)]
200+pub struct MessageRemovedProps {
201+ #[serde(default, rename = "messageID")]
202+ pub message_id: String,
203+}
204+
205+#[derive(Deserialize, Debug)]
206+pub struct SessionIdProps {
207+ #[serde(default, rename = "sessionID")]
208+ pub session_id: String,
209+}
210+
211+#[derive(Deserialize, Debug)]
212+pub struct SessionErrorProps {
213+ #[serde(default, rename = "sessionID")]
214+ pub session_id: Option<String>,
215+ #[serde(default)]
216+ pub error: Option<WireError>,
217+}
218+
219+#[derive(Deserialize, Debug)]
220+pub struct SessionInfoProps {
221+ pub info: WireSession,
222+}
223+
224+#[derive(Deserialize, Debug)]
225+pub struct SessionDeletedProps {
226+ #[serde(default, rename = "sessionID")]
227+ pub session_id: Option<String>,
228+ #[serde(default)]
229+ pub info: Option<WireSession>,
230+}
231+
232+// ─── Parse helpers ───────────────────────────────────────────────────────────
233+
234+pub fn parse_sessions(body: &str) -> Vec<WireSession> {
235+ serde_json::from_str(body).unwrap_or_default()
236+}
237+
238+pub fn parse_session(body: &str) -> Option<WireSession> {
239+ serde_json::from_str(body).ok()
240+}
241+
242+pub fn parse_messages(body: &str) -> Vec<WireMessageEnvelope> {
243+ serde_json::from_str(body).unwrap_or_default()
244+}
245+
246+pub fn parse_event(json: &str) -> Option<ServerEvent> {
247+ serde_json::from_str(json).ok()
248+}