git.iko.soy/opencode-android
on master help sign in
.github/workflows/build.yml+12−28
since splitsnapshots
unifiedsplit
@@ -12,7 +12,19 @@ concurrency:
12 cancel-in-progress: true
12 cancel-in-progress: true
13
13
14jobs:
14jobs:
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+
15 build:
26 build:
27+ name: Android app
16 runs-on: ubuntu-latest
28 runs-on: ubuntu-latest
17
29
18 steps:
30 steps:
@@ -30,43 +42,15 @@ jobs:
30 - name: Run unit tests
42 - name: Run unit tests
31 run: ./gradlew testDebugUnitTest --no-daemon
43 run: ./gradlew testDebugUnitTest --no-daemon
32
44
33− - name: Static analysis (detekt)
34− run: ./gradlew detekt --no-daemon
35−
36 - name: Lint
45 - name: Lint
37 run: ./gradlew lintDebug --no-daemon
46 run: ./gradlew lintDebug --no-daemon
38
47
39 - name: Build debug APK
48 - name: Build debug APK
40 run: ./gradlew assembleDebug --no-daemon
49 run: ./gradlew assembleDebug --no-daemon
41
50
42− - name: Decode release keystore
43− run: |
44− mkdir -p keystore
45− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > keystore/opencode-release.jks
46− env:
47− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
48−
49− - name: Build release APK
50− env:
51− OPENCODE_STORE_FILE: ${{ github.workspace }}/keystore/opencode-release.jks
52− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
53− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
54− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
55− run: ./gradlew assembleRelease --no-daemon
56−
57 - name: Upload debug APK
51 - name: Upload debug APK
58 uses: actions/upload-artifact@v4
52 uses: actions/upload-artifact@v4
59 with:
53 with:
60 name: opencode-debug.apk
54 name: opencode-debug.apk
61 path: app/build/outputs/apk/debug/*.apk
55 path: app/build/outputs/apk/debug/*.apk
62 if-no-files-found: error
56 if-no-files-found: error
63−
64− # The release output dir is `release/` when unsigned but `release-signed/` (or a
65− # variant name) once a signing config is applied. Match both so CI keeps working
66− # once the OPENCODE_STORE_* secrets are wired up.
67− - name: Upload release APK
68− uses: actions/upload-artifact@v4
69− with:
70− name: opencode-release.apk
71− path: app/build/outputs/apk/**/*.apk
72− if-no-files-found: error
.github/workflows/release-build.ymldeleted−54
@@ -1,54 +0,0 @@
1−name: Release Build
2−
3−on:
4− push:
5− branches: [master]
6− pull_request:
7− workflow_dispatch:
8−
9−concurrency:
10− group: ${{ github.workflow }}-${{ github.ref }}
11− cancel-in-progress: true
12−
13−jobs:
14− release-build:
15− runs-on: ubuntu-latest
16−
17− steps:
18− - uses: actions/checkout@v4
19−
20− - uses: actions/setup-java@v4
21− with:
22− distribution: temurin
23− java-version: 17
24−
25− - uses: android-actions/setup-android@v3
26−
27− - uses: gradle/actions/setup-gradle@v4
28−
29− # The keystore ships as base64 in a secret so it never touches the repo.
30− # When the secret isn't configured the Gradle build falls back to an
31− # unsigned APK, so this workflow is green even before signing is wired up.
32− - name: Decode signing keystore
33− if: env.OPENCODE_STORE_BASE64 != ''
34− env:
35− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
36− run: |
37− STORE_FILE="$RUNNER_TEMP/release.keystore"
38− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
39− echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
40−
41− - name: Build release APK
42− env:
43− OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
44− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
45− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
46− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
47− run: ./gradlew assembleRelease --no-daemon
48−
49− - name: Upload release APK
50− uses: actions/upload-artifact@v4
51− with:
52− name: release-apk
53− path: app/build/outputs/apk/**/*.apk
54− if-no-files-found: error
.github/workflows/release.yml+53−36
@@ -1,17 +1,28 @@
1name: Release
1name: Release
2
2
3−# Build the production (release) APK and publish a GitHub Release for every
3+# Build a minified, signed release APK and publish it as a GitHub Release.
4−# commit that lands on master. The version -- and the git tag / release name --
4+#
5−# is derived from the commit's committer date in UTC, formatted as
5+# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6−# YYYY.MM.DD.HHMM (e.g. 2026.07.04.1430). It is passed to the Gradle build as
6+# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7+# Actions tab (workflow_dispatch). For a branch push or manual run the version
8+# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9+# tag name (with the leading `v` stripped) as the version.
10+#
11+# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12+# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13+#
14+# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15+# with that keystore; otherwise the Gradle build falls back to the debug key so
16+# the released APK is still installable (see app/build.gradle.kts). The version
7−# `-PversionName=...` so it is stamped into the APK and surfaces in-app.
17+# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
8on:
18on:
9 push:
19 push:
10 branches: [master]
20 branches: [master]
21+ tags: ["v*"]
22+ workflow_dispatch:
11
23
12−# A release run should never be aborted mid-flight by a newer push (a
24+# Never abort a release mid-flight — a half-published release with a missing
13−# half-published release with a missing artifact is worse than a slightly stale
25+# artifact is worse than a slightly delayed one.
14−# one), so the group is per-ref but cancel-in-progress is left off.
15concurrency:
26concurrency:
16 group: ${{ github.workflow }}-${{ github.ref }}
27 group: ${{ github.workflow }}-${{ github.ref }}
17 cancel-in-progress: false
28 cancel-in-progress: false
@@ -20,9 +31,14 @@ jobs:
20 release:
31 release:
21 runs-on: ubuntu-latest
32 runs-on: ubuntu-latest
22 permissions:
33 permissions:
23− contents: write # required to push the tag and create the release
34+ contents: write # push the tag and create the release
24− id-token: write # required for OIDC so GitHub can sign the attestation
35+ id-token: write # OIDC so GitHub can sign the provenance attestation
25− attestations: write # required to publish the attestation record
36+ attestations: write # publish the attestation record
37+ env:
38+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
26 steps:
42 steps:
27 - uses: actions/checkout@v4
43 - uses: actions/checkout@v4
28 with:
44 with:
@@ -30,12 +46,23 @@ jobs:
30 # accurate instead of collapsing to 1 on a shallow clone.
46 # accurate instead of collapsing to 1 on a shallow clone.
31 fetch-depth: 0
47 fetch-depth: 0
32
48
33− - name: Compute version from commit date
49+ - name: Compute version
34 id: version
50 id: version
35 run: |
51 run: |
52+ if [ "${{ github.ref_type }}" = "tag" ]; then
53+ VERSION="${GITHUB_REF_NAME#v}"
54+ TAG="${GITHUB_REF_NAME}"
55+ else
36− VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
56+ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57+ TAG="v${VERSION}"
58+ fi
59+ CODE="$(git rev-list --count HEAD)"
60+ {
37− echo "version=$VERSION" >> "$GITHUB_OUTPUT"
61+ echo "version=$VERSION"
38− echo "Version: $VERSION"
62+ echo "tag=$TAG"
63+ echo "code=$CODE"
64+ } >> "$GITHUB_OUTPUT"
65+ echo "Releasing $TAG (version $VERSION, code $CODE)"
39
66
40 - uses: actions/setup-java@v4
67 - uses: actions/setup-java@v4
41 with:
68 with:
@@ -47,40 +74,30 @@ jobs:
47 - uses: gradle/actions/setup-gradle@v4
74 - uses: gradle/actions/setup-gradle@v4
48
75
49 # The keystore ships as base64 in a secret so it never touches the repo.
76 # The keystore ships as base64 in a secret so it never touches the repo.
50− # When the secret isn't configured the Gradle build falls back to an
77+ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
51− # unsigned APK (the same behavior as a local build), so this workflow is
52− # green even before signing is wired up.
53 - name: Decode signing keystore
78 - name: Decode signing keystore
54 if: env.OPENCODE_STORE_BASE64 != ''
79 if: env.OPENCODE_STORE_BASE64 != ''
55− env:
56− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
57 run: |
80 run: |
58 STORE_FILE="$RUNNER_TEMP/release.keystore"
81 STORE_FILE="$RUNNER_TEMP/release.keystore"
59 printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
82 printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
60 echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
83 echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
61
84
62 - name: Build release APK
85 - name: Build release APK
63− env:
86+ run: |
64− OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
87+ ./gradlew assembleRelease \
65− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
66− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
67− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
68− run: ./gradlew assembleRelease -PversionName="${{ steps.version.outputs.version }}" --no-daemon
88+ -PversionName="${{ steps.version.outputs.version }}" \
89+ -PversionCode="${{ steps.version.outputs.code }}" \
90+ --no-daemon
69
91
70− # Rename to a stable, version-tagged filename so the release attachment is
92+ # Rename to a stable, version-tagged filename for the release attachment.
71− # recognizable regardless of the release/ vs release-signed/ output dir.
72 - name: Stage APK
93 - name: Stage APK
73 run: |
94 run: |
74 mkdir -p staging
95 mkdir -p staging
75− APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
96+ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
76 cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
97 cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
77
98
78− # Generate a signed build-provenance attestation for the staged APK and
99+ # Signed build-provenance attestation for the exact artifact being
79− # store it in GitHub's artifact attestation store, keyed by the file's
100+ # published; later verifiable with `gh attestation verify <apk>`.
80− # digest. Anyone can later verify the APK's origin/provenance with
81− # `gh attestation verify` -- its repository & workflow. Run *after*
82− # staging and *before* the release is published so the attested subject
83− # is byte-identical to the artifact attached to the release.
84 - name: Attest build provenance
101 - name: Attest build provenance
85 uses: actions/attest-build-provenance@v2
102 uses: actions/attest-build-provenance@v2
86 with:
103 with:
@@ -89,7 +106,7 @@ jobs:
89 - name: Create release
106 - name: Create release
90 uses: softprops/action-gh-release@v2
107 uses: softprops/action-gh-release@v2
91 with:
108 with:
92− tag_name: ${{ steps.version.outputs.version }}
109+ tag_name: ${{ steps.version.outputs.tag }}
93 name: ${{ steps.version.outputs.version }}
110 name: ${{ steps.version.outputs.version }}
94 make_latest: true
111 make_latest: true
95 generate_release_notes: true
112 generate_release_notes: true
.gitignore+13−3
@@ -1,6 +1,8 @@
1# Gradle
1# Gradle
2.gradle/
2.gradle/
3build/
3build/
4+*.apk
5+*.aab
4
6
5# Kotlin
7# Kotlin
6.kotlin/
8.kotlin/
@@ -29,13 +31,21 @@ Thumbs.db
29
31
30# Nix
32# Nix
31result
33result
34+result-*
32.direnv/
35.direnv/
33
36
37+# Local tooling / editor state (not part of the project)
38+.crush/
39+.crush.db
40+*.log
41+
34# Rust
42# Rust
35shared/target/
43shared/target/
44+/target/
36
45
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.
38generated/jniLibs/
49generated/jniLibs/
39
50
40−# Gradle wrapper (should be committed, but ignore if not present)
51+# Gradle wrapper jar is committed so CI can run ./gradlew
41−gradle/wrapper/gradle-wrapper.jar
CLAUDE.mdnew file+40
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+124−75
@@ -1,104 +1,153 @@
1# opencode-android
1# opencode-android
2
2
3−An Android client for the [opencode](https://github.com/sst/opencode) server, built with
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4−[Crux](https://github.com/redbadger/crux) (shared Rust core + Kotlin shell) and Jetpack Compose.
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>
5
12
6## Architecture
13## Architecture
7
14
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+
8```
19```
9−┌─────────────────────────────────────────────────┐
20+┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
10−│ Shell (Kotlin / Jetpack Compose) │
21+│ Crux core — the single source of truth │
11−│ ┌─────────────┐ ┌──────────────────────────┐ │
22+│ • Model / Event / Effect / ViewModel │
12−│ │ Compose UI │ │ HttpClient / SseClient │ │
23+│ • update(): connect+auth, sessions, chat, SSE stream application │
13−│ │ (3 screens) │ │ (Ktor + OkHttp) │ │
24+│ • crux_http for HTTP effects; unit-tested with `cargo test` │
14−│ └──────┬───────┘ └────────────┬─────────────┘ │
25+└───────────────────────────────────────────────────────────────────────┘
15−│ │ │ │
26+ ▲ mirrored, behaviour-for-behaviour
16−│ Event (bincode) Effect (bincode) │
27+┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
17−│ │ ▲ │
28+│ Jetpack Compose shell │
18−│ ┌──────▼───────────────────────┴────────────┐ │
29+│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
19−│ │ Core (ViewModel) — FFI bridge │ │
30+│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
20−│ │ CoreFfi → NativeCoreFfi / PureCoreFfi │ │
31+│ • Compose UI: Connect / Sessions / Chat │
21−│ └───────────────────────────────────────────┘ │
22−└──────────┬──────────────────────────────────────┘
23− │ JNI (bincode bytes)
24−┌──────────▼──────────────────────────────────────┐
25−│ Core (Rust / Crux) │
26−│ ┌────────┐ ┌─────────┐ ┌──────────┐ │
27−│ │ App │ │ Model │ │ Commands │ │
28−│ │ update │ │ state │ │ effects │ │
29−│ └────────┘ └─────────┘ └──────────┘ │
30−│ No side-effects — pure logic │
31−└─────────────────────────────────────────────────┘
32+└───────────────────────────────────────────────────────────────────────┘
32```
33```
33
34
34−### Rust Core (`shared/`)
35+### Why a Kotlin port instead of a JNI `.so`?
35−- **`app.rs`** — Crux `App` implementation: `Event`, `Model`, `ViewModel`, `Effect`, `update()`, `view()`
36−- **`ffi.rs`** — BoltFFI export wrapping `crux_core::bridge::Bridge`
37−- **`bin/codegen.rs`** — Type generator (produces Kotlin types via `facet-generate`)
38
36
39−### Kotlin Shell (`app/`)
37+Crux normally compiles the Rust core to a native library and the shell talks to
40−- **`core/CoreFfi.kt`** — FFI interface + JNI bridge (loads `libopencode.so`)
38+it over an FFI bridge. That requires an Android cross-compilation toolchain
41−- **`core/PureCoreFfi.kt`** — Pure-Kotlin fallback (mirrors Rust logic for dev without NDK)
39+(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
42−- **`core/Core.kt`** — Android ViewModel bridge: sends events, processes effects, exposes `StateFlow<ViewModel>`
40+environment intentionally ships **without** an NDK, so instead:
43−- **`core/HttpClient.kt`** — Executes `Effect.Http` via Ktor/OkHttp, returns `HttpResult` to core
44−- **`core/SseClient.kt`** — Subscribes to opencode SSE event stream
45−- **`ui/screens/`** — Connect, Sessions, Chat screens (Jetpack Compose + Material 3)
46
41
47−### Generated Types (`generated/`)
42+- The **Rust core is the canonical specification** of behaviour and is fully
48−- Auto-generated Kotlin types matching the Rust core (Event, Effect, ViewModel, Request, etc.)
43+ unit-tested (`cd shared && cargo test`).
49−- Bincode serialization helpers (`com.novi.bincode`, `com.novi.serde`)
44+- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45+ reducer, driving an identical `UiState`/`ViewModel`.
50
46
51−## Building
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.
52
51
53−### Prerequisites
52+## Project layout
54−- Rust 1.90+ (with Android NDK targets for native builds)
55−- Android SDK (compile SDK 37, build-tools 37.0.0)
56−- JDK 17+
57
53
58−### Generate Kotlin types
54+```
59−```sh
55+flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
60−cd shared
56+shared/ Crux core (Rust)
61−cargo run --bin codegen --features codegen -- --language kotlin --output-dir ../generated
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
62```
66```
63
67
64−### Build native library (requires NDK)
68+## Getting started
65−```sh
66−# Install Android targets
67−rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
68
69
69−# Build and pack with BoltFFI
70+### Dev shell
70−boltffi pack android
71+
71−```
72+Everything you need is provided by the Nix flake:
72
73
73−### Build the APK
74```sh
74```sh
75−./gradlew assembleDebug
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
76```
76```
77
77
78−The app works without the native library — `PureCoreFfi` provides a pure-Kotlin
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79−reimplementation of the core logic. When `libopencode.so` is present, `NativeCoreFfi`
79+build-tools 35.0.0) on your `PATH`.
80−is used automatically.
80+
81+### Build & test
81
82
82−### Run tests
83```sh
83```sh
84−# Rust core tests
84+# Rust core — logic + streaming unit tests
85cd shared && cargo test
85cd shared && cargo test
86
86
87−# Kotlin shell tests
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89+
90+# Android unit tests + lint
88−./gradlew testDebugUnitTest
91+./gradlew testDebugUnitTest lintDebug
92+```
93+
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
89```
100```
90
101
91−## API Coverage
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
109+
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.
122+
123+## Continuous integration & releases
124+
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.
131+
132+ ```sh
133+ git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134+ ```
135+
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`.
138+
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.
143+
144+## Notes & limitations
92
145
93−The client connects to an opencode server and supports:
146+- Rendering covers text, reasoning, and tool-call parts. Interactive tool
94−- **Health check** (`GET /global/health`)
147+ **permission** prompts (`permission.updated`) are not yet surfaced.
95−- **Session list** (`GET /session`)
148+- Model/agent selection uses the server's configured default (no picker yet).
96−- **Create session** (`POST /session`)
149+- No `.so` is built in this environment (see *Architecture* above).
97−- **Load messages** (`GET /session/{id}/message`)
98−- **Send message** (`POST /session/{id}/prompt_async`)
99−- **SSE events** (`GET /event`) — live message streaming
100
150
101−## Configuration
151+## License
102
152
103−The default server URL is `http://localhost:4096`. Change it in the Connect screen.
153+Apache-2.0.
104−`android:usesCleartextTraffic="true"` is enabled for local development.
app/build.gradle.kts+48−37
@@ -1,5 +1,3 @@
1−import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2−
3plugins {
1plugins {
4 alias(libs.plugins.android.application)
2 alias(libs.plugins.android.application)
5 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
@@ -7,86 +5,99 @@ plugins {
7 alias(libs.plugins.kotlin.serialization)
5 alias(libs.plugins.kotlin.serialization)
8}
6}
9
7
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+
10android {
20android {
11 namespace = "soy.iko.opencode"
21 namespace = "soy.iko.opencode"
12− compileSdk = 37
22+ compileSdk = 35
13− buildToolsVersion = "37.0.0"
14
23
15 defaultConfig {
24 defaultConfig {
16 applicationId = "soy.iko.opencode"
25 applicationId = "soy.iko.opencode"
17 minSdk = 26
26 minSdk = 26
18− targetSdk = 37
27+ targetSdk = 35
19− versionCode = 1
28+ versionCode = appVersionCode
20− versionName = "0.1.0"
29+ versionName = appVersionName
30+
21 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
22 }
32 }
23
33
24 signingConfigs {
34 signingConfigs {
35+ if (hasReleaseKeystore) {
25− create("release") {
36+ create("release") {
26− storeFile = System.getenv("OPENCODE_STORE_FILE")?.let { file(it) }
37+ storeFile = file(releaseStorePath!!)
27− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
38+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
28− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
39+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
29− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
40+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41+ }
30 }
42 }
31 }
43 }
32
44
33 buildTypes {
45 buildTypes {
34 release {
46 release {
35− isMinifyEnabled = false
47+ isMinifyEnabled = true
36− signingConfig = signingConfigs.getByName("release")
37 proguardFiles(
48 proguardFiles(
38 getDefaultProguardFile("proguard-android-optimize.txt"),
49 getDefaultProguardFile("proguard-android-optimize.txt"),
39 "proguard-rules.pro",
50 "proguard-rules.pro",
40 )
51 )
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
41 }
57 }
42 }
58 }
43
59
44 compileOptions {
60 compileOptions {
45− sourceCompatibility = JavaVersion.VERSION_11
61+ sourceCompatibility = JavaVersion.VERSION_17
46− targetCompatibility = JavaVersion.VERSION_11
62+ targetCompatibility = JavaVersion.VERSION_17
47 }
63 }
48
64
49− kotlin {
65+ kotlinOptions {
50− compilerOptions {
51− jvmTarget = JvmTarget.JVM_11
66+ jvmTarget = "17"
52− }
53 }
67 }
54
68
55 buildFeatures {
69 buildFeatures {
56 compose = true
70 compose = true
57 }
71 }
72+
73+ packaging {
74+ resources {
75+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
76+ }
77+ }
58}
78}
59
79
60dependencies {
80dependencies {
61− implementation(project(":shared"))
62−
63 implementation(libs.androidx.core.ktx)
81 implementation(libs.androidx.core.ktx)
64 implementation(libs.androidx.lifecycle.runtime.ktx)
82 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)
65 implementation(libs.androidx.activity.compose)
86 implementation(libs.androidx.activity.compose)
87+
66 implementation(platform(libs.androidx.compose.bom))
88 implementation(platform(libs.androidx.compose.bom))
67 implementation(libs.androidx.compose.ui)
89 implementation(libs.androidx.compose.ui)
68 implementation(libs.androidx.compose.ui.graphics)
90 implementation(libs.androidx.compose.ui.graphics)
69 implementation(libs.androidx.compose.ui.tooling.preview)
91 implementation(libs.androidx.compose.ui.tooling.preview)
70 implementation(libs.androidx.compose.material3)
92 implementation(libs.androidx.compose.material3)
71 implementation(libs.androidx.compose.material.icons.extended)
93 implementation(libs.androidx.compose.material.icons.extended)
72− implementation(libs.lifecycle.viewmodel.compose)
94+ debugImplementation(libs.androidx.compose.ui.tooling)
73− implementation(libs.androidx.navigation.compose)
74
95
75 implementation(libs.kotlinx.coroutines.android)
96 implementation(libs.kotlinx.coroutines.android)
76−
77− implementation(libs.ktor.client.core)
78− implementation(libs.ktor.client.okhttp)
97+ implementation(libs.kotlinx.serialization.json)
79− implementation(libs.ktor.client.content.negotiation)
80− implementation(libs.ktor.serialization.kotlinx.json)
81− implementation(libs.ktor.client.logging)
98+ implementation(libs.okhttp)
82− implementation(libs.ktor.client.auth)
99+ implementation(libs.okhttp.sse)
83
100
84 testImplementation(libs.junit)
101 testImplementation(libs.junit)
85 testImplementation(libs.kotlinx.coroutines.test)
102 testImplementation(libs.kotlinx.coroutines.test)
86− androidTestImplementation(libs.androidx.junit)
87− androidTestImplementation(libs.androidx.espresso.core)
88− androidTestImplementation(platform(libs.androidx.compose.bom))
89− androidTestImplementation(libs.androidx.compose.ui.test.junit4)
90− debugImplementation(libs.androidx.compose.ui.tooling)
91− debugImplementation(libs.androidx.compose.ui.test.manifest)
92}
103}
app/proguard-rules.pro+15−7
@@ -1,8 +1,16 @@
1−# Keep generated Crux types (serialization reflection)
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−-keep class soy.iko.opencode.core.** { *; }
2+-keepattributes *Annotation*, InnerClasses
3−-keep class com.novi.serde.** { *; }
3+-dontnote kotlinx.serialization.**
4−-keep class com.novi.bincode.** { *; }
5
4
6−# Ktor
5+-keepclassmembers class **$$serializer { *; }
7−-keep class io.ktor.** { *; }
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.**
8−-dontwarn io.ktor.**
16+-dontwarn org.openjsse.**
app/src/main/AndroidManifest.xml+7−7
@@ -1,26 +1,26 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3− xmlns:tools="http://schemas.android.com/tools">
4
3
5 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.INTERNET" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7
6
8 <application
7 <application
9 android:allowBackup="true"
8 android:allowBackup="true"
9+ android:icon="@android:drawable/sym_def_app_icon"
10 android:label="@string/app_name"
10 android:label="@string/app_name"
11 android:supportsRtl="true"
11 android:supportsRtl="true"
12− android:theme="@style/Theme.Opencode"
12+ android:theme="@style/Theme.OpencodeAndroid"
13− android:usesCleartextTraffic="true"
13+ android:usesCleartextTraffic="true">
14− tools:targetApi="31">
14+
15 <activity
15 <activity
16 android:name=".MainActivity"
16 android:name=".MainActivity"
17 android:exported="true"
17 android:exported="true"
18+ android:windowSoftInputMode="adjustResize"
18− android:theme="@style/Theme.Opencode">
19+ android:theme="@style/Theme.OpencodeAndroid">
19 <intent-filter>
20 <intent-filter>
20 <action android:name="android.intent.action.MAIN" />
21 <action android:name="android.intent.action.MAIN" />
21 <category android:name="android.intent.category.LAUNCHER" />
22 <category android:name="android.intent.category.LAUNCHER" />
22 </intent-filter>
23 </intent-filter>
23 </activity>
24 </activity>
24 </application>
25 </application>
25−
26</manifest>
26</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+66
@@ -0,0 +1,66 @@
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+ // The password is stored in plain text in SharedPreferences — a deliberate
27+ // convenience trade-off for a local dev tool so reconnecting is one tap.
28+ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29+
30+ // On a fresh process after a configuration change, resume the SSE stream if
31+ // the core is already connected (no-op otherwise — it guards on `connected`).
32+ core.resumeStreamingIfConnected()
33+
34+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35+ // aren't already connected, kick off the connection attempt on launch. The core
36+ // handles an empty/invalid URL, so this only fires when there's something to try.
37+ core.view.value.let { restored ->
38+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39+ core.dispatch(Event.Connect)
40+ }
41+ }
42+
43+ // Persist connection details once a connection succeeds.
44+ viewModelScope.launch {
45+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46+ if (state.connected) {
47+ prefs.edit {
48+ putString(KEY_URL, state.serverUrl)
49+ putString(KEY_USER, state.username)
50+ putString(KEY_PASSWORD, state.password)
51+ }
52+ }
53+ }
54+ }
55+ }
56+
57+ override fun onCleared() {
58+ core.shutdown()
59+ }
60+
61+ private companion object {
62+ const val KEY_URL = "serverUrl"
63+ const val KEY_USER = "username"
64+ const val KEY_PASSWORD = "password"
65+ }
66+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+12−20
@@ -2,38 +2,30 @@ package soy.iko.opencode
2
2
3import android.os.Bundle
3import android.os.Bundle
4import androidx.activity.ComponentActivity
4import androidx.activity.ComponentActivity
5+import androidx.activity.SystemBarStyle
5import androidx.activity.compose.setContent
6import androidx.activity.compose.setContent
6import androidx.activity.enableEdgeToEdge
7import androidx.activity.enableEdgeToEdge
7−import androidx.compose.runtime.Composable
8−import androidx.compose.runtime.collectAsState
9import androidx.compose.runtime.getValue
8import androidx.compose.runtime.getValue
9+import androidx.lifecycle.compose.collectAsStateWithLifecycle
10import androidx.lifecycle.viewmodel.compose.viewModel
10import androidx.lifecycle.viewmodel.compose.viewModel
11−import soy.iko.opencode.core.Core
11+import soy.iko.opencode.ui.OpencodeApp
12−import soy.iko.opencode.core.Screen
13−import soy.iko.opencode.ui.screens.ChatScreen
14−import soy.iko.opencode.ui.screens.ConnectScreen
15−import soy.iko.opencode.ui.screens.SessionsScreen
16import soy.iko.opencode.ui.theme.OpencodeTheme
12import soy.iko.opencode.ui.theme.OpencodeTheme
17
13
18class MainActivity : ComponentActivity() {
14class MainActivity : ComponentActivity() {
19 override fun onCreate(savedInstanceState: Bundle?) {
15 override fun onCreate(savedInstanceState: Bundle?) {
16+ enableEdgeToEdge(
17+ statusBarStyle = SystemBarStyle.auto(
18+ lightScrim = 0xFF0F1115.toInt(),
19+ darkScrim = 0xFF0F1115.toInt(),
20+ ),
21+ )
20 super.onCreate(savedInstanceState)
22 super.onCreate(savedInstanceState)
21− enableEdgeToEdge()
22 setContent {
23 setContent {
23 OpencodeTheme {
24 OpencodeTheme {
24− App()
25+ val vm: CoreViewModel = viewModel()
26+ val state by vm.core.view.collectAsStateWithLifecycle()
27+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
25 }
28 }
26 }
29 }
27 }
30 }
28}
31}
29−
30−@Composable
31−fun App(core: Core = viewModel()) {
32− val view by core.view.collectAsState()
33−
34− when (view.screen) {
35− Screen.CONNECT -> ConnectScreen(core)
36− Screen.SESSIONS -> SessionsScreen(core)
37− Screen.CHAT -> ChatScreen(core)
38− }
39−}
app/src/main/java/soy/iko/opencode/core/Core.ktdeleted−96
@@ -1,96 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import androidx.lifecycle.viewModelScope
4−import kotlinx.coroutines.flow.MutableStateFlow
5−import kotlinx.coroutines.flow.StateFlow
6−import kotlinx.coroutines.flow.asStateFlow
7−import kotlinx.coroutines.launch
8−
9−/**
10− * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
11− * Jetpack Compose UI.
12− *
13− * - Sends [Event]s to the core via [update]
14− * - Processes [Effect]s returned by the core (Render, Http)
15− * - Exposes the current [ViewModel] as a [StateFlow] for Compose to observe
16− */
17−class Core : androidx.lifecycle.ViewModel() {
18− private val ffi: CoreFfi = CoreFfi.create()
19− private val httpClient = HttpClient()
20− private val sseClient = SseClient()
21−
22− private val _view = MutableStateFlow(getInitialView())
23− val view: StateFlow<ViewModel> = _view.asStateFlow()
24−
25− init {
26− // Emit Start event to initialize the model
27− update(Event.Start)
28−
29− // Subscribe to SSE events
30− viewModelScope.launch {
31− sseClient.events.collect { data ->
32− update(Event.EventReceived(data))
33− }
34− }
35− }
36−
37− fun update(event: Event) {
38− val effectsBytes = ffi.update(event.bincodeSerialize())
39− val requests = Requests.bincodeDeserialize(effectsBytes).value
40− for (request in requests) {
41− processEffect(request)
42− }
43− }
44−
45− private fun processEffect(request: Request) {
46− when (val effect = request.effect) {
47− is Effect.Render -> {
48− _view.value = ViewModel.bincodeDeserialize(ffi.view())
49− }
50− is Effect.Http -> {
51− handleHttpEffect(effect.value, request.id)
52− }
53− }
54− }
55−
56− private fun handleHttpEffect(request: HttpRequest, requestId: UInt) {
57− viewModelScope.launch {
58− val result = httpClient.request(request)
59− val effectsBytes = ffi.resolve(requestId, result.bincodeSerialize())
60− val requests = Requests.bincodeDeserialize(effectsBytes).value
61− for (req in requests) {
62− processEffect(req)
63− }
64− // Refresh the view after resolving
65− _view.value = ViewModel.bincodeDeserialize(ffi.view())
66−
67− // Connect SSE when we have a server URL and are connected
68− val currentView = _view.value
69− if (currentView.connected && currentView.currentSessionId != null) {
70− sseClient.connect("${currentView.serverUrl}/event")
71− }
72− }
73− }
74−
75− private fun getInitialView(): ViewModel {
76− return try {
77− ViewModel.bincodeDeserialize(ffi.view())
78− } catch (e: Throwable) {
79− ViewModel(
80− screen = Screen.CONNECT,
81− serverUrl = "http://localhost:4096",
82− connected = false,
83− loading = false,
84− error = null,
85− sessions = emptyList(),
86− currentSessionId = null,
87− messages = emptyList(),
88− draftMessage = "",
89− )
90− }
91− }
92−
93− override fun onCleared() {
94− sseClient.disconnect()
95− }
96−}
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+60−59
@@ -1,74 +1,75 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
2
2
3−import com.novi.serde.Bytes
4−import io.ktor.client.HttpClient as KtorHttpClient
5−import io.ktor.client.call.body
6−import io.ktor.client.engine.okhttp.OkHttp
7−import io.ktor.client.plugins.HttpTimeout
8−import io.ktor.client.request.headers
9−import io.ktor.client.request.request
10−import io.ktor.client.request.setBody
11−import io.ktor.client.statement.bodyAsText
12−import io.ktor.http.HttpMethod
13−import io.ktor.util.flattenEntries
14import kotlinx.coroutines.Dispatchers
3import kotlinx.coroutines.Dispatchers
4+import kotlinx.coroutines.suspendCancellableCoroutine
15import kotlinx.coroutines.withContext
5import kotlinx.coroutines.withContext
16−import java.net.SocketTimeoutException
6+import okhttp3.Call
17−import java.net.UnknownHostException
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
18−import kotlin.coroutines.cancellation.CancellationException
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)
19
20
20/**
21/**
21− * Executes HTTP requests on behalf of the Crux core.
22+ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
22− *
23+ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
23− * When the core emits an [Effect.Http], the shell calls [request] with the
24+ * carrying its status code, matching the Rust core's `HttpResult` handling.
24− * [HttpRequest] and returns the [HttpResult] back to the core via [Core.resolve].
25 */
25 */
26−class HttpClient {
26+class HttpClient(
27− private val ktorHttpClient = KtorHttpClient(OkHttp) {
27+ val okhttp: OkHttpClient = OkHttpClient.Builder()
28− install(HttpTimeout) {
28+ .connectTimeout(15, TimeUnit.SECONDS)
29− requestTimeoutMillis = 30_000
29+ .readTimeout(30, TimeUnit.SECONDS)
30− connectTimeoutMillis = 15_000
30+ .build(),
31− socketTimeoutMillis = 15_000
31+) {
32− }
32+ private val jsonMedia = "application/json".toMediaType()
33− }
33+
34+ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35+ exec(url, auth) { get() }
36+
37+ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38+ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39+
40+ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41+ exec(url, auth) { delete() }
34
42
43+ // Build and run the request off the main thread. Building it — parsing [url]
44+ // in particular — can throw (e.g. a URL with no scheme), so that happens
45+ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46+ // instead of crashing the caller's coroutine.
47+ private suspend fun exec(
48+ url: String,
49+ auth: String?,
50+ method: Request.Builder.() -> Request.Builder,
35− suspend fun request(request: HttpRequest): HttpResult = withContext(Dispatchers.IO) {
51+ ): Result<HttpResp> = withContext(Dispatchers.IO) {
36− try {
52+ runCatching {
37− val response = executeRequest(request)
53+ val builder = Request.Builder().url(url).method()
38− HttpResult.Ok(response)
54+ builder.header("User-Agent", "opencode-android/1.0")
39− } catch (ce: CancellationException) {
55+ if (auth != null) builder.header("Authorization", auth)
40− throw ce
56+ await(okhttp.newCall(builder.build()))
41− } catch (e: Throwable) {
42− HttpResult.Err(toHttpError(e))
43 }
57 }
44 }
58 }
45
59
46− private suspend fun executeRequest(request: HttpRequest): HttpResponse {
60+ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
47− val response = ktorHttpClient.request(request.url) {
61+ cont.invokeOnCancellation { runCatching { call.cancel() } }
48− this.method = HttpMethod.parse(request.method)
62+ call.enqueue(object : Callback {
49− this.headers {
63+ override fun onFailure(call: Call, e: IOException) {
50− for (header in request.headers) {
64+ if (cont.isActive) cont.resumeWithException(e)
51− append(header.name, header.value)
52− }
53− }
54− if (request.body.content.isNotEmpty()) {
55− setBody(request.body.content)
56 }
65 }
57− }
58− val bytes: ByteArray = response.body()
59− val headers = response.headers
60− .flattenEntries()
61− .map { HttpHeader(it.first, it.second) }
62− return HttpResponse(
63− status = response.status.value.toUShort(),
64− headers = headers,
65− body = Bytes(bytes),
66− )
67− }
68
66
69− private fun toHttpError(error: Throwable): HttpError = when (error) {
67+ override fun onResponse(call: Call, response: Response) {
70− is SocketTimeoutException -> HttpError.Timeout
68+ response.use {
71− is UnknownHostException -> HttpError.Io("Unknown host")
69+ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
72− else -> HttpError.Io(error.message ?: "HTTP request failed")
70+ if (cont.isActive) cont.resume(HttpResp(it.code, body))
71+ }
72+ }
73+ })
73 }
74 }
74}
75}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktnew file+668
@@ -0,0 +1,668 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
5+import kotlinx.coroutines.delay
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import java.util.Base64
11+
12+/**
13+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14+ *
15+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16+ * observable [view]. Effects are executed here in the shell: REST calls through
17+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18+ * server-sent event is fed back in and applied incrementally, so assistant
19+ * replies stream in token-by-token.
20+ *
21+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22+ * network I/O hops to a background thread inside the clients.
23+ */
24+class OpencodeCore(
25+ private val scope: CoroutineScope,
26+ private val http: HttpClient = HttpClient(),
27+) {
28+ // ── Model ────────────────────────────────────────────────────────────────
29+ private var serverUrl = ""
30+ private var username = ""
31+ private var password = ""
32+ private var authRequired = 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 sseConnected = true
44+ private var localSeq = 0L
45+
46+ private val _view = MutableStateFlow(snapshot())
47+ val view: StateFlow<UiState> = _view.asStateFlow()
48+
49+ private var sse: SseClient? = null
50+ private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
53+
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
77+
78+ // ── Event entry point ──────────────────────────────────────────────────────
79+ fun dispatch(event: Event) {
80+ when (event) {
81+ // Store the field verbatim while typing; normalizing here would fight
82+ // the user's edits (e.g. eat the "://" as they type it). The URL is
83+ // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89+
90+ Event.Connect -> {
91+ serverUrl = normalizeUrl(serverUrl)
92+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
98+ loading = true; error = null; emit()
99+ connectJob?.cancel()
100+ connectJob = scope.launch { probeHealth() }
101+ }
102+
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
107+ loadSessionsJob = scope.launch { loadSessions() }
108+ }
109+
110+ is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
116+ currentSessionId = event.id
117+ messages.clear()
118+ removedMessageIds.clear()
119+ screen = Screen.Chat
120+ loading = true
121+ generating = false
122+ error = null
123+ emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
130+ }
131+
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
136+ createSessionJob = scope.launch { createSession() }
137+ }
138+
139+ is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
141+ val id = event.id
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
150+ sessions.removeAll { it.id == id }
151+ if (isCurrent(id)) {
152+ currentSessionId = null
153+ messages.clear()
154+ screen = Screen.Sessions
155+ }
156+ } else {
157+ error = "Failed to delete session"
158+ }
159+ loading = false
160+ },
161+ onFailure = {
162+ error = "Failed to delete session"
163+ loading = false
164+ },
165+ )
166+ emit()
167+ }
168+ }
169+
170+ is Event.DraftChanged -> { draft = event.text; emit() }
171+
172+ is Event.SendMessage -> sendMessage(event.text)
173+
174+ Event.CancelGeneration -> {
175+ generating = false
176+ messages.forEach { it.streaming = false }
177+ emit()
178+ val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
181+ abortJob?.cancel()
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183+ }
184+
185+ Event.NavigateToSessions -> {
186+ screen = Screen.Sessions
187+ currentSessionId = null
188+ messages.clear()
189+ removedMessageIds.clear()
190+ draft = ""
191+ generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
195+ loading = true
196+ emit()
197+ connectJob?.cancel()
198+ abortJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
204+ loadSessionsJob = scope.launch { loadSessions() }
205+ }
206+
207+ Event.NavigateToConnect -> {
208+ screen = Screen.Connect
209+ connected = false
210+ loading = false
211+ generating = false
212+ sessions.clear()
213+ messages.clear()
214+ removedMessageIds.clear()
215+ currentSessionId = null
216+ draft = ""
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
219+ sseConnected = true
220+ connectJob?.cancel()
221+ abortJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
226+ closeSse()
227+ emit()
228+ }
229+
230+ Event.DismissError -> { error = null; emit() }
231+ }
232+ }
233+
234+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235+ fun resumeStreamingIfConnected() {
236+ if (connected && sse == null) startSse()
237+ }
238+
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
248+ abortJob?.cancel()
249+ closeSse()
250+ }
251+
252+ // ── Effects ────────────────────────────────────────────────────────────────
253+ private fun sendMessage(raw: String) {
254+ val text = raw.trim()
255+ val session = currentSessionId ?: return
256+ if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
260+ localSeq += 1
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262+ status = MessageStatus.Pending
263+ textParts["local"] = text
264+ }
265+ messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
269+ draft = ""
270+ generating = true
271+ error = null
272+ emit()
273+
274+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
276+ sendMessageJob = scope.launch {
277+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278+ result.fold(
279+ onSuccess = { resp ->
280+ if (resp.code in 200..299) {
281+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282+ } else {
283+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284+ generating = false
285+ messages.forEach { it.streaming = false }
286+ draft = savedDraft
287+ error = "Server returned status ${resp.code}"
288+ }
289+ },
290+ onFailure = { e ->
291+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292+ generating = false
293+ messages.forEach { it.streaming = false }
294+ draft = savedDraft
295+ error = "Request failed: ${e.message}"
296+ },
297+ )
298+ emit()
299+ }
300+ }
301+
302+ private suspend fun probeHealth() {
303+ val hasCreds = hasCredentials()
304+ http.get("$serverUrl/global/health", authHeader()).fold(
305+ onSuccess = { resp ->
306+ when {
307+ resp.code == 401 -> {
308+ loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
312+ connected = false
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
315+ authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
317+ emit()
318+ }
319+ resp.code in 200..299 -> {
320+ connected = true
321+ authRequired = false
322+ error = null
323+ screen = Screen.Sessions
324+ loading = true
325+ emit()
326+ startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
330+ loadSessionsJob = scope.launch { loadSessions() }
331+ }
332+ else -> {
333+ loading = false
334+ connected = false
335+ error = "Server returned status ${resp.code}"
336+ emit()
337+ }
338+ }
339+ },
340+ onFailure = { e ->
341+ loading = false
342+ connected = false
343+ error = "Connection failed: ${e.message}"
344+ emit()
345+ },
346+ )
347+ }
348+
349+ private suspend fun loadSessions() {
350+ http.get("$serverUrl/session", authHeader()).fold(
351+ onSuccess = { resp ->
352+ if (resp.code in 200..299) {
353+ sessions.clear()
354+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
358+ } else {
359+ error = "Server returned status ${resp.code}"
360+ }
361+ },
362+ onFailure = { e -> error = "Request failed: ${e.message}" },
363+ )
364+ loading = false
365+ emit()
366+ }
367+
368+ private suspend fun createSession() {
369+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370+ onSuccess = { resp ->
371+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372+ if (session != null) {
373+ if (sessions.none { it.id == session.id }) {
374+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
379+ }
380+ emit()
381+ // SelectSession takes over the loading state (loads messages).
382+ dispatch(Event.SelectSession(session.id))
383+ return
384+ }
385+ error = "Server returned status ${resp.code}"
386+ },
387+ onFailure = { e -> error = "Request failed: ${e.message}" },
388+ )
389+ loading = false
390+ emit()
391+ }
392+
393+ private suspend fun loadMessages(sessionId: String) {
394+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395+ onSuccess = { resp ->
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397+ loading = false
398+ if (resp.code in 200..299) {
399+ messages.clear()
400+ removedMessageIds.clear()
401+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402+ generating = messages.any { it.streaming }
403+ } else {
404+ error = "Server returned status ${resp.code}"
405+ }
406+ emit()
407+ },
408+ onFailure = { e ->
409+ if (!isCurrent(sessionId)) { loading = false; return }
410+ loading = false
411+ generating = false
412+ error = "Request failed: ${e.message}"
413+ emit()
414+ },
415+ )
416+ }
417+
418+ // ── SSE ─────────────────────────────────────────────────────────────────────
419+ private fun startSse() {
420+ closeSse()
421+ sseClosedIntentionally = false
422+ val client = SseClient(
423+ url = "$serverUrl/event",
424+ auth = authHeader(),
425+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426+ onStateChange = { open ->
427+ scope.launch {
428+ sseConnected = open
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
430+ emit()
431+ }
432+ },
433+ )
434+ sse = client
435+ client.connect()
436+ }
437+
438+ private fun closeSse() {
439+ sseClosedIntentionally = true
440+ reconnectJob?.cancel()
441+ reconnectJob = null
442+ sse?.close()
443+ sse = null
444+ }
445+
446+ private fun scheduleReconnect() {
447+ if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
455+ reconnectJob = scope.launch {
456+ delay(delayMs)
457+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458+ }
459+ }
460+
461+ private fun handleServerEvent(json: String) {
462+ val event = Protocol.parseEvent(json) ?: return
463+ // Only re-render if the event actually changed our state; applyEvent returns
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
466+ }
467+
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
469+ private fun applyEvent(event: Protocol.Event): Boolean {
470+ when (event) {
471+ is Protocol.Event.MessageUpdated -> {
472+ val info = event.info
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
477+ val streaming = info.time.completed == null && info.error == null
478+ val existing = messages.firstOrNull { it.id == info.id }
479+ if (existing != null) {
480+ existing.streaming = streaming
481+ if (existing.time == 0L) existing.time = info.time.created
482+ } else {
483+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484+ }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486+ return true
487+ }
488+
489+ is Protocol.Event.PartUpdated -> {
490+ val part = event.part
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
494+ return when (part) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
496+ false
497+ } else {
498+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499+ true
500+ }
501+ is Protocol.Part.Reasoning -> {
502+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503+ true
504+ }
505+ is Protocol.Part.Tool -> {
506+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507+ true
508+ }
509+ }
510+ }
511+
512+ is Protocol.Event.PartRemoved -> {
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
515+ return true
516+ }
517+
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
521+ return messages.removeAll { it.id == event.messageId }
522+ }
523+
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
526+ generating = false
527+ messages.forEach { it.streaming = false }
528+ return true
529+ }
530+
531+ is Protocol.Event.SessionError -> {
532+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533+ if (!scoped) return false
534+ event.error?.let { error = it.message() }
535+ generating = false
536+ messages.forEach { it.streaming = false }
537+ return true
538+ }
539+
540+ is Protocol.Event.SessionUpserted -> {
541+ val title = displayTitle(event.info.title)
542+ val existing = sessions.firstOrNull { it.id == event.info.id }
543+ if (existing != null) {
544+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545+ } else {
546+ sessions.add(0, SessionView(event.info.id, title))
547+ }
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
551+ if (isCurrent(event.info.id)) currentSessionTitle = title
552+ return true
553+ }
554+
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
557+ val removed = sessions.removeAll { it.id == id }
558+ if (isCurrent(id)) {
559+ currentSessionId = null
560+ messages.clear()
561+ screen = Screen.Sessions
562+ return true
563+ }
564+ return removed
565+ }
566+
567+ Protocol.Event.Other -> return false
568+ }
569+ }
570+
571+ // ── View / helpers ──────────────────────────────────────────────────────────
572+ private fun snapshot() = UiState(
573+ screen = screen,
574+ serverUrl = serverUrl,
575+ username = username,
576+ password = password,
577+ authRequired = authRequired,
578+ connected = connected,
579+ loading = loading,
580+ error = error,
581+ sessions = sessions.toList(),
582+ currentSessionId = currentSessionId,
583+ currentSessionTitle = currentSessionTitle,
584+ messages = messages.map { it.toView() },
585+ draft = draft,
586+ generating = generating,
587+ sseConnected = sseConnected,
588+ )
589+
590+ private fun emit() { _view.value = snapshot() }
591+
592+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594+
595+ private fun authHeader(): String? {
596+ if (!hasCredentials()) return null
597+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598+ return "Basic $encoded"
599+ }
600+}
601+
602+// ── Internal message state (parts tracked by id for streaming) ─────────────────
603+private class MsgState(val id: String, val role: String, var time: Long) {
604+ var status = MessageStatus.Sent
605+ var streaming = false
606+ val textParts = LinkedHashMap<String, String>()
607+ val reasoningParts = LinkedHashMap<String, String>()
608+ val toolParts = LinkedHashMap<String, ToolView>()
609+
610+ fun remove(partId: String) {
611+ textParts.remove(partId)
612+ reasoningParts.remove(partId)
613+ toolParts.remove(partId)
614+ }
615+
616+ fun toView(): MessageView {
617+ val reasoning = reasoningParts.values.joinToString("")
618+ return MessageView(
619+ id = id,
620+ role = role,
621+ text = textParts.values.joinToString(""),
622+ reasoning = reasoning.ifEmpty { null },
623+ tools = toolParts.values.toList(),
624+ time = time,
625+ status = status,
626+ streaming = streaming,
627+ )
628+ }
629+}
630+
631+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632+ val info = env.info
633+ val msg = MsgState(info.id, info.role, info.time.created)
634+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635+ for (raw in env.parts) {
636+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637+ when (val part = Protocol.parsePart(obj)) {
638+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641+ null -> Unit
642+ }
643+ }
644+ return msg
645+}
646+
647+private fun pickText(text: String, delta: String?, prior: String?): String =
648+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649+
650+private fun normalizeUrl(url: String): String {
651+ val trimmed = url.trim().trimEnd('/')
652+ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653+ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654+ return when {
655+ trimmed.isEmpty() -> ""
656+ "://" in trimmed -> trimmed
657+ else -> "http://$trimmed"
658+ }
659+}
660+
661+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662+
663+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664+@kotlinx.serialization.Serializable
665+private data class PromptBody(val parts: List<TextInput>)
666+
667+@kotlinx.serialization.Serializable
668+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−347
@@ -1,347 +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−internal class PureCoreFfi : CoreFfi {
13− private val model = PureModel()
14−
15− override fun update(data: ByteArray): ByteArray {
16− val event = Event.bincodeDeserialize(data)
17− val effects = model.handleEvent(event)
18− return effects.bincodeSerialize()
19− }
20−
21− override fun resolve(id: UInt, data: ByteArray): ByteArray {
22− val result = HttpResult.bincodeDeserialize(data)
23− val effects = model.handleResult(id, result)
24− return effects.bincodeSerialize()
25− }
26−
27− override fun view(): ByteArray {
28− return model.currentView().bincodeSerialize()
29− }
30−}
31−
32−internal data class PureModel(
33− var serverUrl: String = "http://localhost:4096",
34− var connected: Boolean = false,
35− var loading: Boolean = false,
36− var error: String? = null,
37− var sessions: MutableList<PureSession> = mutableListOf(),
38− var currentSessionId: String? = null,
39− var messages: MutableList<PureMessage> = mutableListOf(),
40− var draftMessage: String = "",
41− var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
42− var nextId: UInt = 0u,
43−) {
44− fun handleEvent(event: Event): Requests {
45− val requests = mutableListOf<Request>()
46− when (event) {
47− is Event.Start -> {
48− serverUrl = "http://localhost:4096"
49− }
50− is Event.ServerUrlChanged -> {
51− serverUrl = event.value
52− }
53− is Event.Connect -> {
54− loading = true
55− error = null
56− val id = nextId++
57− pendingRequests[id] = PendingRequest.Connect
58− requests.add(
59− Request(id, Effect.Http(makeGetRequest("${serverUrl}/global/health"))),
60− )
61− }
62− is Event.LoadSessions -> {
63− loading = true
64− val id = nextId++
65− pendingRequests[id] = PendingRequest.LoadSessions
66− requests.add(
67− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session"))),
68− )
69− }
70− is Event.SelectSession -> {
71− currentSessionId = event.value
72− messages.clear()
73− val id = nextId++
74− pendingRequests[id] = PendingRequest.LoadMessages(event.value)
75− requests.add(
76− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
77− )
78− }
79− is Event.CreateSession -> {
80− loading = true
81− val id = nextId++
82− pendingRequests[id] = PendingRequest.CreateSession
83− requests.add(
84− Request(id, Effect.Http(makePostRequest("${serverUrl}/session", ByteArray(0))),
85− ))
86− }
87− is Event.LoadMessages -> {
88− loading = true
89− val id = nextId++
90− pendingRequests[id] = PendingRequest.LoadMessages(event.value)
91− requests.add(
92− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
93− )
94− }
95− is Event.SendMessage -> {
96− val sessionId = currentSessionId ?: return Requests(requests)
97− draftMessage = ""
98− val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
99− .toByteArray()
100− val id = nextId++
101− pendingRequests[id] = PendingRequest.SendMessage(sessionId)
102− requests.add(
103− Request(
104− id,
105− Effect.Http(
106− makePostRequest("${serverUrl}/session/$sessionId/prompt_async", body)
107− .copy(headers = listOf(HttpHeader("Content-Type", "application/json"))),
108− ),
109− ),
110− )
111− }
112− is Event.EventReceived -> {
113− val sid = currentSessionId
114− if (sid != null) {
115− val id = nextId++
116− pendingRequests[id] = PendingRequest.LoadMessages(sid)
117− requests.add(
118− Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
119− )
120− }
121− }
122− is Event.NavigateToChat -> {
123− currentSessionId = event.value
124− messages.clear()
125− }
126− is Event.NavigateToSessions -> {
127− currentSessionId = null
128− messages.clear()
129− }
130− is Event.DismissError -> {
131− error = null
132− }
133− else -> {}
134− }
135− return Requests(requests)
136− }
137−
138− fun handleResult(id: UInt, result: HttpResult): Requests {
139− val requests = mutableListOf<Request>()
140− val pending = pendingRequests.remove(id) ?: return Requests(requests)
141−
142− when (pending) {
143− is PendingRequest.Connect -> {
144− when (result) {
145− is HttpResult.Ok -> {
146− connected = true
147− loading = false
148− // Trigger LoadSessions
149− val newId = nextId++
150− pendingRequests[newId] = PendingRequest.LoadSessions
151− requests.add(
152− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
153− )
154− }
155− is HttpResult.Err -> {
156− loading = false
157− error = "Connection failed: ${result.value}"
158− }
159− }
160− }
161− is PendingRequest.LoadSessions -> {
162− loading = false
163− when (result) {
164− is HttpResult.Ok -> {
165− val body = result.value.body.content.decodeToString()
166− sessions = parseSessions(body).toMutableList()
167− connected = true
168− }
169− is HttpResult.Err -> {
170− error = "Failed to load sessions: ${result.value}"
171− }
172− }
173− }
174− is PendingRequest.CreateSession -> {
175− loading = false
176− when (result) {
177− is HttpResult.Ok -> {
178− val body = result.value.body.content.decodeToString()
179− val sid = extractJsonString(body, "id") ?: ""
180− if (sid.isNotEmpty()) {
181− currentSessionId = sid
182− messages.clear()
183− val newId = nextId++
184− pendingRequests[newId] = PendingRequest.LoadMessages(sid)
185− requests.add(
186− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
187− )
188− }
189− }
190− is HttpResult.Err -> {
191− error = "Failed to create session: ${result.value}"
192− }
193− }
194− }
195− is PendingRequest.LoadMessages -> {
196− loading = false
197− when (result) {
198− is HttpResult.Ok -> {
199− val body = result.value.body.content.decodeToString()
200− messages = parseMessages(body).toMutableList()
201− }
202− is HttpResult.Err -> {
203− error = "Failed to load messages: ${result.value}"
204− }
205− }
206− }
207− is PendingRequest.SendMessage -> {
208− when (result) {
209− is HttpResult.Ok -> {
210− val sid = pending.sessionId
211− val newId = nextId++
212− pendingRequests[newId] = PendingRequest.LoadMessages(sid)
213− requests.add(
214− Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
215− )
216− }
217− is HttpResult.Err -> {
218− error = "Failed to send message: ${result.value}"
219− }
220− }
221− }
222− }
223− return Requests(requests)
224− }
225−
226− fun currentView(): ViewModel {
227− val screen = when {
228− currentSessionId != null -> Screen.CHAT
229− connected -> Screen.SESSIONS
230− else -> Screen.CONNECT
231− }
232− return ViewModel(
233− screen = screen,
234− serverUrl = serverUrl,
235− connected = connected,
236− loading = loading,
237− error = error,
238− sessions = sessions.map { SessionView(it.id, it.title) },
239− currentSessionId = currentSessionId,
240− messages = messages.map { MessageView(it.id, it.role, it.text, it.time) },
241− draftMessage = draftMessage,
242− )
243− }
244−
245− private fun makeGetRequest(url: String): HttpRequest =
246− HttpRequest(
247− method = "GET",
248− url = url,
249− headers = emptyList(),
250− body = Bytes(ByteArray(0)),
251− )
252−
253− private fun makePostRequest(url: String, body: ByteArray): HttpRequest =
254− HttpRequest(
255− method = "POST",
256− url = url,
257− headers = emptyList(),
258− body = Bytes(body),
259− )
260−}
261−
262−internal sealed class PendingRequest {
263− data object Connect : PendingRequest()
264− data object LoadSessions : PendingRequest()
265− data object CreateSession : PendingRequest()
266− data class LoadMessages(val sessionId: String) : PendingRequest()
267− data class SendMessage(val sessionId: String) : PendingRequest()
268−}
269−
270−internal data class PureSession(val id: String, val title: String)
271−
272−internal data class PureMessage(val id: String, val role: String, val text: String, val time: ULong)
273−
274−internal fun parseSessions(json: String): List<PureSession> {
275− val sessions = mutableListOf<PureSession>()
276− var depth = 0
277− var start = 0
278− for (i in json.indices) {
279− when (json[i]) {
280− '{' -> {
281− if (depth == 0) start = i
282− depth++
283− }
284− '}' -> {
285− depth--
286− if (depth == 0) {
287− val obj = json.substring(start, i + 1)
288− val id = extractJsonString(obj, "id") ?: ""
289− val title = extractJsonString(obj, "title") ?: ""
290− if (id.isNotEmpty()) {
291− sessions.add(PureSession(id, if (title.isNotEmpty()) title else "Untitled"))
292− }
293− }
294− }
295− }
296− }
297− return sessions
298−}
299−
300−internal fun parseMessages(json: String): List<PureMessage> {
301− val messages = mutableListOf<PureMessage>()
302− var depth = 0
303− var start = 0
304− for (i in json.indices) {
305− when (json[i]) {
306− '{' -> {
307− if (depth == 0) start = i
308− depth++
309− }
310− '}' -> {
311− depth--
312− if (depth == 0) {
313− val obj = json.substring(start, i + 1)
314− val id = extractJsonString(obj, "id") ?: ""
315− val role = extractJsonString(obj, "role") ?: ""
316− val time = extractJsonNumber(obj, "created") ?: 0uL
317− if (id.isNotEmpty() && role.isNotEmpty()) {
318− messages.add(PureMessage(id, role, "", time))
319− }
320− }
321− }
322− }
323− }
324− return messages
325−}
326−
327−internal fun extractJsonString(json: String, key: String): String? {
328− val pattern = "\"$key\""
329− val idx = json.indexOf(pattern) ?: return null
330− val rest = json.substring(idx + pattern.length)
331− val colon = rest.indexOf(':') ?: return null
332− val rest2 = rest.substring(colon + 1)
333− val quote = rest2.indexOf('"') ?: return null
334− val rest3 = rest2.substring(quote + 1)
335− val end = rest3.indexOf('"') ?: return null
336− return rest3.substring(0, end)
337−}
338−
339−internal fun extractJsonNumber(json: String, key: String): ULong? {
340− val pattern = "\"$key\""
341− val idx = json.indexOf(pattern) ?: return null
342− val rest = json.substring(idx + pattern.length)
343− val colon = rest.indexOf(':') ?: return null
344− val rest2 = rest.substring(colon + 1).trimStart()
345− val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it == -1) rest2.length else it }
346− return rest2.substring(0, end).toULongOrNull()
347−}
app/src/main/java/soy/iko/opencode/core/SseClient.kt+49−52
@@ -1,66 +1,63 @@
1package soy.iko.opencode.core
1package soy.iko.opencode.core
2
2
3−import io.ktor.client.HttpClient
3+import okhttp3.OkHttpClient
4−import io.ktor.client.engine.okhttp.OkHttp
4+import okhttp3.Request
5−import io.ktor.client.plugins.HttpTimeout
5+import okhttp3.Response
6−import io.ktor.client.request.prepareGet
6+import okhttp3.sse.EventSource
7−import io.ktor.client.statement.bodyAsChannel
7+import okhttp3.sse.EventSourceListener
8−import io.ktor.utils.io.readUTF8Line
8+import okhttp3.sse.EventSources
9−import kotlinx.coroutines.CoroutineScope
9+import java.util.concurrent.TimeUnit
10−import kotlinx.coroutines.Dispatchers
11−import kotlinx.coroutines.Job
12−import kotlinx.coroutines.SupervisorJob
13−import kotlinx.coroutines.flow.MutableSharedFlow
14−import kotlinx.coroutines.flow.SharedFlow
15−import kotlinx.coroutines.flow.asSharedFlow
16−import kotlinx.coroutines.launch
17
10
18/**
11/**
19− * Subscribes to the opencode server's SSE event stream.
12+ * Long-lived subscription to the opencode `/event` SSE stream.
20 *
13 *
21− * Events are emitted as raw strings on [events]. The Core converts them into
14+ * Crux only models request/response HTTP, so the streaming connection lives here
22− * [Event.EventReceived] which triggers a message reload.
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].
23 */
18 */
24−class SseClient {
19+class SseClient(
25− private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
20+ private val url: String,
26− private val httpClient = HttpClient(OkHttp) {
21+ private val auth: String?,
27− install(HttpTimeout) {
22+ private val onEvent: (String) -> Unit,
28− requestTimeoutMillis = Long.MAX_VALUE
23+ private val onStateChange: (Boolean) -> Unit,
29− connectTimeoutMillis = 15_000
24+) {
30− socketTimeoutMillis = Long.MAX_VALUE
25+ // A dedicated client with no read timeout — the stream is meant to stay open.
31− }
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+ builder.header("User-Agent", "opencode-android/1.0")
37+ if (auth != null) builder.header("Authorization", auth)
38+ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
32 }
39 }
33
40
34− private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
41+ fun close() {
35− val events: SharedFlow<String> = _events.asSharedFlow()
42+ source?.cancel()
43+ source = null
44+ }
36
45
37− private var currentJob: Job? = null
46+ private val listener = object : EventSourceListener() {
47+ override fun onOpen(eventSource: EventSource, response: Response) {
48+ onStateChange(true)
49+ }
38
50
39− fun connect(url: String) {
51+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
40− currentJob?.cancel()
52+ if (data.isNotBlank()) onEvent(data)
41− currentJob = scope.launch {
42− try {
43− httpClient.prepareGet(url).execute { response ->
44− val channel = response.bodyAsChannel()
45− val dataBuilder = StringBuilder()
46− while (!channel.isClosedForRead) {
47− val line = channel.readUTF8Line() ?: break
48− if (line.startsWith("data: ")) {
49− dataBuilder.append(line.substring(6))
50− } else if (line.isEmpty() && dataBuilder.isNotEmpty()) {
51− _events.emit(dataBuilder.toString())
52− dataBuilder.clear()
53− }
53+ }
54− }
54+
55− }
55+ override fun onClosed(eventSource: EventSource) {
56− } catch (_: Throwable) {
56+ onStateChange(false)
57− // Connection lost — the Core will retry on next event cycle
58− }
59 }
57 }
60− }
61
58
62− fun disconnect() {
59+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
63− currentJob?.cancel()
60+ onStateChange(false)
64− currentJob = null
61+ }
65 }
62 }
66}
63}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktnew file+77
@@ -0,0 +1,77 @@
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+ val sseConnected: Boolean = true,
52+)
53+
54+/**
55+ * Everything the shell can ask the core to do. Internal HTTP results are not
56+ * events here (unlike the Rust core, which routes them through `then_send`);
57+ * the Kotlin driver awaits them inline in coroutines instead.
58+ */
59+sealed interface Event {
60+ data class ServerUrlChanged(val url: String) : Event
61+ data class UsernameChanged(val value: String) : Event
62+ data class PasswordChanged(val value: String) : Event
63+ data object Connect : Event
64+
65+ data object LoadSessions : Event
66+ data class SelectSession(val id: String) : Event
67+ data object CreateSession : Event
68+ data class DeleteSession(val id: String) : Event
69+
70+ data class DraftChanged(val text: String) : Event
71+ data class SendMessage(val text: String) : Event
72+ data object CancelGeneration : Event
73+
74+ data object NavigateToSessions : Event
75+ data object NavigateToConnect : Event
76+ data object DismissError : Event
77+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.ktnew file+124
@@ -0,0 +1,124 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedContent
5+import androidx.compose.animation.slideInHorizontally
6+import androidx.compose.animation.slideOutHorizontally
7+import androidx.compose.animation.togetherWith
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
13+import androidx.compose.material3.SnackbarHost
14+import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.LaunchedEffect
19+import androidx.compose.runtime.getValue
20+import androidx.compose.runtime.mutableStateOf
21+import androidx.compose.runtime.remember
22+import androidx.compose.runtime.setValue
23+import androidx.compose.ui.Modifier
24+import soy.iko.opencode.core.Event
25+import soy.iko.opencode.core.Screen
26+import soy.iko.opencode.core.UiState
27+import soy.iko.opencode.ui.screens.ChatScreen
28+import soy.iko.opencode.ui.screens.ConnectScreen
29+import soy.iko.opencode.ui.screens.SessionsScreen
30+
31+@Composable
32+fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
33+ val snackbar = remember { SnackbarHostState() }
34+ var showStopConfirm by remember { mutableStateOf(false) }
35+ var showDisconnectConfirm by remember { mutableStateOf(false) }
36+
37+ LaunchedEffect(state.error) {
38+ state.error?.let {
39+ snackbar.showSnackbar(
40+ message = it,
41+ actionLabel = "Dismiss",
42+ duration = SnackbarDuration.Short,
43+ )
44+ // Always clear the error from state after showing.
45+ dispatch(Event.DismissError)
46+ }
47+ }
48+
49+ BackHandler(enabled = state.screen != Screen.Connect) {
50+ when (state.screen) {
51+ // Leaving mid-generation would silently drop the reply, so confirm first.
52+ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
53+ // Disconnecting drops the server session, so guard the back button.
54+ Screen.Sessions -> showDisconnectConfirm = true
55+ Screen.Connect -> Unit
56+ }
57+ }
58+
59+ if (showStopConfirm) {
60+ AlertDialog(
61+ onDismissRequest = { showStopConfirm = false },
62+ title = { Text("Stop generation?") },
63+ text = { Text("Leaving will stop the current generation.") },
64+ confirmButton = {
65+ TextButton(onClick = {
66+ showStopConfirm = false
67+ dispatch(Event.CancelGeneration)
68+ dispatch(Event.NavigateToSessions)
69+ }) {
70+ Text("Stop and leave")
71+ }
72+ },
73+ dismissButton = {
74+ TextButton(onClick = { showStopConfirm = false }) {
75+ Text("Stay")
76+ }
77+ },
78+ )
79+ }
80+
81+ if (showDisconnectConfirm) {
82+ AlertDialog(
83+ onDismissRequest = { showDisconnectConfirm = false },
84+ title = { Text("Disconnect?") },
85+ text = { Text("You will be disconnected from the server.") },
86+ confirmButton = {
87+ TextButton(onClick = {
88+ showDisconnectConfirm = false
89+ dispatch(Event.NavigateToConnect)
90+ }) {
91+ Text("Disconnect")
92+ }
93+ },
94+ dismissButton = {
95+ TextButton(onClick = { showDisconnectConfirm = false }) {
96+ Text("Cancel")
97+ }
98+ },
99+ )
100+ }
101+
102+ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
103+ AnimatedContent(
104+ targetState = state.screen,
105+ modifier = Modifier.fillMaxSize().padding(padding),
106+ transitionSpec = {
107+ // Compare screen ordinals to decide slide direction: forward navigation
108+ // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
109+ if (targetState.ordinal > initialState.ordinal) {
110+ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
111+ } else {
112+ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
113+ }
114+ },
115+ label = "screen",
116+ ) { screen ->
117+ when (screen) {
118+ Screen.Connect -> ConnectScreen(state, dispatch)
119+ Screen.Sessions -> SessionsScreen(state, dispatch)
120+ Screen.Chat -> ChatScreen(state, dispatch)
121+ }
122+ }
123+ }
124+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktnew file+479
@@ -0,0 +1,479 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.content.Intent
7+import android.net.Uri
8+import android.widget.Toast
9+import androidx.compose.foundation.ExperimentalFoundationApi
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.clickable
12+import androidx.compose.foundation.combinedClickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.fillMaxWidth
17+import androidx.compose.foundation.layout.padding
18+import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.selection.SelectionContainer
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Build
24+import androidx.compose.material.icons.filled.Check
25+import androidx.compose.material.icons.filled.ErrorOutline
26+import androidx.compose.material.icons.filled.ExpandLess
27+import androidx.compose.material.icons.filled.ExpandMore
28+import androidx.compose.material.icons.filled.Schedule
29+import androidx.compose.material.icons.filled.SmartToy
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.DropdownMenuItem
32+import androidx.compose.material3.Icon
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.Text
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.graphics.Color
44+import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
47+import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
49+import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
51+import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.withLink
54+import androidx.compose.ui.text.withStyle
55+import androidx.compose.ui.text.style.TextDecoration
56+import androidx.compose.ui.text.style.TextOverflow
57+import androidx.compose.ui.unit.dp
58+import androidx.compose.ui.unit.sp
59+import soy.iko.opencode.core.MessageStatus
60+import soy.iko.opencode.core.MessageView
61+import soy.iko.opencode.core.ToolView
62+import soy.iko.opencode.ui.theme.MonoStyle
63+
64+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65+
66+@OptIn(ExperimentalFoundationApi::class)
67+@Composable
68+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69+ val isUser = message.isUser
70+ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71+ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73+ val shape = RoundedCornerShape(
74+ topStart = 16.dp,
75+ topEnd = 16.dp,
76+ bottomStart = if (isUser) 16.dp else 4.dp,
77+ bottomEnd = if (isUser) 4.dp else 16.dp,
78+ )
79+
80+ val context = LocalContext.current
81+ var showMenu by remember { mutableStateOf(false) }
82+
83+ Row(
84+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86+ ) {
87+ if (!isUser) {
88+ Icon(
89+ Icons.Filled.SmartToy,
90+ contentDescription = null,
91+ tint = MaterialTheme.colorScheme.primary,
92+ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93+ )
94+ }
95+ Column(
96+ modifier = Modifier
97+ .fillMaxWidth(0.85f)
98+ .widthIn(max = 320.dp)
99+ .clip(shape)
100+ .background(bubbleColor)
101+ .combinedClickable(
102+ onClick = {},
103+ onLongClick = {
104+ if (message.text.isNotEmpty()) {
105+ showMenu = true
106+ }
107+ },
108+ )
109+ .padding(horizontal = 14.dp, vertical = 10.dp),
110+ ) {
111+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112+ DropdownMenuItem(
113+ text = { Text("Copy") },
114+ onClick = {
115+ copyToClipboard(context, message.text)
116+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117+ showMenu = false
118+ },
119+ )
120+ DropdownMenuItem(
121+ text = { Text("Share") },
122+ onClick = {
123+ shareText(context, message.text)
124+ showMenu = false
125+ },
126+ )
127+ }
128+
129+ message.reasoning?.let { ReasoningBlock(it, context) }
130+
131+ // Give a quick overview of how many tools ran before listing them.
132+ if (message.tools.size > 1) {
133+ Text(
134+ text = "${message.tools.size} tool calls",
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(bottom = 4.dp),
138+ )
139+ }
140+
141+ message.tools.forEach { ToolRow(it) }
142+
143+ SelectionContainer {
144+ if (message.text.isNotEmpty()) {
145+ val linkColor = MaterialTheme.colorScheme.primary
146+ // Tint the code background off the bubble's own foreground colour so it
147+ // contrasts on both bubble types (user bubbles use primaryContainer).
148+ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151+ }
152+ Text(
153+ text = annotatedText,
154+ color = textColor,
155+ // Give assistant replies (often long, code-heavy) extra line
156+ // spacing for readability; user bubbles keep the default.
157+ style = if (isUser) {
158+ MaterialTheme.typography.bodyLarge
159+ } else {
160+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161+ },
162+ )
163+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165+ }
166+ }
167+
168+ if (message.time > 0) {
169+ Text(
170+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171+ style = MaterialTheme.typography.labelSmall,
172+ color = mutedColor,
173+ modifier = Modifier.padding(top = 4.dp),
174+ )
175+ }
176+
177+ if (isUser && message.status != MessageStatus.Sent) {
178+ StatusLine(message.status, onRetry = onRetry)
179+ }
180+ }
181+ }
182+}
183+
184+private fun copyToClipboard(context: Context, text: String) {
185+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187+}
188+
189+private val urlRegex = Regex("""https?://[^\s]+""")
190+
191+/**
192+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193+ * prose and code segments: code segments are rendered monospace on a tinted
194+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196+ */
197+private fun buildLinkedText(
198+ text: String,
199+ streaming: Boolean,
200+ linkColor: Color,
201+ codeBackground: Color,
202+ context: Context,
203+): AnnotatedString = buildAnnotatedString {
204+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206+ val segments = text.split("```")
207+ segments.forEachIndexed { index, segment ->
208+ if (index % 2 == 1) {
209+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210+ append(segment)
211+ }
212+ } else {
213+ appendLinkedText(segment, linkColor, codeBackground, context)
214+ }
215+ }
216+ if (streaming) append(" ▌")
217+}
218+
219+/**
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224+ * inline spans reach here.
225+ */
226+private fun AnnotatedString.Builder.appendLinkedText(
227+ text: String,
228+ linkColor: Color,
229+ codeBackground: Color,
230+ context: Context,
231+) {
232+ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233+ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234+ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235+ // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236+ // segment on "*" would misfire on the leftover asterisks from the ** markers.
237+ val segments = text.split("**")
238+ // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239+ // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240+ val unclosed = segments.size % 2 == 0
241+ segments.forEachIndexed { index, segment ->
242+ val isUnclosedTail = unclosed && index == segments.size - 1
243+ if (index % 2 == 1 && !isUnclosedTail) {
244+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245+ appendInlineCode(segment, linkColor, codeBackground, context)
246+ }
247+ } else {
248+ if (isUnclosedTail) append("**")
249+ appendItalicText(segment, linkColor, codeBackground, context)
250+ }
251+ }
252+}
253+
254+/**
255+ * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256+ * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257+ * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258+ */
259+private fun AnnotatedString.Builder.appendItalicText(
260+ text: String,
261+ linkColor: Color,
262+ codeBackground: Color,
263+ context: Context,
264+) {
265+ // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266+ // (markers dropped); even segments are ordinary prose.
267+ val segments = text.split("*")
268+ // An even segment count means an odd number of * markers, so the final * is unclosed:
269+ // its trailing segment isn't italic. Restore the literal * and render it as prose.
270+ val unclosed = segments.size % 2 == 0
271+ segments.forEachIndexed { index, segment ->
272+ val isUnclosedTail = unclosed && index == segments.size - 1
273+ if (index % 2 == 1 && !isUnclosedTail) {
274+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275+ appendInlineCode(segment, linkColor, codeBackground, context)
276+ }
277+ } else {
278+ if (isUnclosedTail) append("*")
279+ appendInlineCode(segment, linkColor, codeBackground, context)
280+ }
281+ }
282+}
283+
284+/**
285+ * Append [text] to the builder, rendering single-backtick pairs as inline code
286+ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287+ * into a clickable link.
288+ */
289+private fun AnnotatedString.Builder.appendInlineCode(
290+ text: String,
291+ linkColor: Color,
292+ codeBackground: Color,
293+ context: Context,
294+) {
295+ // Odd-indexed segments sit between a pair of single backticks, so they're inline
296+ // code. Even segments are ordinary prose and get URL linking applied.
297+ val segments = text.split("`")
298+ // An even segment count means an odd number of backtick markers, so the final backtick
299+ // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300+ val unclosed = segments.size % 2 == 0
301+ segments.forEachIndexed { index, segment ->
302+ val isUnclosedTail = unclosed && index == segments.size - 1
303+ if (index % 2 == 1 && !isUnclosedTail) {
304+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305+ append(segment)
306+ }
307+ } else {
308+ if (isUnclosedTail) append("`")
309+ appendUrls(segment, linkColor, context)
310+ }
311+ }
312+}
313+
314+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315+private fun AnnotatedString.Builder.appendUrls(
316+ text: String,
317+ linkColor: Color,
318+ context: Context,
319+) {
320+ val matches = urlRegex.findAll(text).toList()
321+ var cursor = 0
322+ for (match in matches) {
323+ append(text.substring(cursor, match.range.first))
324+ val url = match.value
325+ withLink(
326+ LinkAnnotation.Clickable(
327+ tag = url,
328+ styles = TextLinkStyles(
329+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330+ ),
331+ linkInteractionListener = {
332+ runCatching {
333+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334+ }
335+ },
336+ ),
337+ ) {
338+ append(url)
339+ }
340+ cursor = match.range.last + 1
341+ }
342+ append(text.substring(cursor))
343+}
344+
345+private fun shareText(context: Context, text: String) {
346+ val intent = Intent(Intent.ACTION_SEND).apply {
347+ type = "text/plain"
348+ putExtra(Intent.EXTRA_TEXT, text)
349+ }
350+ context.startActivity(Intent.createChooser(intent, null))
351+}
352+
353+@Composable
354+private fun ReasoningBlock(text: String, context: Context) {
355+ var expanded by remember { mutableStateOf(false) }
356+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
357+ Row(
358+ verticalAlignment = Alignment.CenterVertically,
359+ modifier = Modifier.clickable { expanded = !expanded },
360+ ) {
361+ Text(
362+ text = if (expanded) "Show less" else "Reasoning",
363+ style = MaterialTheme.typography.bodySmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ )
366+ Icon(
367+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
370+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371+ )
372+ }
373+ if (expanded) {
374+ Text(
375+ text = text,
376+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377+ color = MaterialTheme.colorScheme.onSurfaceVariant,
378+ modifier = Modifier.padding(top = 4.dp),
379+ )
380+ Text(
381+ text = "Copy",
382+ style = MaterialTheme.typography.labelSmall,
383+ color = MaterialTheme.colorScheme.primary,
384+ modifier = Modifier
385+ .padding(top = 4.dp)
386+ .clickable {
387+ copyToClipboard(context, text)
388+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389+ },
390+ )
391+ }
392+ }
393+}
394+
395+@Composable
396+private fun ToolRow(tool: ToolView) {
397+ var expanded by remember { mutableStateOf(false) }
398+ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399+ val statusColor = when (tool.status) {
400+ "completed", "success" -> MaterialTheme.colorScheme.primary
401+ "error", "failed" -> MaterialTheme.colorScheme.error
402+ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403+ else -> MaterialTheme.colorScheme.onSurfaceVariant
404+ }
405+ Row(
406+ verticalAlignment = Alignment.CenterVertically,
407+ modifier = Modifier
408+ .fillMaxWidth()
409+ .clickable { expanded = !expanded }
410+ .padding(bottom = 6.dp),
411+ ) {
412+ Icon(
413+ Icons.Filled.Build,
414+ contentDescription = null,
415+ tint = MaterialTheme.colorScheme.primary,
416+ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417+ )
418+ Text(
419+ text = buildAnnotatedString {
420+ withStyle(SpanStyle(color = baseColor)) {
421+ append(tool.name)
422+ append(" · ")
423+ }
424+ withStyle(SpanStyle(color = statusColor)) {
425+ append(tool.status)
426+ }
427+ if (expanded) tool.title?.let {
428+ withStyle(SpanStyle(color = baseColor)) {
429+ append(" — ")
430+ append(it)
431+ }
432+ }
433+ },
434+ style = MonoStyle,
435+ color = baseColor,
436+ maxLines = if (expanded) Int.MAX_VALUE else 1,
437+ overflow = TextOverflow.Ellipsis,
438+ modifier = Modifier.weight(1f),
439+ )
440+ Icon(
441+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442+ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
444+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445+ )
446+ }
447+}
448+
449+@Composable
450+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451+ val (icon, label, tint) = when (status) {
452+ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453+ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454+ MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455+ }
456+ Row(
457+ verticalAlignment = Alignment.CenterVertically,
458+ horizontalArrangement = Arrangement.End,
459+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460+ ) {
461+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462+ Text(
463+ text = label,
464+ style = MaterialTheme.typography.labelSmall,
465+ color = tint,
466+ modifier = Modifier.padding(start = 4.dp),
467+ )
468+ if (status == MessageStatus.Failed) {
469+ Text(
470+ text = "Retry",
471+ style = MaterialTheme.typography.labelSmall,
472+ color = MaterialTheme.colorScheme.primary,
473+ modifier = Modifier
474+ .padding(start = 12.dp)
475+ .clickable { onRetry() },
476+ )
477+ }
478+ }
479+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+489−111
@@ -1,57 +1,146 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.animation.AnimatedVisibility
8+import androidx.compose.animation.core.RepeatMode
9+import androidx.compose.animation.core.animateFloat
10+import androidx.compose.animation.core.infiniteRepeatable
11+import androidx.compose.animation.core.rememberInfiniteTransition
12+import androidx.compose.animation.core.tween
13+import androidx.compose.animation.fadeIn
14+import androidx.compose.animation.fadeOut
15+import androidx.compose.animation.scaleIn
16+import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
18+import androidx.compose.foundation.background
19+import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
3import androidx.compose.foundation.layout.Arrangement
21import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
22import androidx.compose.foundation.layout.Box
23+import androidx.compose.foundation.layout.BoxScope
5import androidx.compose.foundation.layout.Column
24import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Row
25import androidx.compose.foundation.layout.Row
7import androidx.compose.foundation.layout.Spacer
26import androidx.compose.foundation.layout.Spacer
8import androidx.compose.foundation.layout.fillMaxSize
27import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxWidth
28import androidx.compose.foundation.layout.fillMaxWidth
10−import androidx.compose.foundation.layout.height
29+import androidx.compose.foundation.layout.heightIn
30+import androidx.compose.foundation.layout.PaddingValues
31+import androidx.compose.foundation.layout.WindowInsets
32+import androidx.compose.foundation.layout.ime
33+import androidx.compose.foundation.layout.imePadding
11import androidx.compose.foundation.layout.padding
34import androidx.compose.foundation.layout.padding
12import androidx.compose.foundation.layout.size
35import androidx.compose.foundation.layout.size
13−import androidx.compose.foundation.layout.widthIn
14import androidx.compose.foundation.lazy.LazyColumn
36import androidx.compose.foundation.lazy.LazyColumn
15−import androidx.compose.foundation.lazy.items
37+import androidx.compose.foundation.lazy.itemsIndexed
16import androidx.compose.foundation.lazy.rememberLazyListState
38import androidx.compose.foundation.lazy.rememberLazyListState
39+import androidx.compose.foundation.shape.CircleShape
17import androidx.compose.foundation.shape.RoundedCornerShape
40import androidx.compose.foundation.shape.RoundedCornerShape
41+import androidx.compose.foundation.text.KeyboardActions
42+import androidx.compose.foundation.text.KeyboardOptions
18import androidx.compose.material.icons.Icons
43import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.automirrored.filled.ArrowBack
44import androidx.compose.material.icons.automirrored.filled.ArrowBack
20import androidx.compose.material.icons.automirrored.filled.Send
45import androidx.compose.material.icons.automirrored.filled.Send
46+import androidx.compose.material.icons.filled.ChatBubbleOutline
47+import androidx.compose.material.icons.filled.Close
48+import androidx.compose.material.icons.filled.KeyboardArrowDown
49+import androidx.compose.material.icons.filled.KeyboardArrowUp
50+import androidx.compose.material.icons.filled.Stop
21import androidx.compose.material3.CircularProgressIndicator
51import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.ExperimentalMaterial3Api
52import androidx.compose.material3.ExperimentalMaterial3Api
53+import androidx.compose.material3.FilledIconButton
54+import androidx.compose.material3.FilledTonalButton
55+import androidx.compose.material3.FloatingActionButton
23import androidx.compose.material3.Icon
56import androidx.compose.material3.Icon
24import androidx.compose.material3.IconButton
57import androidx.compose.material3.IconButton
58+import androidx.compose.material3.LinearProgressIndicator
25import androidx.compose.material3.MaterialTheme
59import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.OutlinedTextField
60import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.Scaffold
61import androidx.compose.material3.Scaffold
28import androidx.compose.material3.Surface
62import androidx.compose.material3.Surface
29import androidx.compose.material3.Text
63import androidx.compose.material3.Text
64+import androidx.compose.material3.TextButton
30import androidx.compose.material3.TopAppBar
65import androidx.compose.material3.TopAppBar
31import androidx.compose.runtime.Composable
66import androidx.compose.runtime.Composable
32import androidx.compose.runtime.LaunchedEffect
67import androidx.compose.runtime.LaunchedEffect
33−import androidx.compose.runtime.collectAsState
68+import androidx.compose.runtime.derivedStateOf
34import androidx.compose.runtime.getValue
69import androidx.compose.runtime.getValue
35import androidx.compose.runtime.mutableStateOf
70import androidx.compose.runtime.mutableStateOf
36import androidx.compose.runtime.remember
71import androidx.compose.runtime.remember
72+import androidx.compose.runtime.rememberCoroutineScope
37import androidx.compose.runtime.setValue
73import androidx.compose.runtime.setValue
38import androidx.compose.ui.Alignment
74import androidx.compose.ui.Alignment
39import androidx.compose.ui.Modifier
75import androidx.compose.ui.Modifier
40import androidx.compose.ui.draw.clip
76import androidx.compose.ui.draw.clip
77+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
78+import androidx.compose.ui.platform.LocalClipboardManager
79+import androidx.compose.ui.platform.LocalContext
80+import androidx.compose.ui.platform.LocalDensity
81+import androidx.compose.ui.platform.LocalHapticFeedback
82+import androidx.compose.ui.text.font.FontStyle
83+import androidx.compose.ui.text.input.ImeAction
84+import androidx.compose.ui.text.style.TextAlign
85+import androidx.compose.ui.text.style.TextOverflow
41import androidx.compose.ui.unit.dp
86import androidx.compose.ui.unit.dp
42−import soy.iko.opencode.core.Core
87+import kotlinx.coroutines.launch
43import soy.iko.opencode.core.Event
88import soy.iko.opencode.core.Event
89+import soy.iko.opencode.core.UiState
90+import soy.iko.opencode.ui.components.MessageBubble
44
91
45−@OptIn(ExperimentalMaterial3Api::class)
92+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
46@Composable
93@Composable
47−fun ChatScreen(core: Core) {
94+fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
48− val view by core.view.collectAsState()
49− var inputText by remember { mutableStateOf("") }
50 val listState = rememberLazyListState()
95 val listState = rememberLazyListState()
96+ val coroutineScope = rememberCoroutineScope()
97+ val context = LocalContext.current
51
98
99+ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
100+ // sit over the top of the IME. Reading the ime inset bottom during composition
101+ // observes snapshot state, so this flips as the keyboard opens/closes.
102+ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
103+
104+ // Show a jump-to-bottom button once the user scrolls up away from the latest
105+ // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
106+ // it only flips when the near-bottom threshold is actually crossed.
107+ val showScrollButton by remember {
108+ derivedStateOf {
109+ val layoutInfo = listState.layoutInfo
110+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111+ val totalItems = layoutInfo.totalItemsCount
112+ totalItems > 0 && lastVisibleIndex < totalItems - 3
113+ }
114+ }
115+
116+ // Offer a jump-to-top shortcut once the conversation is long and the user has
117+ // scrolled a few messages down — saves flick-scrolling back through history.
118+ val showScrollTop by remember {
119+ derivedStateOf { state.messages.size > 10 && listState.firstVisibleItemIndex > 3 }
120+ }
121+
122+ // Track how many messages the user has "seen" (i.e. was at the bottom for). While
123+ // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
124+ var lastSeenCount by remember { mutableStateOf(0) }
52− LaunchedEffect(view.messages.size) {
125+ LaunchedEffect(showScrollButton, state.messages.size) {
126+ if (!showScrollButton) {
127+ lastSeenCount = state.messages.size
128+ }
129+ }
130+ val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131+
132+ // Keep the newest content in view as it streams in, but only when the user is
133+ // already near the bottom. If they've scrolled up to read history, leave it alone.
134+ val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
135+ LaunchedEffect(state.messages.size, lastLen, state.generating) {
53− if (view.messages.isNotEmpty()) {
136+ if (state.messages.isNotEmpty()) {
137+ val layoutInfo = listState.layoutInfo
138+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
139+ val totalItems = layoutInfo.totalItemsCount
140+ val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
141+ if (nearBottom) {
54− listState.animateScrollToItem(view.messages.size - 1)
142+ listState.animateScrollToItem(state.messages.size - 1)
143+ }
55 }
144 }
56 }
145 }
57
146
@@ -59,154 +148,443 @@ fun ChatScreen(core: Core) {
59 topBar = {
148 topBar = {
60 TopAppBar(
149 TopAppBar(
61 title = {
150 title = {
151+ val title = state.currentSessionTitle.ifEmpty { "Chat" }
62 Text(
152 Text(
63− view.currentSessionId?.take(8)?.plus("…") ?: "Chat",
153+ text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
64 maxLines = 1,
154 maxLines = 1,
155+ overflow = TextOverflow.Ellipsis,
156+ modifier = Modifier.combinedClickable(
157+ onClick = { dispatch(Event.NavigateToSessions) },
158+ onLongClick = {
159+ state.currentSessionId?.let { id ->
160+ copyToClipboard(context, id)
161+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
162+ }
163+ },
164+ ),
65 )
165 )
66 },
166 },
67 navigationIcon = {
167 navigationIcon = {
68− IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
168+ IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
69− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
169+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
170+ }
171+ },
172+ actions = {
173+ if (state.generating) {
174+ CircularProgressIndicator(
175+ modifier = Modifier.padding(end = 16.dp).size(22.dp),
176+ strokeWidth = 2.dp,
177+ )
70 }
178 }
71 },
179 },
72 )
180 )
73 },
181 },
74 bottomBar = {
182 bottomBar = {
75 ChatInputBar(
183 ChatInputBar(
76− text = inputText,
184+ draft = state.draft,
77− onTextChange = { inputText = it },
185+ generating = state.generating,
78− onSend = {
186+ loading = state.loading && state.messages.isEmpty(),
79− if (inputText.isNotBlank()) {
187+ onDraftChange = { dispatch(Event.DraftChanged(it)) },
80− core.update(Event.SendMessage(inputText))
188+ onSend = { dispatch(Event.SendMessage(state.draft)) },
81− inputText = ""
189+ onStop = { dispatch(Event.CancelGeneration) },
82− }
83− },
84− enabled = !view.loading,
85 )
190 )
86 },
191 },
87 ) { padding ->
192 ) { padding ->
88− Box(
89− modifier = Modifier
90− .fillMaxSize()
91− .padding(padding),
193+ Column(Modifier.fillMaxSize().padding(padding)) {
92− ) {
194+ if (!state.sseConnected && !state.generating) {
195+ ReconnectingBanner()
196+ }
93− if (view.loading && view.messages.isEmpty()) {
197+ if (state.loading && state.messages.isEmpty()) {
94− CircularProgressIndicator(
198+ LinearProgressIndicator(Modifier.fillMaxWidth())
95− modifier = Modifier.align(Alignment.Center),
199+ }
200+ if (state.error != null && state.messages.isNotEmpty()) {
201+ ErrorBanner(
202+ message = state.error,
203+ onDismiss = { dispatch(Event.DismissError) },
96 )
204 )
205+ }
206+ Box(Modifier.fillMaxWidth().weight(1f)) {
97− } else if (view.messages.isEmpty()) {
207+ if (state.messages.isEmpty() && !state.loading) {
98− Column(
208+ Column(
209+ modifier = Modifier.align(Alignment.Center).padding(32.dp),
99− horizontalAlignment = Alignment.CenterHorizontally,
210+ horizontalAlignment = Alignment.CenterHorizontally,
100− verticalArrangement = Arrangement.Center,
211+ ) {
212+ Icon(
213+ Icons.Filled.ChatBubbleOutline,
214+ contentDescription = null,
101− modifier = Modifier.fillMaxSize().padding(24.dp),
215+ modifier = Modifier.size(48.dp),
216+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
102− ) {
217+ )
218+ Spacer(Modifier.size(16.dp))
103− Text(
219+ Text(
104− "Send a message to start chatting",
220+ text = "Send a message to start the conversation.",
105− style = MaterialTheme.typography.bodyLarge,
221+ style = MaterialTheme.typography.bodyMedium,
106− color = MaterialTheme.colorScheme.onSurfaceVariant,
222+ color = MaterialTheme.colorScheme.onSurfaceVariant,
223+ textAlign = TextAlign.Center,
107− )
224+ )
225+ }
108 }
226 }
109− } else {
110 LazyColumn(
227 LazyColumn(
111 state = listState,
228 state = listState,
112− modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
229+ modifier = Modifier.fillMaxSize(),
113− verticalArrangement = Arrangement.spacedBy(8.dp),
114− contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
230+ contentPadding = PaddingValues(vertical = 8.dp),
115 ) {
231 ) {
116− items(view.messages, key = { it.id }) { message ->
232+ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
233+ // Insert a date separator whenever a message falls on a different
234+ // calendar day than the one before it (and above the first message).
235+ val previous = state.messages.getOrNull(index - 1)
236+ if (shouldShowDateSeparator(previous, message)) {
237+ DateSeparator(message.time)
238+ }
239+ val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
117− MessageBubble(message)
240+ MessageBubble(
241+ message,
242+ onRetry = { dispatch(Event.SendMessage(message.text)) },
243+ compactSpacing = sameSenderAsPrevious,
244+ )
245+ }
246+ // While the assistant is spinning up but hasn't emitted a streaming
247+ // message yet (only the user's message is present), show a typing
248+ // indicator so the wait doesn't look like a stall.
249+ if (state.generating && state.messages.none { it.streaming }) {
250+ item(key = "typing-indicator") {
251+ TypingIndicator()
252+ }
118 }
253 }
119 }
254 }
255+ ScrollToBottomButton(
256+ visible = showScrollButton && !isImeVisible,
257+ showBadge = hasUnseenMessages,
258+ onClick = {
259+ coroutineScope.launch {
260+ if (state.messages.isNotEmpty()) {
261+ // When the typing indicator is showing it occupies the item
262+ // just past the last message, so scroll one further to reach it.
263+ val target = if (state.generating && state.messages.none { it.streaming })
264+ state.messages.size
265+ else
266+ state.messages.size - 1
267+ listState.animateScrollToItem(target)
268+ }
269+ }
270+ },
271+ )
272+ ScrollToTopButton(
273+ visible = showScrollTop,
274+ onClick = {
275+ coroutineScope.launch {
276+ listState.animateScrollToItem(0)
277+ }
278+ },
279+ )
280+ }
281+ }
282+ }
283+}
284+
285+// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
286+// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
287+// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
288+@Composable
289+private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
290+ AnimatedVisibility(
291+ visible = visible,
292+ modifier = Modifier.align(Alignment.BottomEnd),
293+ enter = scaleIn() + fadeIn(),
294+ exit = scaleOut() + fadeOut(),
295+ ) {
296+ Box(Modifier.padding(16.dp)) {
297+ FloatingActionButton(
298+ onClick = onClick,
299+ modifier = Modifier.size(44.dp),
300+ ) {
301+ Icon(
302+ Icons.Filled.KeyboardArrowDown,
303+ contentDescription = "Scroll to latest message",
304+ )
305+ }
306+ if (showBadge) {
307+ Box(
308+ modifier = Modifier
309+ .align(Alignment.TopEnd)
310+ .size(8.dp)
311+ .clip(CircleShape)
312+ .background(MaterialTheme.colorScheme.error),
313+ )
120 }
314 }
121 }
315 }
122 }
316 }
123}
317}
124
318
319+// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320+// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
125@Composable
321@Composable
126−private fun MessageBubble(message: soy.iko.opencode.core.MessageView) {
322+private fun BoxScope.ScrollToTopButton(visible: Boolean, onClick: () -> Unit) {
127− val isUser = message.role == "user"
323+ AnimatedVisibility(
324+ visible = visible,
325+ modifier = Modifier.align(Alignment.TopStart),
326+ enter = scaleIn() + fadeIn(),
327+ exit = scaleOut() + fadeOut(),
328+ ) {
329+ Box(Modifier.padding(16.dp)) {
330+ FloatingActionButton(
331+ onClick = onClick,
332+ modifier = Modifier.size(44.dp),
333+ ) {
334+ Icon(
335+ Icons.Filled.KeyboardArrowUp,
336+ contentDescription = "Scroll to top",
337+ )
338+ }
339+ }
340+ }
341+}
342+
343+private fun copyToClipboard(context: Context, text: String) {
344+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
345+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
346+}
347+
348+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
349+
350+/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
351+private fun localDayOf(epochMillis: Long): java.time.LocalDate =
352+ java.time.Instant.ofEpochMilli(epochMillis)
353+ .atZone(java.time.ZoneId.systemDefault())
354+ .toLocalDate()
355+
356+/**
357+ * A separator is shown above a message when it starts a new calendar day — i.e. the
358+ * first timestamped message, or one whose day differs from the previous message's.
359+ * Messages without a valid time (time <= 0) never get one.
360+ */
361+private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
362+ if (current.time <= 0) return false
363+ if (previous == null || previous.time <= 0) return true
364+ return localDayOf(previous.time) != localDayOf(current.time)
365+}
366+
367+/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
368+private fun dateSeparatorLabel(day: java.time.LocalDate): String {
369+ val today = java.time.LocalDate.now()
370+ return when (day) {
371+ today -> "Today"
372+ today.minusDays(1) -> "Yesterday"
373+ else -> day.format(dateSeparatorFormat)
374+ }
375+}
376+
377+@Composable
378+private fun DateSeparator(epochMillis: Long) {
379+ Text(
380+ text = dateSeparatorLabel(localDayOf(epochMillis)),
381+ style = MaterialTheme.typography.labelSmall,
382+ color = MaterialTheme.colorScheme.onSurfaceVariant,
383+ textAlign = TextAlign.Center,
384+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
385+ )
386+}
387+
388+// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
389+// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
390+// surfaceVariant look so it reads as an incoming message.
391+@Composable
392+private fun TypingIndicator() {
393+ val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
394+ val transition = rememberInfiniteTransition(label = "typing")
128 Row(
395 Row(
129− modifier = Modifier.fillMaxWidth(),
396+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
130− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
397+ horizontalArrangement = Arrangement.Start,
131 ) {
398 ) {
132− Surface(
399+ Row(
133− color = if (isUser) {
134− MaterialTheme.colorScheme.primary
135− } else {
136− MaterialTheme.colorScheme.surfaceVariant
137− },
138− contentColor = if (isUser) {
139− MaterialTheme.colorScheme.onPrimary
140− } else {
141− MaterialTheme.colorScheme.onSurfaceVariant
142− },
143− shape = RoundedCornerShape(
144− topStart = 16.dp,
145− topEnd = 16.dp,
146− bottomStart = if (isUser) 16.dp else 4.dp,
147− bottomEnd = if (isUser) 4.dp else 16.dp,
148− ),
149− modifier = Modifier.widthIn(max = 280.dp),
400+ modifier = Modifier
401+ .clip(shape)
402+ .background(MaterialTheme.colorScheme.surfaceVariant)
403+ .padding(horizontal = 14.dp, vertical = 12.dp),
404+ verticalAlignment = Alignment.CenterVertically,
405+ horizontalArrangement = Arrangement.spacedBy(6.dp),
150 ) {
406 ) {
151− Column(
152− modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
153− ) {
154− if (!isUser) {
155− Text(
407+ Text(
156− text = message.role.replaceFirstChar { it.uppercase() },
408+ text = "Thinking",
157− style = MaterialTheme.typography.labelSmall,
409+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
158− color = MaterialTheme.colorScheme.primary,
410+ color = MaterialTheme.colorScheme.onSurfaceVariant,
411+ )
412+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
413+ // Stagger the three dots so they fade in and out in sequence.
414+ listOf(0, 150, 300).forEach { staggerMs ->
415+ val alpha by transition.animateFloat(
416+ initialValue = 0.2f,
417+ targetValue = 1f,
418+ animationSpec = infiniteRepeatable(
419+ animation = tween(durationMillis = 600, delayMillis = staggerMs),
420+ repeatMode = RepeatMode.Reverse,
421+ ),
422+ label = "dot",
423+ )
424+ Box(
425+ modifier = Modifier
426+ .size(6.dp)
427+ .clip(CircleShape)
428+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
159 )
429 )
160− Spacer(Modifier.height(2.dp))
161 }
430 }
162− Text(
163− text = message.text.ifBlank { "(no content)" },
164− style = MaterialTheme.typography.bodyMedium,
165− )
166 }
431 }
167 }
432 }
168 }
433 }
169}
434}
170
435
171@Composable
436@Composable
172−private fun ChatInputBar(
437+private fun ReconnectingBanner() {
173− text: String,
174− onTextChange: (String) -> Unit,
175− onSend: () -> Unit,
176− enabled: Boolean,
177−) {
178 Surface(
438 Surface(
179− tonalElevation = 3.dp,
180 modifier = Modifier.fillMaxWidth(),
439 modifier = Modifier.fillMaxWidth(),
440+ color = MaterialTheme.colorScheme.tertiaryContainer,
181 ) {
441 ) {
182 Row(
442 Row(
443+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
183 verticalAlignment = Alignment.CenterVertically,
444 verticalAlignment = Alignment.CenterVertically,
184− modifier = Modifier
445+ horizontalArrangement = Arrangement.spacedBy(8.dp),
185− .fillMaxWidth()
186− .padding(horizontal = 12.dp, vertical = 8.dp),
187 ) {
446 ) {
188− OutlinedTextField(
447+ CircularProgressIndicator(
189− value = text,
448+ modifier = Modifier.size(14.dp),
190− onValueChange = onTextChange,
449+ strokeWidth = 2.dp,
191− placeholder = { Text("Message…") },
450+ color = MaterialTheme.colorScheme.onTertiaryContainer,
451+ )
452+ Text(
453+ text = "Reconnecting…",
454+ style = MaterialTheme.typography.bodySmall,
455+ color = MaterialTheme.colorScheme.onTertiaryContainer,
456+ )
457+ }
458+ }
459+}
460+
461+@Composable
462+private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
463+ Surface(
464+ modifier = Modifier.fillMaxWidth(),
465+ color = MaterialTheme.colorScheme.errorContainer,
466+ ) {
467+ Row(
468+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
469+ verticalAlignment = Alignment.CenterVertically,
470+ horizontalArrangement = Arrangement.spacedBy(8.dp),
471+ ) {
472+ Text(
473+ text = message,
474+ style = MaterialTheme.typography.bodySmall,
475+ color = MaterialTheme.colorScheme.onErrorContainer,
192 modifier = Modifier.weight(1f),
476 modifier = Modifier.weight(1f),
193− maxLines = 4,
194 )
477 )
195− Spacer(Modifier.size(8.dp))
478+ IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
196− IconButton(
197− onClick = onSend,
198− enabled = enabled && text.isNotBlank(),
199− ) {
200 Icon(
479 Icon(
201− Icons.AutoMirrored.Filled.Send,
480+ Icons.Filled.Close,
202− contentDescription = "Send",
481+ contentDescription = "Dismiss error",
203− tint = if (text.isNotBlank()) {
204− MaterialTheme.colorScheme.primary
482+ tint = MaterialTheme.colorScheme.onErrorContainer,
205− } else {
483+ modifier = Modifier.size(18.dp),
206− MaterialTheme.colorScheme.onSurfaceVariant
207− },
208 )
484 )
209 }
485 }
210 }
486 }
211 }
487 }
212}
488}
489+
490+@Composable
491+private fun ChatInputBar(
492+ draft: String,
493+ generating: Boolean,
494+ loading: Boolean,
495+ onDraftChange: (String) -> Unit,
496+ onSend: () -> Unit,
497+ onStop: () -> Unit,
498+) {
499+ val haptic = LocalHapticFeedback.current
500+ val clipboardManager = LocalClipboardManager.current
501+ Surface(tonalElevation = 3.dp) {
502+ Column(
503+ modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
504+ ) {
505+ Row(
506+ modifier = Modifier.fillMaxWidth(),
507+ verticalAlignment = Alignment.Bottom,
508+ horizontalArrangement = Arrangement.spacedBy(8.dp),
509+ ) {
510+ OutlinedTextField(
511+ value = draft,
512+ onValueChange = onDraftChange,
513+ modifier = Modifier.weight(1f).heightIn(min = 48.dp),
514+ enabled = !loading,
515+ placeholder = { Text("Message opencode…") },
516+ maxLines = 5,
517+ // Offer a quick way to wipe the draft once there's something to clear
518+ // and we're not mid-generation (where the field is a poor place to fuss).
519+ trailingIcon = if (draft.isNotEmpty() && !generating) {
520+ {
521+ IconButton(
522+ onClick = { onDraftChange("") },
523+ modifier = Modifier.size(20.dp),
524+ ) {
525+ Icon(
526+ Icons.Filled.Close,
527+ contentDescription = "Clear draft",
528+ modifier = Modifier.size(16.dp),
529+ )
530+ }
531+ }
532+ } else {
533+ null
534+ },
535+ // Surface a running count once the draft gets long, so the user has a
536+ // sense of scale before sending a wall of text.
537+ supportingText = if (draft.length > 500) {
538+ {
539+ Text(
540+ text = "${draft.length} characters",
541+ style = MaterialTheme.typography.labelSmall,
542+ color = MaterialTheme.colorScheme.onSurfaceVariant,
543+ textAlign = TextAlign.End,
544+ modifier = Modifier.fillMaxWidth(),
545+ )
546+ }
547+ } else {
548+ null
549+ },
550+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551+ keyboardActions = KeyboardActions(onSend = {
552+ if (!generating && !loading && draft.isNotBlank()) onSend()
553+ }),
554+ )
555+ if (generating) {
556+ FilledTonalButton(onClick = onStop) {
557+ Icon(
558+ Icons.Filled.Stop,
559+ contentDescription = null,
560+ modifier = Modifier.size(18.dp),
561+ )
562+ Spacer(Modifier.size(6.dp))
563+ Text("Stop")
564+ }
565+ } else {
566+ FilledIconButton(
567+ onClick = {
568+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
569+ onSend()
570+ },
571+ enabled = draft.isNotBlank() && !loading,
572+ ) {
573+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
574+ }
575+ }
576+ }
577+ // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
578+ if (draft.isEmpty() && !generating && !loading) {
579+ TextButton(
580+ onClick = {
581+ clipboardManager.getText()?.text?.let { onDraftChange(it) }
582+ },
583+ contentPadding = PaddingValues(0.dp),
584+ ) {
585+ Text("Paste")
586+ }
587+ }
588+ }
589+ }
590+}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+177−64
@@ -4,106 +4,219 @@ import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Column
4import androidx.compose.foundation.layout.Column
5import androidx.compose.foundation.layout.Spacer
5import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.fillMaxSize
6import androidx.compose.foundation.layout.fillMaxSize
7+import androidx.compose.foundation.layout.fillMaxWidth
7import androidx.compose.foundation.layout.height
8import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.imePadding
8import androidx.compose.foundation.layout.padding
10import androidx.compose.foundation.layout.padding
9import androidx.compose.foundation.layout.size
11import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.rememberScrollState
13+import androidx.compose.foundation.text.KeyboardActions
10import androidx.compose.foundation.text.KeyboardOptions
14import androidx.compose.foundation.text.KeyboardOptions
15+import androidx.compose.foundation.verticalScroll
11import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
12−import androidx.compose.material.icons.filled.Dns
17+import androidx.compose.material.icons.filled.Lock
18+import androidx.compose.material.icons.filled.Person
19+import androidx.compose.material.icons.filled.Terminal
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
13import androidx.compose.material3.Button
22import androidx.compose.material3.Button
14import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
15−import androidx.compose.material3.ExperimentalMaterial3Api
16import androidx.compose.material3.Icon
24import androidx.compose.material3.Icon
25+import androidx.compose.material3.IconButton
17import androidx.compose.material3.MaterialTheme
26import androidx.compose.material3.MaterialTheme
18import androidx.compose.material3.OutlinedTextField
27import androidx.compose.material3.OutlinedTextField
19−import androidx.compose.material3.Scaffold
20−import androidx.compose.material3.SnackbarHost
21−import androidx.compose.material3.SnackbarHostState
22import androidx.compose.material3.Text
28import androidx.compose.material3.Text
23−import androidx.compose.material3.TopAppBar
24import androidx.compose.runtime.Composable
29import androidx.compose.runtime.Composable
25import androidx.compose.runtime.LaunchedEffect
30import androidx.compose.runtime.LaunchedEffect
26−import androidx.compose.runtime.collectAsState
27import androidx.compose.runtime.getValue
31import androidx.compose.runtime.getValue
28import androidx.compose.runtime.mutableStateOf
32import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.remember
33import androidx.compose.runtime.remember
30import androidx.compose.runtime.setValue
34import androidx.compose.runtime.setValue
31import androidx.compose.ui.Alignment
35import androidx.compose.ui.Alignment
32import androidx.compose.ui.Modifier
36import androidx.compose.ui.Modifier
37+import androidx.compose.ui.focus.FocusRequester
38+import androidx.compose.ui.focus.focusRequester
33import androidx.compose.ui.text.input.ImeAction
39import androidx.compose.ui.text.input.ImeAction
34import androidx.compose.ui.text.input.KeyboardType
40import androidx.compose.ui.text.input.KeyboardType
41+import androidx.compose.ui.text.input.PasswordVisualTransformation
42+import androidx.compose.ui.text.input.VisualTransformation
43+import androidx.compose.ui.text.style.TextAlign
35import androidx.compose.ui.unit.dp
44import androidx.compose.ui.unit.dp
36−import soy.iko.opencode.core.Core
37import soy.iko.opencode.core.Event
45import soy.iko.opencode.core.Event
46+import soy.iko.opencode.core.UiState
38
47
39−@OptIn(ExperimentalMaterial3Api::class)
40@Composable
48@Composable
41−fun ConnectScreen(core: Core) {
49+fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
42− val view by core.view.collectAsState()
50+ val focusRequester = remember { FocusRequester() }
43− var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
51+ var passwordVisible by remember { mutableStateOf(false) }
44− val snackbarHostState = remember { SnackbarHostState() }
45
52
53+ // Focus the server URL field when the screen opens, but only when it's empty
54+ // so we don't steal focus (and pop the keyboard) on a return visit.
46− LaunchedEffect(view.error) {
55+ LaunchedEffect(Unit) {
47− view.error?.let {
56+ if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
48− snackbarHostState.showSnackbar(it)
49− }
50 }
57 }
51
58
52− Scaffold(
53− topBar = {
54− TopAppBar(title = { Text("Opencode") })
55− },
56− snackbarHost = { SnackbarHost(snackbarHostState) },
57− ) { padding ->
58− Column(
59+ Column(
60+ modifier = Modifier
61+ .fillMaxSize()
62+ .verticalScroll(rememberScrollState())
63+ .imePadding()
64+ .padding(horizontal = 28.dp),
65+ verticalArrangement = Arrangement.Center,
59− horizontalAlignment = Alignment.CenterHorizontally,
66+ horizontalAlignment = Alignment.CenterHorizontally,
60− verticalArrangement = Arrangement.Center,
61− modifier = Modifier
62− .fillMaxSize()
63− .padding(padding)
64− .padding(24.dp),
65− ) {
67+ ) {
66− Icon(
68+ Icon(
67− imageVector = Icons.Default.Dns,
69+ Icons.Filled.Terminal,
68− contentDescription = null,
70+ contentDescription = null,
71+ tint = MaterialTheme.colorScheme.primary,
69− modifier = Modifier.size(72.dp),
72+ modifier = Modifier.size(72.dp),
70− tint = MaterialTheme.colorScheme.primary,
73+ )
74+ Spacer(Modifier.height(16.dp))
75+ Text(
76+ text = "opencode",
77+ style = MaterialTheme.typography.displaySmall,
78+ color = MaterialTheme.colorScheme.primary,
79+ )
80+ Text(
81+ text = "Connect to your opencode server",
82+ style = MaterialTheme.typography.bodyMedium,
83+ color = MaterialTheme.colorScheme.onSurfaceVariant,
84+ textAlign = TextAlign.Center,
85+ )
86+
87+ Spacer(Modifier.height(32.dp))
88+
89+ OutlinedTextField(
90+ value = state.serverUrl,
91+ onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
92+ label = { Text("Server URL") },
93+ placeholder = { Text("http://192.168.1.10:4096") },
94+ singleLine = true,
95+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
96+ keyboardOptions = KeyboardOptions(
97+ keyboardType = KeyboardType.Uri,
98+ imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
99+ ),
100+ keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
101+ enabled = !state.loading,
102+ )
103+
104+ // Point first-time users at the expected address format. Hidden once they
105+ // start typing so it doesn't compete with the field's own contents.
106+ if (state.serverUrl.isEmpty()) {
107+ Spacer(Modifier.height(6.dp))
108+ Text(
109+ text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ textAlign = TextAlign.Center,
71 )
113 )
114+ }
115+
116+ // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117+ // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118+ if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
72− Spacer(Modifier.height(16.dp))
119+ Spacer(Modifier.height(6.dp))
73 Text(
120 Text(
74− text = "Connect to Opencode Server",
121+ text = "Tip: Add http:// if connecting to a local server",
75− style = MaterialTheme.typography.headlineSmall,
122+ style = MaterialTheme.typography.bodySmall,
123+ color = MaterialTheme.colorScheme.tertiary,
124+ textAlign = TextAlign.Center,
76 )
125 )
126+ }
127+
128+ if (state.authRequired) {
77− Spacer(Modifier.height(24.dp))
129+ Spacer(Modifier.height(12.dp))
78 OutlinedTextField(
130 OutlinedTextField(
79− value = url,
131+ value = state.username,
80− onValueChange = { url = it },
132+ onValueChange = { dispatch(Event.UsernameChanged(it)) },
81− label = { Text("Server URL") },
133+ label = { Text("Username") },
82 singleLine = true,
134 singleLine = true,
135+ leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136+ modifier = Modifier.fillMaxWidth(),
83− keyboardOptions = KeyboardOptions(
137+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
84− keyboardType = KeyboardType.Uri,
138+ enabled = !state.loading,
85− imeAction = ImeAction.Go,
86− ),
87− modifier = Modifier.padding(horizontal = 16.dp),
88 )
139 )
89− Spacer(Modifier.height(16.dp))
140+ Spacer(Modifier.height(12.dp))
90− Button(
141+ OutlinedTextField(
91− onClick = {
142+ value = state.password,
92− core.update(Event.ServerUrlChanged(url))
143+ onValueChange = { dispatch(Event.PasswordChanged(it)) },
93− core.update(Event.Connect)
144+ label = { Text("Password") },
145+ singleLine = true,
146+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
147+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
148+ trailingIcon = {
149+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
150+ Icon(
151+ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
152+ contentDescription = if (passwordVisible) "Hide password" else "Show password",
153+ )
154+ }
94 },
155 },
95− enabled = !view.loading,
156+ modifier = Modifier.fillMaxWidth(),
157+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158+ keyboardActions = KeyboardActions(onGo = {
159+ // Mirror the connect button's enabled condition so the keyboard's
160+ // Go action can't fire a request the form isn't ready for.
161+ if (!state.loading && state.serverUrl.isNotBlank() &&
162+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163+ ) {
164+ dispatch(Event.Connect)
165+ }
166+ }),
167+ enabled = !state.loading,
96− ) {
168+ )
169+ Spacer(Modifier.height(4.dp))
170+ Text(
171+ text = "This server requires authentication.",
172+ style = MaterialTheme.typography.bodySmall,
173+ color = MaterialTheme.colorScheme.onSurfaceVariant,
174+ )
175+ }
176+
177+ Spacer(Modifier.height(24.dp))
178+
179+ Button(
180+ onClick = { dispatch(Event.Connect) },
181+ // When auth is required, keep the button disabled until at least one
182+ // credential is entered — otherwise it just triggers a validation error.
183+ enabled = !state.loading && state.serverUrl.isNotBlank() &&
184+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
185+ modifier = Modifier.fillMaxWidth(),
186+ ) {
97− if (view.loading) {
187+ if (state.loading) {
98− CircularProgressIndicator(
188+ CircularProgressIndicator(
99− modifier = Modifier.size(20.dp),
189+ modifier = Modifier.height(20.dp),
100− strokeWidth = 2.dp,
190+ strokeWidth = 2.dp,
101− color = MaterialTheme.colorScheme.onPrimary,
191+ color = MaterialTheme.colorScheme.onPrimary,
102− )
192+ )
103− } else {
193+ } else {
104− Text("Connect")
194+ Text(if (state.authRequired) "Sign in" else "Connect")
105− }
106 }
195 }
107 }
196 }
197+
198+ // While connecting (including auto-connect on launch), echo the target URL
199+ // so the user knows what the spinner is waiting on.
200+ if (state.loading) {
201+ Spacer(Modifier.height(8.dp))
202+ Text(
203+ text = "Connecting to ${state.serverUrl}...",
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ textAlign = TextAlign.Center,
207+ )
208+ }
209+
210+ // Surface the error inline as well, so it stays visible while the user
211+ // corrects the form (the snackbar is transient).
212+ if (state.error != null && !state.loading) {
213+ Spacer(Modifier.height(8.dp))
214+ Text(
215+ text = state.error,
216+ style = MaterialTheme.typography.bodySmall,
217+ color = MaterialTheme.colorScheme.error,
218+ textAlign = TextAlign.Center,
219+ )
220+ }
108 }
221 }
109}
222}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+351−50
@@ -1,101 +1,402 @@
1package soy.iko.opencode.ui.screens
1package soy.iko.opencode.ui.screens
2
2
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
3import androidx.compose.foundation.clickable
9import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
11+import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
13import androidx.compose.foundation.layout.Column
14+import androidx.compose.foundation.layout.PaddingValues
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.Spacer
6import androidx.compose.foundation.layout.fillMaxSize
17import androidx.compose.foundation.layout.fillMaxSize
18+import androidx.compose.foundation.layout.fillMaxWidth
19+import androidx.compose.foundation.layout.height
7import androidx.compose.foundation.layout.padding
20import androidx.compose.foundation.layout.padding
21+import androidx.compose.foundation.layout.size
8import androidx.compose.foundation.lazy.LazyColumn
22import androidx.compose.foundation.lazy.LazyColumn
9import androidx.compose.foundation.lazy.items
23import androidx.compose.foundation.lazy.items
24+import androidx.compose.foundation.lazy.rememberLazyListState
25+import androidx.compose.foundation.shape.CircleShape
10import androidx.compose.material.icons.Icons
26import androidx.compose.material.icons.Icons
11import androidx.compose.material.icons.automirrored.filled.ArrowBack
27import androidx.compose.material.icons.automirrored.filled.ArrowBack
12import androidx.compose.material.icons.filled.Add
28import androidx.compose.material.icons.filled.Add
13−import androidx.compose.material.icons.filled.ChatBubble
29+import androidx.compose.material.icons.filled.ChatBubbleOutline
30+import androidx.compose.material.icons.filled.ChevronRight
31+import androidx.compose.material.icons.filled.DeleteOutline
32+import androidx.compose.material.icons.filled.Refresh
33+import androidx.compose.material3.AlertDialog
14import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.ExperimentalMaterial3Api
35import androidx.compose.material3.ExperimentalMaterial3Api
16−import androidx.compose.material3.FloatingActionButton
36+import androidx.compose.material3.ExtendedFloatingActionButton
37+import androidx.compose.material3.HorizontalDivider
17import androidx.compose.material3.Icon
38import androidx.compose.material3.Icon
18import androidx.compose.material3.IconButton
39import androidx.compose.material3.IconButton
19−import androidx.compose.material3.ListItem
40+import androidx.compose.material3.LinearProgressIndicator
20import androidx.compose.material3.MaterialTheme
41import androidx.compose.material3.MaterialTheme
42+import androidx.compose.material3.OutlinedTextField
21import androidx.compose.material3.Scaffold
43import androidx.compose.material3.Scaffold
44+import androidx.compose.material3.Surface
45+import androidx.compose.material3.SwipeToDismissBox
46+import androidx.compose.material3.SwipeToDismissBoxValue
22import androidx.compose.material3.Text
47import androidx.compose.material3.Text
48+import androidx.compose.material3.TextButton
23import androidx.compose.material3.TopAppBar
49import androidx.compose.material3.TopAppBar
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
51+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
24import androidx.compose.runtime.Composable
53import androidx.compose.runtime.Composable
25−import androidx.compose.runtime.collectAsState
54+import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
55import androidx.compose.runtime.getValue
56+import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.remember
58+import androidx.compose.runtime.setValue
27import androidx.compose.ui.Alignment
59import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
60import androidx.compose.ui.Modifier
61+import androidx.compose.ui.draw.clip
62+import androidx.compose.ui.graphics.Color
63+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
64+import androidx.compose.ui.platform.LocalContext
65+import androidx.compose.ui.platform.LocalHapticFeedback
66+import androidx.compose.ui.text.style.TextAlign
67+import androidx.compose.ui.text.style.TextOverflow
29import androidx.compose.ui.unit.dp
68import androidx.compose.ui.unit.dp
30−import soy.iko.opencode.core.Core
31import soy.iko.opencode.core.Event
69import soy.iko.opencode.core.Event
70+import soy.iko.opencode.core.SessionView
71+import soy.iko.opencode.core.UiState
32
72
33@OptIn(ExperimentalMaterial3Api::class)
73@OptIn(ExperimentalMaterial3Api::class)
34@Composable
74@Composable
35−fun SessionsScreen(core: Core) {
75+fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
36− val view by core.view.collectAsState()
76+ val haptic = LocalHapticFeedback.current
77+ var pendingDeleteId by remember { mutableStateOf<String?>(null) }
78+ var isPullRefreshing by remember { mutableStateOf(false) }
79+ var searchQuery by remember { mutableStateOf("") }
80+ val sessionsListState = rememberLazyListState()
81+
82+ // Bring a newly created session (which lands at the top of the list) into view.
83+ // Only scroll when the list actually grows, not on every reload of the same sessions.
84+ var prevSize by remember { mutableStateOf(state.sessions.size) }
85+ LaunchedEffect(state.sessions.size) {
86+ if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
87+ prevSize = state.sessions.size
88+ }
89+
90+ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
91+ // every loading operation. Clear it once the underlying load completes.
92+ LaunchedEffect(state.loading) {
93+ if (!state.loading) isPullRefreshing = false
94+ }
95+
96+ pendingDeleteId?.let { deleteId ->
97+ val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
98+ AlertDialog(
99+ onDismissRequest = { pendingDeleteId = null },
100+ title = { Text("Delete session?") },
101+ text = { Text(pendingSession?.title ?: deleteId) },
102+ confirmButton = {
103+ TextButton(onClick = {
104+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
105+ dispatch(Event.DeleteSession(deleteId))
106+ pendingDeleteId = null
107+ }) {
108+ Text("Delete")
109+ }
110+ },
111+ dismissButton = {
112+ TextButton(onClick = { pendingDeleteId = null }) {
113+ Text("Cancel")
114+ }
115+ },
116+ )
117+ }
37
118
38 Scaffold(
119 Scaffold(
39 topBar = {
120 topBar = {
40 TopAppBar(
121 TopAppBar(
41− title = { Text("Sessions") },
122+ title = {
123+ Row(verticalAlignment = Alignment.CenterVertically) {
124+ // A quick at-a-glance health dot for the SSE stream: green when
125+ // connected, error-tinted when the stream is down (updates stall).
126+ Box(
127+ modifier = Modifier
128+ .size(8.dp)
129+ .clip(CircleShape)
130+ .background(
131+ if (state.sseConnected) Color(0xFF4CAF50)
132+ else MaterialTheme.colorScheme.error,
133+ ),
134+ )
135+ Spacer(Modifier.size(8.dp))
136+ Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions")
137+ }
138+ },
139+ navigationIcon = {
140+ IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
142+ }
143+ },
42 actions = {
144 actions = {
43− IconButton(onClick = { core.update(Event.CreateSession) }) {
145+ IconButton(onClick = { dispatch(Event.LoadSessions) }) {
44− Icon(Icons.Default.Add, contentDescription = "New session")
146+ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
45 }
147 }
46 },
148 },
47 )
149 )
48 },
150 },
49 floatingActionButton = {
151 floatingActionButton = {
152+ // Give the search results the full width while searching — the FAB would
153+ // otherwise cover the bottom rows and isn't relevant mid-search anyway.
154+ if (searchQuery.isEmpty()) {
155+ ExtendedFloatingActionButton(
50− FloatingActionButton(onClick = { core.update(Event.CreateSession) }) {
156+ onClick = { dispatch(Event.CreateSession) },
51− Icon(Icons.Default.Add, contentDescription = "New session")
157+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
158+ text = { Text("New session") },
159+ )
52 }
160 }
53 },
161 },
54 ) { padding ->
162 ) { padding ->
55− Box(
56− modifier = Modifier
57− .fillMaxSize()
58− .padding(padding),
163+ Column(Modifier.fillMaxSize().padding(padding)) {
59− ) {
164+ if (!state.sseConnected) {
60− if (view.loading && view.sessions.isEmpty()) {
165+ ReconnectingBanner()
61− CircularProgressIndicator(
166+ }
62− modifier = Modifier.align(Alignment.Center),
167+ if (state.loading && !isPullRefreshing) {
63− )
168+ LinearProgressIndicator(Modifier.fillMaxWidth())
169+ }
64− } else if (view.sessions.isEmpty()) {
170+ if (state.sessions.isNotEmpty()) {
65− Column(
171+ OutlinedTextField(
66− horizontalAlignment = Alignment.CenterHorizontally,
172+ value = searchQuery,
173+ onValueChange = { searchQuery = it },
174+ placeholder = { Text("Search sessions") },
175+ singleLine = true,
67 modifier = Modifier
176 modifier = Modifier
68− .fillMaxSize()
177+ .fillMaxWidth()
69− .padding(24.dp),
178+ .padding(horizontal = 16.dp, vertical = 8.dp),
70− ) {
179+ )
71− Text(
180+ }
181+ val filteredSessions = if (searchQuery.isEmpty()) {
72− "No sessions yet",
182+ state.sessions
73− style = MaterialTheme.typography.bodyLarge,
74− color = MaterialTheme.colorScheme.onSurfaceVariant,
75− )
76− }
77 } else {
183 } else {
184+ state.sessions.filter {
185+ it.title.contains(searchQuery, ignoreCase = true) ||
186+ it.id.contains(searchQuery, ignoreCase = true)
187+ }
188+ }
189+ when {
190+ state.sessions.isEmpty() && !state.loading -> EmptySessions()
191+ filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
192+ else -> PullToRefreshBox(
193+ isRefreshing = isPullRefreshing,
194+ onRefresh = {
195+ isPullRefreshing = true
196+ dispatch(Event.LoadSessions)
197+ },
198+ modifier = Modifier.fillMaxSize(),
199+ state = rememberPullToRefreshState(),
200+ ) {
78− LazyColumn {
201+ LazyColumn(
202+ state = sessionsListState,
203+ modifier = Modifier.fillMaxSize(),
204+ contentPadding = PaddingValues(bottom = 88.dp),
205+ ) {
79− items(view.sessions, key = { it.id }) { session ->
206+ items(filteredSessions, key = { it.id }) { session ->
80− ListItem(
207+ // animateItem() slides the remaining rows into place when one is
81− headlineContent = { Text(session.title.ifEmpty { "Untitled" }) },
208+ // deleted (and fades new rows in) instead of snapping instantly.
82− supportingContent = { Text(session.id.take(8)) },
209+ Column(modifier = Modifier.animateItem()) {
83− leadingContent = {
210+ val dismissState = rememberSwipeToDismissBoxState(
84− Icon(
211+ // Only a swipe from the end (right-to-left) triggers deletion;
85− Icons.Default.ChatBubble,
212+ // return false so the row snaps back and the confirm dialog decides.
86− contentDescription = null,
213+ confirmValueChange = { value ->
87− tint = MaterialTheme.colorScheme.primary,
214+ if (value == SwipeToDismissBoxValue.EndToStart) {
215+ pendingDeleteId = session.id
216+ }
217+ false
218+ },
88 )
219 )
89− },
220+ SwipeToDismissBox(
90− modifier = Modifier
221+ state = dismissState,
91− .padding(horizontal = 0.dp)
222+ enableDismissFromStartToEnd = false,
92− .clickable {
223+ backgroundContent = { SwipeDeleteBackground() },
224+ ) {
225+ SessionRow(
226+ session = session,
93− core.update(Event.SelectSession(session.id))
227+ onOpen = { dispatch(Event.SelectSession(session.id)) },
94− },
228+ onDelete = { pendingDeleteId = session.id },
95− )
229+ )
230+ }
231+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
232+ }
233+ }
96 }
234 }
97 }
235 }
98 }
236 }
99 }
237 }
100 }
238 }
101}
239}
240+
241+@OptIn(ExperimentalFoundationApi::class)
242+@Composable
243+private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
244+ val context = LocalContext.current
245+ Row(
246+ modifier = Modifier
247+ .fillMaxWidth()
248+ .background(MaterialTheme.colorScheme.surface)
249+ .combinedClickable(
250+ onClick = onOpen,
251+ onLongClick = {
252+ copyToClipboard(context, session.id)
253+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
254+ },
255+ )
256+ .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
257+ verticalAlignment = Alignment.CenterVertically,
258+ ) {
259+ Icon(
260+ Icons.Filled.ChatBubbleOutline,
261+ contentDescription = null,
262+ tint = MaterialTheme.colorScheme.primary,
263+ modifier = Modifier.padding(end = 16.dp),
264+ )
265+ Column(Modifier.weight(1f)) {
266+ Text(
267+ text = session.title,
268+ style = MaterialTheme.typography.bodyLarge,
269+ maxLines = 1,
270+ overflow = TextOverflow.Ellipsis,
271+ )
272+ Text(
273+ text = shortSessionId(session.id),
274+ style = MaterialTheme.typography.bodySmall,
275+ color = MaterialTheme.colorScheme.onSurfaceVariant,
276+ maxLines = 1,
277+ overflow = TextOverflow.Ellipsis,
278+ )
279+ }
280+ Icon(
281+ Icons.Filled.ChevronRight,
282+ contentDescription = null,
283+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
284+ modifier = Modifier.padding(horizontal = 4.dp),
285+ )
286+ IconButton(onClick = onDelete) {
287+ Icon(
288+ Icons.Filled.DeleteOutline,
289+ contentDescription = "Delete session",
290+ tint = MaterialTheme.colorScheme.error,
291+ )
292+ }
293+ }
294+}
295+
296+/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
297+@Composable
298+private fun SwipeDeleteBackground() {
299+ Box(
300+ modifier = Modifier
301+ .fillMaxSize()
302+ .background(MaterialTheme.colorScheme.error)
303+ .padding(horizontal = 24.dp),
304+ contentAlignment = Alignment.CenterEnd,
305+ ) {
306+ Row(verticalAlignment = Alignment.CenterVertically) {
307+ Icon(
308+ Icons.Filled.DeleteOutline,
309+ contentDescription = null,
310+ tint = MaterialTheme.colorScheme.onError,
311+ )
312+ Text(
313+ text = "Delete",
314+ style = MaterialTheme.typography.labelLarge,
315+ color = MaterialTheme.colorScheme.onError,
316+ modifier = Modifier.padding(start = 4.dp),
317+ )
318+ }
319+ }
320+}
321+
322+/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
323+@Composable
324+private fun ReconnectingBanner() {
325+ Surface(
326+ modifier = Modifier.fillMaxWidth(),
327+ color = MaterialTheme.colorScheme.tertiaryContainer,
328+ ) {
329+ Row(
330+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
331+ verticalAlignment = Alignment.CenterVertically,
332+ horizontalArrangement = Arrangement.spacedBy(8.dp),
333+ ) {
334+ CircularProgressIndicator(
335+ modifier = Modifier.size(14.dp),
336+ strokeWidth = 2.dp,
337+ color = MaterialTheme.colorScheme.onTertiaryContainer,
338+ )
339+ Text(
340+ text = "Reconnecting…",
341+ style = MaterialTheme.typography.bodySmall,
342+ color = MaterialTheme.colorScheme.onTertiaryContainer,
343+ )
344+ }
345+ }
346+}
347+
348+private fun copyToClipboard(context: Context, text: String) {
349+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
350+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
351+}
352+
353+/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
354+private fun shortSessionId(id: String): String =
355+ if (id.length > 12) id.take(8) + "…" else id
356+
357+@Composable
358+private fun NoSearchResults(query: String) {
359+ Box(
360+ modifier = Modifier.fillMaxSize().padding(32.dp),
361+ contentAlignment = Alignment.Center,
362+ ) {
363+ Text(
364+ text = "No results for “$query”",
365+ style = MaterialTheme.typography.bodyMedium,
366+ color = MaterialTheme.colorScheme.onSurfaceVariant,
367+ )
368+ }
369+}
370+
371+@Composable
372+private fun EmptySessions() {
373+ Column(
374+ modifier = Modifier.fillMaxSize().padding(32.dp),
375+ verticalArrangement = Arrangement.Center,
376+ horizontalAlignment = Alignment.CenterHorizontally,
377+ ) {
378+ Icon(
379+ Icons.Filled.ChatBubbleOutline,
380+ contentDescription = null,
381+ modifier = Modifier.size(64.dp),
382+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
383+ )
384+ Spacer(Modifier.height(16.dp))
385+ Text(
386+ text = "No sessions yet",
387+ style = MaterialTheme.typography.titleMedium,
388+ )
389+ Text(
390+ text = "Tap “New session” to start chatting.",
391+ style = MaterialTheme.typography.bodyMedium,
392+ color = MaterialTheme.colorScheme.onSurfaceVariant,
393+ )
394+ Spacer(Modifier.height(8.dp))
395+ Text(
396+ text = "Sessions you create on this server will appear here.",
397+ style = MaterialTheme.typography.bodySmall,
398+ color = MaterialTheme.colorScheme.onSurfaceVariant,
399+ textAlign = TextAlign.Center,
400+ )
401+ }
402+}
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/Theme.kt+46−28
@@ -11,48 +11,66 @@ import androidx.compose.runtime.Composable
11import androidx.compose.ui.graphics.Color
11import androidx.compose.ui.graphics.Color
12import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.platform.LocalContext
13
13
14−private val DarkColorScheme = darkColorScheme(
14+private val DarkColors = darkColorScheme(
15− primary = Color(0xFF82B1FF),
15+ primary = Emerald,
16+ onPrimary = Color(0xFF0F1115),
17+ primaryContainer = Color(0xFF1A3D2A),
18+ onPrimaryContainer = Color(0xFFA8E6C1),
16− secondary = Color(0xFF80CBC4),
19+ secondary = EmeraldDark,
20+ onSecondary = Color(0xFFE6E9EF),
21+ secondaryContainer = Color(0xFF1A2A3D),
22+ onSecondaryContainer = Color(0xFFA8C5E6),
17− tertiary = Color(0xFFFFB74D),
23+ tertiary = Color(0xFF6B7280),
24+ onTertiary = Color(0xFFE6E9EF),
25+ tertiaryContainer = Color(0xFF2A2A3D),
26+ onTertiaryContainer = Color(0xFFC5C5E6),
27+ errorContainer = Color(0xFF4D2626),
28+ onErrorContainer = Color(0xFFFFB4B4),
18− background = Color(0xFF121212),
29+ background = DarkBackground,
19− surface = Color(0xFF1E1E1E),
30+ surface = DarkSurface,
20− onPrimary = Color.Black,
31+ surfaceVariant = DarkSurfaceVariant,
21− onSecondary = Color.Black,
22− onTertiary = Color.Black,
23− onBackground = Color.White,
24− onSurface = Color.White,
32+ onSurface = DarkOnSurface,
33+ outline = DarkOutline,
25)
34)
26
35
27−private val LightColorScheme = lightColorScheme(
36+private val LightColors = lightColorScheme(
28− primary = Color(0xFF1565C0),
37+ primary = EmeraldDark,
38+ primaryContainer = Color(0xFFD7F5E3),
39+ onPrimaryContainer = Color(0xFF0A3D22),
29− secondary = Color(0xFF00897B),
40+ secondary = Emerald,
41+ onSecondary = Color(0xFF1A1C1F),
42+ secondaryContainer = Color(0xFFD7E8F5),
43+ onSecondaryContainer = Color(0xFF0A2A4D),
30− tertiary = Color(0xFFF57C00),
44+ tertiary = Color(0xFF6B7280),
45+ onTertiary = Color(0xFFFFFFFF),
46+ tertiaryContainer = Color(0xFFE8E8F5),
47+ onTertiaryContainer = Color(0xFF2A2A4D),
48+ errorContainer = Color(0xFFFFE0E0),
49+ onErrorContainer = Color(0xFF660000),
31− background = Color(0xFFF5F5F5),
50+ background = LightBackground,
32− surface = Color.White,
51+ surface = LightSurface,
33− onPrimary = Color.White,
52+ surfaceVariant = LightSurfaceVariant,
34− onSecondary = Color.White,
35− onTertiary = Color.White,
36− onBackground = Color.Black,
37− onSurface = Color.Black,
53+ onSurface = LightOnSurface,
54+ outline = LightOutline,
38)
55)
39
56
40@Composable
57@Composable
41fun OpencodeTheme(
58fun OpencodeTheme(
42 darkTheme: Boolean = isSystemInDarkTheme(),
59 darkTheme: Boolean = isSystemInDarkTheme(),
43− dynamicColor: Boolean = true,
60+ dynamicColor: Boolean = false,
44 content: @Composable () -> Unit,
61 content: @Composable () -> Unit,
45) {
62) {
63+ val context = LocalContext.current
46 val colorScheme = when {
64 val colorScheme = when {
47− dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
65+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
48− val context = LocalContext.current
49 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
66 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
50− }
51− darkTheme -> DarkColorScheme
67+ darkTheme -> DarkColors
52− else -> LightColorScheme
68+ else -> LightColors
53 }
69 }
70+
54 MaterialTheme(
71 MaterialTheme(
55 colorScheme = colorScheme,
72 colorScheme = colorScheme,
73+ typography = AppTypography,
56 content = content,
74 content = content,
57 )
75 )
58}
76}
app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+15
@@ -0,0 +1,15 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.material3.Typography
4+import androidx.compose.ui.text.TextStyle
5+import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.unit.sp
7+
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.
13+)
14+
15+val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/res/values-night/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="window_background">#0F1115</color>
4+</resources>
app/src/main/res/values-night/themes.xml+5−1
@@ -1,4 +1,8 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3− <style name="Theme.Opencode" parent="android:Theme.Material.NoActionBar" />
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>
7+ </style>
4</resources>
8</resources>
app/src/main/res/values/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="window_background">#F7F8FA</color>
4+</resources>
app/src/main/res/values/strings.xml+2−1
@@ -1,3 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
1<resources>
2<resources>
2− <string name="app_name">Opencode</string>
3+ <string name="app_name">opencode</string>
3</resources>
4</resources>
app/src/main/res/values/themes.xml+10−1
@@ -1,4 +1,13 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
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+ -->
3− <style name="Theme.Opencode" parent="android:Theme.Material.Light.NoActionBar" />
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>
12+ </style>
4</resources>
13</resources>
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktnew file+69
@@ -0,0 +1,69 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.test.runTest
6+import okhttp3.OkHttpClient
7+import okhttp3.Interceptor
8+import org.junit.Assert.assertEquals
9+import org.junit.Assert.assertTrue
10+import org.junit.Test
11+import java.io.IOException
12+
13+/**
14+ * Covers two related connect-screen bugs:
15+ * - the URL field must stay editable — normalizing per keystroke made it
16+ * "impossible to input" (it ate the "://" as you typed);
17+ * - a scheme-less URL must still be normalized to http:// on connect, and the
18+ * HTTP client must never crash on a malformed URL.
19+ */
20+class ConnectUrlTest {
21+ private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
22+
23+ /** A client that never touches the network, so Connect's probe can't hang. */
24+ private fun offlineCore(): OpencodeCore {
25+ val offline = OkHttpClient.Builder()
26+ .addInterceptor(Interceptor { throw IOException("offline in test") })
27+ .build()
28+ return OpencodeCore(CoroutineScope(Dispatchers.Unconfined), HttpClient(offline))
29+ }
30+
31+ @Test
32+ fun `typing preserves raw input verbatim`() {
33+ val core = core()
34+ // Mid-scheme text the user is still typing must not be rewritten.
35+ core.dispatch(Event.ServerUrlChanged("http:/"))
36+ assertEquals("http:/", core.view.value.serverUrl)
37+ }
38+
39+ @Test
40+ fun `connect normalizes a scheme-less url to http`() {
41+ val core = offlineCore()
42+ core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
43+ assertEquals("192.168.1.10:4096", core.view.value.serverUrl) // raw while typing
44+ core.dispatch(Event.Connect)
45+ assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl) // normalized on connect
46+ }
47+
48+ @Test
49+ fun `connect trims and keeps an explicit scheme`() {
50+ val core = offlineCore()
51+ core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
52+ core.dispatch(Event.Connect)
53+ assertEquals("https://host:4096", core.view.value.serverUrl)
54+ }
55+
56+ @Test
57+ fun `connect on a blank url reports an error`() {
58+ val core = core()
59+ core.dispatch(Event.ServerUrlChanged(" "))
60+ core.dispatch(Event.Connect)
61+ assertEquals("Enter a server URL", core.view.value.error)
62+ }
63+
64+ @Test
65+ fun `http client returns failure rather than throwing on an unparseable url`() = runTest {
66+ val result = HttpClient().get("not a valid url", auth = null)
67+ assertTrue("expected a Result.failure, got $result", result.isFailure)
68+ }
69+}
app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.ktdeleted−136
@@ -1,136 +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−
10−class PureCoreFfiTest {
11−
12− private fun createCore(): PureCoreFfi = PureCoreFfi()
13−
14− private fun PureCoreFfi.currentView(): ViewModel =
15− ViewModel.bincodeDeserialize(view())
16−
17− private fun PureCoreFfi.processEvent(event: Event): Requests {
18− val effects = update(event.bincodeSerialize())
19− return Requests.bincodeDeserialize(effects)
20− }
21−
22− private fun PureCoreFfi.resolveHttp(
23− requestId: UInt,
24− status: Int = 200,
25− body: String = "{}",
26− ): Requests {
27− val response = HttpResponse(
28− status = status.toUShort(),
29− headers = emptyList(),
30− body = com.novi.serde.Bytes(body.toByteArray()),
31− )
32− val result = HttpResult.Ok(response)
33− val effects = resolve(requestId, result.bincodeSerialize())
34− return Requests.bincodeDeserialize(effects)
35− }
36−
37− @Test
38− fun `start event sets default server URL`() {
39− val core = createCore()
40− core.processEvent(Event.Start)
41− val view = core.currentView()
42− assertEquals("http://localhost:4096", view.serverUrl)
43− assertEquals(Screen.CONNECT, view.screen)
44− }
45−
46− @Test
47− fun `server URL change updates view`() {
48− val core = createCore()
49− core.processEvent(Event.Start)
50− core.processEvent(Event.ServerUrlChanged("http://10.0.0.1:4096"))
51− val view = core.currentView()
52− assertEquals("http://10.0.0.1:4096", view.serverUrl)
53− }
54−
55− @Test
56− fun `connect emits HTTP effect`() {
57− val core = createCore()
58− core.processEvent(Event.Start)
59− val requests = core.processEvent(Event.Connect)
60− assertEquals(1, requests.value.size)
61− assertTrue(requests.value[0].effect is Effect.Http)
62− val view = core.currentView()
63− assertTrue(view.loading)
64− }
65−
66− @Test
67− fun `successful connect transitions to sessions screen`() {
68− val core = createCore()
69− core.processEvent(Event.Start)
70− val requests = core.processEvent(Event.Connect)
71− val httpReq = requests.value[0]
72− assertEquals(Effect.Http::class, httpReq.effect::class)
73−
74− // Simulate health check success — triggers LoadSessions
75− val followUpRequests = core.resolveHttp(httpReq.id, body = """{"healthy":true}""")
76− assertEquals(1, followUpRequests.value.size)
77−
78− // Simulate sessions loaded
79− val sessionsBody = """[{"id":"s1","title":"My Session"}]"""
80− val finalRequests = core.resolveHttp(followUpRequests.value[0].id, body = sessionsBody)
81− assertEquals(0, finalRequests.value.size)
82−
83− val view = core.currentView()
84− assertTrue(view.connected)
85− assertEquals(Screen.SESSIONS, view.screen)
86− assertEquals(1, view.sessions.size)
87− assertEquals("s1", view.sessions[0].id)
88− assertEquals("My Session", view.sessions[0].title)
89− }
90−
91− @Test
92− fun `failed connect shows error`() {
93− val core = createCore()
94− core.processEvent(Event.Start)
95− val requests = core.processEvent(Event.Connect)
96−
97− val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
98− val effects = core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
99− Requests.bincodeDeserialize(effects)
100−
101− val view = core.currentView()
102− assertNotNull(view.error)
103− assertFalse(view.loading)
104− }
105−
106− @Test
107− fun `dismiss error clears error`() {
108− val core = createCore()
109− core.processEvent(Event.Start)
110− val requests = core.processEvent(Event.Connect)
111−
112− val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
113− core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
114−
115− core.processEvent(Event.DismissError)
116− val view = core.currentView()
117− assertNull(view.error)
118− }
119−
120− @Test
121− fun `select session loads messages and transitions to chat`() {
122− val core = createCore()
123− core.processEvent(Event.Start)
124− val connectReqs = core.processEvent(Event.Connect)
125− core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
126− core.resolveHttp(0u, body = "[]") // won't find the id, but we can manually set sessions
127−
128− // Manually select a session
129− val selectReqs = core.processEvent(Event.SelectSession("session-123"))
130− assertTrue(selectReqs.value.isNotEmpty())
131−
132− val view = core.currentView()
133− assertEquals("session-123", view.currentSessionId)
134− assertEquals(Screen.CHAT, view.screen)
135− }
136−}
build.gradle.kts−1
@@ -2,6 +2,5 @@ plugins {
2 alias(libs.plugins.android.application) apply false
2 alias(libs.plugins.android.application) apply false
3 alias(libs.plugins.kotlin.android) apply false
3 alias(libs.plugins.kotlin.android) apply false
4 alias(libs.plugins.kotlin.compose) apply false
4 alias(libs.plugins.kotlin.compose) apply false
5− alias(libs.plugins.android.library) apply false
6 alias(libs.plugins.kotlin.serialization) apply false
5 alias(libs.plugins.kotlin.serialization) apply false
7}
6}
flake.lock+3−3
@@ -20,11 +20,11 @@
20 },
20 },
21 "nixpkgs": {
21 "nixpkgs": {
22 "locked": {
22 "locked": {
23− "lastModified": 1782467914,
23+ "lastModified": 1783224372,
24− "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
24+ "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
25 "owner": "NixOS",
25 "owner": "NixOS",
26 "repo": "nixpkgs",
26 "repo": "nixpkgs",
27− "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
27+ "rev": "d407951447dcd00442e97087bf374aad70c04cea",
28 "type": "github"
28 "type": "github"
29 },
29 },
30 "original": {
30 "original": {
flake.nix+29−19
@@ -1,5 +1,5 @@
1{
1{
2− description = "Dev environment for opencode-android (Kotlin / Jetpack Compose client for the opencode serve API)";
2+ description = "Dev environment for opencode-android — a Crux (Rust) core + Kotlin / Jetpack Compose shell";
3
3
4 inputs = {
4 inputs = {
5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -9,14 +9,17 @@
9 outputs = { self, nixpkgs, flake-utils }:
9 outputs = { self, nixpkgs, flake-utils }:
10 flake-utils.lib.eachDefaultSystem (system:
10 flake-utils.lib.eachDefaultSystem (system:
11 let
11 let
12− # Versions kept in sync with the Gradle project (compileSdk / build-tools).
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.
13− platformVersion = "37";
16+ platformVersion = "35";
14− buildToolsVersion = "37.0.0";
17+ buildToolsVersion = "35.0.0";
15
18
16 pkgs = import nixpkgs {
19 pkgs = import nixpkgs {
17 inherit system;
20 inherit system;
18 config = {
21 config = {
19− allowUnfree = true; # Android SDK is unfree
22+ allowUnfree = true; # the Android SDK is unfree
20 android_sdk.accept_license = true;
23 android_sdk.accept_license = true;
21 };
24 };
22 };
25 };
@@ -26,45 +29,52 @@
26 buildToolsVersions = [ buildToolsVersion ];
29 buildToolsVersions = [ buildToolsVersion ];
27 cmdLineToolsVersion = "latest";
30 cmdLineToolsVersion = "latest";
28
31
29− # Flip these on if the host has /dev/kvm and you want an AVD.
30 includeEmulator = false;
32 includeEmulator = false;
31 includeSystemImages = false;
33 includeSystemImages = false;
32− systemImageTypes = [ "google_apis" ];
33− abiVersions = [ "x86_64" ];
34
34
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.
35 includeNDK = false;
40 includeNDK = false;
36 };
41 };
37
42
38− sdk = android.androidsdk;
43+ sdk = android.androidsdk;
39− sdkRoot = "${sdk}/libexec/android-sdk";
44+ sdkRoot = "${sdk}/libexec/android-sdk";
40−
41− # Gradle's bundled aapt2 binary is dynamically linked against a loader
42− # path that does not exist on NixOS, so it segfaults. Point Gradle and
43− # Android Studio at the Nix-built aapt2 instead.
44 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45 in {
46 in {
46 devShells.default = pkgs.mkShell {
47 devShells.default = pkgs.mkShell {
47 buildInputs = [
48 buildInputs = [
49+ # JVM / Android toolchain
48 pkgs.jdk17
50 pkgs.jdk17
49 pkgs.gradle
51 pkgs.gradle
50 sdk
52 sdk
53+
54+ # Rust toolchain for the Crux core (build, test, typegen codegen)
55+ pkgs.rustc
56+ pkgs.cargo
51 ];
57 ];
52
58
53 ANDROID_HOME = sdkRoot;
59 ANDROID_HOME = sdkRoot;
54 ANDROID_SDK_ROOT = sdkRoot;
60 ANDROID_SDK_ROOT = sdkRoot;
55 JAVA_HOME = "${pkgs.jdk17}";
61 JAVA_HOME = "${pkgs.jdk17}";
56
62
57− # Override Gradle's bundled aapt2 (see note above).
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.
58 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
65 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
59
66
60 shellHook = ''
67 shellHook = ''
61 echo "opencode-android dev shell"
68 echo "opencode-android dev shell"
62− echo " JDK: $(java -version 2>&1 | head -1)"
69+ echo " JDK: $(java -version 2>&1 | head -1)"
70+ echo " Rust: $(rustc --version)"
71+ echo " Gradle: $(gradle --version 2>/dev/null | awk '/^Gradle/{print $2}')"
63 echo " ANDROID_HOME: $ANDROID_HOME"
72 echo " ANDROID_HOME: $ANDROID_HOME"
64− echo " aapt2: ${aapt2Path}"
65 echo ""
73 echo ""
74+ echo " Core: (cd shared && cargo test) # Crux core unit tests"
75+ echo " Types: (cd shared && cargo run --bin codegen)"
66− echo "Build: ./gradlew assembleDebug"
76+ echo " App: ./gradlew assembleDebug # build debug APK"
67− echo "Device: adb devices"
77+ echo " Unit: ./gradlew testDebugUnitTest"
68 '';
78 '';
69 };
79 };
70 });
80 });
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−1110
@@ -1,1110 +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 object Connect: Event {
167− override fun serialize(serializer: Serializer) {
168− serializer.increase_container_depth()
169− serializer.serialize_variant_index(2)
170− serializer.decrease_container_depth()
171− }
172−
173− fun deserialize(deserializer: Deserializer): Connect {
174− return Connect
175− }
176− }
177−
178− data object LoadSessions: Event {
179− override fun serialize(serializer: Serializer) {
180− serializer.increase_container_depth()
181− serializer.serialize_variant_index(3)
182− serializer.decrease_container_depth()
183− }
184−
185− fun deserialize(deserializer: Deserializer): LoadSessions {
186− return LoadSessions
187− }
188− }
189−
190− data class SelectSession(
191− val value: String,
192− ) : Event {
193− override fun serialize(serializer: Serializer) {
194− serializer.increase_container_depth()
195− serializer.serialize_variant_index(4)
196− serializer.serialize_str(value)
197− serializer.decrease_container_depth()
198− }
199−
200− companion object {
201− fun deserialize(deserializer: Deserializer): SelectSession {
202− deserializer.increase_container_depth()
203− val value = deserializer.deserialize_str()
204− deserializer.decrease_container_depth()
205− return SelectSession(value)
206− }
207− }
208− }
209−
210− data object CreateSession: Event {
211− override fun serialize(serializer: Serializer) {
212− serializer.increase_container_depth()
213− serializer.serialize_variant_index(5)
214− serializer.decrease_container_depth()
215− }
216−
217− fun deserialize(deserializer: Deserializer): CreateSession {
218− return CreateSession
219− }
220− }
221−
222− data object SessionCreated: Event {
223− override fun serialize(serializer: Serializer) {
224− serializer.increase_container_depth()
225− serializer.serialize_variant_index(6)
226− serializer.decrease_container_depth()
227− }
228−
229− fun deserialize(deserializer: Deserializer): SessionCreated {
230− return SessionCreated
231− }
232− }
233−
234− data class LoadMessages(
235− val value: String,
236− ) : Event {
237− override fun serialize(serializer: Serializer) {
238− serializer.increase_container_depth()
239− serializer.serialize_variant_index(7)
240− serializer.serialize_str(value)
241− serializer.decrease_container_depth()
242− }
243−
244− companion object {
245− fun deserialize(deserializer: Deserializer): LoadMessages {
246− deserializer.increase_container_depth()
247− val value = deserializer.deserialize_str()
248− deserializer.decrease_container_depth()
249− return LoadMessages(value)
250− }
251− }
252− }
253−
254− data object MessagesLoaded: Event {
255− override fun serialize(serializer: Serializer) {
256− serializer.increase_container_depth()
257− serializer.serialize_variant_index(8)
258− serializer.decrease_container_depth()
259− }
260−
261− fun deserialize(deserializer: Deserializer): MessagesLoaded {
262− return MessagesLoaded
263− }
264− }
265−
266− data class SendMessage(
267− val value: String,
268− ) : Event {
269− override fun serialize(serializer: Serializer) {
270− serializer.increase_container_depth()
271− serializer.serialize_variant_index(9)
272− serializer.serialize_str(value)
273− serializer.decrease_container_depth()
274− }
275−
276− companion object {
277− fun deserialize(deserializer: Deserializer): SendMessage {
278− deserializer.increase_container_depth()
279− val value = deserializer.deserialize_str()
280− deserializer.decrease_container_depth()
281− return SendMessage(value)
282− }
283− }
284− }
285−
286− data object MessageSent: Event {
287− override fun serialize(serializer: Serializer) {
288− serializer.increase_container_depth()
289− serializer.serialize_variant_index(10)
290− serializer.decrease_container_depth()
291− }
292−
293− fun deserialize(deserializer: Deserializer): MessageSent {
294− return MessageSent
295− }
296− }
297−
298− data class EventReceived(
299− val value: String,
300− ) : Event {
301− override fun serialize(serializer: Serializer) {
302− serializer.increase_container_depth()
303− serializer.serialize_variant_index(11)
304− serializer.serialize_str(value)
305− serializer.decrease_container_depth()
306− }
307−
308− companion object {
309− fun deserialize(deserializer: Deserializer): EventReceived {
310− deserializer.increase_container_depth()
311− val value = deserializer.deserialize_str()
312− deserializer.decrease_container_depth()
313− return EventReceived(value)
314− }
315− }
316− }
317−
318− data class NavigateToChat(
319− val value: String,
320− ) : Event {
321− override fun serialize(serializer: Serializer) {
322− serializer.increase_container_depth()
323− serializer.serialize_variant_index(12)
324− serializer.serialize_str(value)
325− serializer.decrease_container_depth()
326− }
327−
328− companion object {
329− fun deserialize(deserializer: Deserializer): NavigateToChat {
330− deserializer.increase_container_depth()
331− val value = deserializer.deserialize_str()
332− deserializer.decrease_container_depth()
333− return NavigateToChat(value)
334− }
335− }
336− }
337−
338− data object NavigateToSessions: Event {
339− override fun serialize(serializer: Serializer) {
340− serializer.increase_container_depth()
341− serializer.serialize_variant_index(13)
342− serializer.decrease_container_depth()
343− }
344−
345− fun deserialize(deserializer: Deserializer): NavigateToSessions {
346− return NavigateToSessions
347− }
348− }
349−
350− data object DismissError: Event {
351− override fun serialize(serializer: Serializer) {
352− serializer.increase_container_depth()
353− serializer.serialize_variant_index(14)
354− serializer.decrease_container_depth()
355− }
356−
357− fun deserialize(deserializer: Deserializer): DismissError {
358− return DismissError
359− }
360− }
361−
362− companion object {
363− @Throws(DeserializationError::class)
364− fun deserialize(deserializer: Deserializer): Event {
365− val index = deserializer.deserialize_variant_index()
366− return when (index) {
367− 0 -> Start.deserialize(deserializer)
368− 1 -> ServerUrlChanged.deserialize(deserializer)
369− 2 -> Connect.deserialize(deserializer)
370− 3 -> LoadSessions.deserialize(deserializer)
371− 4 -> SelectSession.deserialize(deserializer)
372− 5 -> CreateSession.deserialize(deserializer)
373− 6 -> SessionCreated.deserialize(deserializer)
374− 7 -> LoadMessages.deserialize(deserializer)
375− 8 -> MessagesLoaded.deserialize(deserializer)
376− 9 -> SendMessage.deserialize(deserializer)
377− 10 -> MessageSent.deserialize(deserializer)
378− 11 -> EventReceived.deserialize(deserializer)
379− 12 -> NavigateToChat.deserialize(deserializer)
380− 13 -> NavigateToSessions.deserialize(deserializer)
381− 14 -> DismissError.deserialize(deserializer)
382− else -> throw DeserializationError("Unknown variant index for Event: $index")
383− }
384− }
385−
386− @Throws(DeserializationError::class)
387− fun bincodeDeserialize(input: ByteArray?): Event {
388− if (input == null) {
389− throw DeserializationError("Cannot deserialize null array")
390− }
391− val deserializer = BincodeDeserializer(input)
392− val value = deserialize(deserializer)
393− if (deserializer.get_buffer_offset() < input.size) {
394− throw DeserializationError("Some input bytes were not read")
395− }
396− return value
397− }
398− }
399−}
400−
401−/// An error produced when an HTTP request fails.
402−///
403−/// Variants fall into two groups:
404−///
405−/// **Transport errors** — generated by the shell when it cannot complete the HTTP
406−/// exchange. These cross the FFI boundary and are serialized in the protocol:
407−/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
408−///
409−/// **Processing errors** — generated on the Rust side after a response arrives.
410−/// These are never serialized or visible to shells:
411−///
412−/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
413−/// a 4xx or 5xx status. At the *protocol* level these arrive as
414−/// [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
415−/// them here, so app code using `crux_http::Result<Response<T>>` will see them as
416−/// `Err(HttpError::Http { code, .. })`.
417−/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
418−sealed interface HttpError {
419− fun serialize(serializer: Serializer)
420−
421− fun bincodeSerialize(): ByteArray {
422− val serializer = BincodeSerializer()
423− serialize(serializer)
424− return serializer.get_bytes()
425− }
426−
427− /// The request URL could not be parsed.
428− data class Url(
429− val value: String,
430− ) : HttpError {
431− override fun serialize(serializer: Serializer) {
432− serializer.increase_container_depth()
433− serializer.serialize_variant_index(0)
434− serializer.serialize_str(value)
435− serializer.decrease_container_depth()
436− }
437−
438− companion object {
439− fun deserialize(deserializer: Deserializer): Url {
440− deserializer.increase_container_depth()
441− val value = deserializer.deserialize_str()
442− deserializer.decrease_container_depth()
443− return Url(value)
444− }
445− }
446− }
447−
448− /// An IO error prevented the request from completing.
449− data class Io(
450− val value: String,
451− ) : HttpError {
452− override fun serialize(serializer: Serializer) {
453− serializer.increase_container_depth()
454− serializer.serialize_variant_index(1)
455− serializer.serialize_str(value)
456− serializer.decrease_container_depth()
457− }
458−
459− companion object {
460− fun deserialize(deserializer: Deserializer): Io {
461− deserializer.increase_container_depth()
462− val value = deserializer.deserialize_str()
463− deserializer.decrease_container_depth()
464− return Io(value)
465− }
466− }
467− }
468−
469− /// The request timed out before a response was received.
470− data object Timeout: HttpError {
471− override fun serialize(serializer: Serializer) {
472− serializer.increase_container_depth()
473− serializer.serialize_variant_index(2)
474− serializer.decrease_container_depth()
475− }
476−
477− fun deserialize(deserializer: Deserializer): Timeout {
478− return Timeout
479− }
480− }
481−
482− companion object {
483− @Throws(DeserializationError::class)
484− fun deserialize(deserializer: Deserializer): HttpError {
485− val index = deserializer.deserialize_variant_index()
486− return when (index) {
487− 0 -> Url.deserialize(deserializer)
488− 1 -> Io.deserialize(deserializer)
489− 2 -> Timeout.deserialize(deserializer)
490− else -> throw DeserializationError("Unknown variant index for HttpError: $index")
491− }
492− }
493−
494− @Throws(DeserializationError::class)
495− fun bincodeDeserialize(input: ByteArray?): HttpError {
496− if (input == null) {
497− throw DeserializationError("Cannot deserialize null array")
498− }
499− val deserializer = BincodeDeserializer(input)
500− val value = deserialize(deserializer)
501− if (deserializer.get_buffer_offset() < input.size) {
502− throw DeserializationError("Some input bytes were not read")
503− }
504− return value
505− }
506− }
507−}
508−
509−data class HttpHeader(
510− val name: String,
511− val value: String,
512−) {
513− fun serialize(serializer: Serializer) {
514− serializer.increase_container_depth()
515− serializer.serialize_str(name)
516− serializer.serialize_str(value)
517− serializer.decrease_container_depth()
518− }
519−
520− fun bincodeSerialize(): ByteArray {
521− val serializer = BincodeSerializer()
522− serialize(serializer)
523− return serializer.get_bytes()
524− }
525−
526− companion object {
527− fun deserialize(deserializer: Deserializer): HttpHeader {
528− deserializer.increase_container_depth()
529− val name = deserializer.deserialize_str()
530− val value = deserializer.deserialize_str()
531− deserializer.decrease_container_depth()
532− return HttpHeader(name, value)
533− }
534−
535− @Throws(DeserializationError::class)
536− fun bincodeDeserialize(input: ByteArray?): HttpHeader {
537− if (input == null) {
538− throw DeserializationError("Cannot deserialize null array")
539− }
540− val deserializer = BincodeDeserializer(input)
541− val value = deserialize(deserializer)
542− if (deserializer.get_buffer_offset() < input.size) {
543− throw DeserializationError("Some input bytes were not read")
544− }
545− return value
546− }
547− }
548−}
549−
550−/// A raw HTTP request, as sent to the shell over the protocol boundary.
551−///
552−/// # No header validation
553−///
554−/// All fields are plain strings. Header names and values are carried as-is with no
555−/// validation against the HTTP specification. This is intentional: `HttpRequest` is a
556−/// cross-language data carrier deserialised by Swift, Kotlin, and TypeScript shells;
557−/// Rust's `http`-crate validation rules cannot be enforced on the other side of that
558−/// boundary.
559−///
560−/// **Shell authors must not assume that header names or values are well-formed.**
561−/// Pass them to your underlying HTTP client as-is — it will apply its own rules.
562−///
563−/// For the ergonomic Rust-side builder that *does* validate header values (and panics
564−/// on invalid input), see [`crate::command::RequestBuilder`].
565−data class HttpRequest(
566− val method: String,
567− val url: String,
568− val headers: List<soy.iko.opencode.core.HttpHeader>,
569− val body: Bytes,
570−) {
571− fun serialize(serializer: Serializer) {
572− serializer.increase_container_depth()
573− serializer.serialize_str(method)
574− serializer.serialize_str(url)
575− headers.serialize(serializer) {
576− it.serialize(serializer)
577− }
578− serializer.serialize_bytes(body)
579− serializer.decrease_container_depth()
580− }
581−
582− fun bincodeSerialize(): ByteArray {
583− val serializer = BincodeSerializer()
584− serialize(serializer)
585− return serializer.get_bytes()
586− }
587−
588− companion object {
589− fun deserialize(deserializer: Deserializer): HttpRequest {
590− deserializer.increase_container_depth()
591− val method = deserializer.deserialize_str()
592− val url = deserializer.deserialize_str()
593− val headers =
594− deserializer.deserializeListOf {
595− soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
596− }
597− val body = deserializer.deserialize_bytes()
598− deserializer.decrease_container_depth()
599− return HttpRequest(method, url, headers, body)
600− }
601−
602− @Throws(DeserializationError::class)
603− fun bincodeDeserialize(input: ByteArray?): HttpRequest {
604− if (input == null) {
605− throw DeserializationError("Cannot deserialize null array")
606− }
607− val deserializer = BincodeDeserializer(input)
608− val value = deserialize(deserializer)
609− if (deserializer.get_buffer_offset() < input.size) {
610− throw DeserializationError("Some input bytes were not read")
611− }
612− return value
613− }
614− }
615−}
616−
617−data class HttpResponse(
618− val status: UShort,
619− val headers: List<soy.iko.opencode.core.HttpHeader>,
620− val body: Bytes,
621−) {
622− fun serialize(serializer: Serializer) {
623− serializer.increase_container_depth()
624− serializer.serialize_u16(status)
625− headers.serialize(serializer) {
626− it.serialize(serializer)
627− }
628− serializer.serialize_bytes(body)
629− serializer.decrease_container_depth()
630− }
631−
632− fun bincodeSerialize(): ByteArray {
633− val serializer = BincodeSerializer()
634− serialize(serializer)
635− return serializer.get_bytes()
636− }
637−
638− companion object {
639− fun deserialize(deserializer: Deserializer): HttpResponse {
640− deserializer.increase_container_depth()
641− val status = deserializer.deserialize_u16()
642− val headers =
643− deserializer.deserializeListOf {
644− soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
645− }
646− val body = deserializer.deserialize_bytes()
647− deserializer.decrease_container_depth()
648− return HttpResponse(status, headers, body)
649− }
650−
651− @Throws(DeserializationError::class)
652− fun bincodeDeserialize(input: ByteArray?): HttpResponse {
653− if (input == null) {
654− throw DeserializationError("Cannot deserialize null array")
655− }
656− val deserializer = BincodeDeserializer(input)
657− val value = deserialize(deserializer)
658− if (deserializer.get_buffer_offset() < input.size) {
659− throw DeserializationError("Some input bytes were not read")
660− }
661− return value
662− }
663− }
664−}
665−
666−/// The result of an HTTP request, as returned by the shell over the protocol boundary.
667−///
668−/// # Status codes are not errors
669−///
670−/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
671−/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
672−/// reach the server at all) produce [`HttpResult::Err`].
673−///
674−/// To act on an error status, inspect [`HttpResponse::status`]:
675−///
676−/// ```
677−/// # use crux_http::protocol::{HttpResult, HttpResponse};
678−/// # use crux_http::HttpError;
679−/// # fn handle(result: HttpResult) {
680−/// match result {
681−/// HttpResult::Ok(response) if response.status == 200 => { /* success */ }
682−/// HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
683−/// HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
684−/// HttpResult::Ok(_) => { /* other status */ }
685−/// HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
686−/// }
687−/// # }
688−/// ```
689−sealed interface HttpResult {
690− fun serialize(serializer: Serializer)
691−
692− fun bincodeSerialize(): ByteArray {
693− val serializer = BincodeSerializer()
694− serialize(serializer)
695− return serializer.get_bytes()
696− }
697−
698− /// The shell completed the HTTP exchange. The response may carry any status code,
699− /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
700− data class Ok(
701− val value: soy.iko.opencode.core.HttpResponse,
702− ) : HttpResult {
703− override fun serialize(serializer: Serializer) {
704− serializer.increase_container_depth()
705− serializer.serialize_variant_index(0)
706− value.serialize(serializer)
707− serializer.decrease_container_depth()
708− }
709−
710− companion object {
711− fun deserialize(deserializer: Deserializer): Ok {
712− deserializer.increase_container_depth()
713− val value = soy.iko.opencode.core.HttpResponse.deserialize(deserializer)
714− deserializer.decrease_container_depth()
715− return Ok(value)
716− }
717− }
718− }
719−
720− /// The shell could not complete the HTTP exchange due to a transport-level failure.
721− /// See [`HttpError`] for the possible causes.
722− data class Err(
723− val value: soy.iko.opencode.core.HttpError,
724− ) : HttpResult {
725− override fun serialize(serializer: Serializer) {
726− serializer.increase_container_depth()
727− serializer.serialize_variant_index(1)
728− value.serialize(serializer)
729− serializer.decrease_container_depth()
730− }
731−
732− companion object {
733− fun deserialize(deserializer: Deserializer): Err {
734− deserializer.increase_container_depth()
735− val value = soy.iko.opencode.core.HttpError.deserialize(deserializer)
736− deserializer.decrease_container_depth()
737− return Err(value)
738− }
739− }
740− }
741−
742− companion object {
743− @Throws(DeserializationError::class)
744− fun deserialize(deserializer: Deserializer): HttpResult {
745− val index = deserializer.deserialize_variant_index()
746− return when (index) {
747− 0 -> Ok.deserialize(deserializer)
748− 1 -> Err.deserialize(deserializer)
749− else -> throw DeserializationError("Unknown variant index for HttpResult: $index")
750− }
751− }
752−
753− @Throws(DeserializationError::class)
754− fun bincodeDeserialize(input: ByteArray?): HttpResult {
755− if (input == null) {
756− throw DeserializationError("Cannot deserialize null array")
757− }
758− val deserializer = BincodeDeserializer(input)
759− val value = deserialize(deserializer)
760− if (deserializer.get_buffer_offset() < input.size) {
761− throw DeserializationError("Some input bytes were not read")
762− }
763− return value
764− }
765− }
766−}
767−
768−data class MessageView(
769− val id: String,
770− val role: String,
771− val text: String,
772− val time: ULong,
773−) {
774− fun serialize(serializer: Serializer) {
775− serializer.increase_container_depth()
776− serializer.serialize_str(id)
777− serializer.serialize_str(role)
778− serializer.serialize_str(text)
779− serializer.serialize_u64(time)
780− serializer.decrease_container_depth()
781− }
782−
783− fun bincodeSerialize(): ByteArray {
784− val serializer = BincodeSerializer()
785− serialize(serializer)
786− return serializer.get_bytes()
787− }
788−
789− companion object {
790− fun deserialize(deserializer: Deserializer): MessageView {
791− deserializer.increase_container_depth()
792− val id = deserializer.deserialize_str()
793− val role = deserializer.deserialize_str()
794− val text = deserializer.deserialize_str()
795− val time = deserializer.deserialize_u64()
796− deserializer.decrease_container_depth()
797− return MessageView(id, role, text, time)
798− }
799−
800− @Throws(DeserializationError::class)
801− fun bincodeDeserialize(input: ByteArray?): MessageView {
802− if (input == null) {
803− throw DeserializationError("Cannot deserialize null array")
804− }
805− val deserializer = BincodeDeserializer(input)
806− val value = deserialize(deserializer)
807− if (deserializer.get_buffer_offset() < input.size) {
808− throw DeserializationError("Some input bytes were not read")
809− }
810− return value
811− }
812− }
813−}
814−
815−/// The single operation `Render` implements.
816−data object RenderOperation {
817− fun serialize(serializer: Serializer) {}
818−
819− fun bincodeSerialize(): ByteArray {
820− val serializer = BincodeSerializer()
821− serialize(serializer)
822− return serializer.get_bytes()
823− }
824−
825− fun deserialize(deserializer: Deserializer): RenderOperation {
826− return RenderOperation
827− }
828−
829− @Throws(DeserializationError::class)
830− fun bincodeDeserialize(input: ByteArray?): RenderOperation {
831− if (input == null) {
832− throw DeserializationError("Cannot deserialize null array")
833− }
834− val deserializer = BincodeDeserializer(input)
835− val value = deserialize(deserializer)
836− if (deserializer.get_buffer_offset() < input.size) {
837− throw DeserializationError("Some input bytes were not read")
838− }
839− return value
840− }
841−}
842−
843−/// Request for a side-effect passed from the Core to the Shell.
844−///
845−/// The `EffectId` links the `Request` with the corresponding call to [`Core::resolve`] to pass the data back
846−/// to the [`App::update`] function (wrapped in the event provided to the capability originating the effect).
847−data class Request(
848− val id: UInt,
849− val effect: soy.iko.opencode.core.Effect,
850−) {
851− fun serialize(serializer: Serializer) {
852− serializer.increase_container_depth()
853− serializer.serialize_u32(id)
854− effect.serialize(serializer)
855− serializer.decrease_container_depth()
856− }
857−
858− fun bincodeSerialize(): ByteArray {
859− val serializer = BincodeSerializer()
860− serialize(serializer)
861− return serializer.get_bytes()
862− }
863−
864− companion object {
865− fun deserialize(deserializer: Deserializer): Request {
866− deserializer.increase_container_depth()
867− val id = deserializer.deserialize_u32()
868− val effect = soy.iko.opencode.core.Effect.deserialize(deserializer)
869− deserializer.decrease_container_depth()
870− return Request(id, effect)
871− }
872−
873− @Throws(DeserializationError::class)
874− fun bincodeDeserialize(input: ByteArray?): Request {
875− if (input == null) {
876− throw DeserializationError("Cannot deserialize null array")
877− }
878− val deserializer = BincodeDeserializer(input)
879− val value = deserialize(deserializer)
880− if (deserializer.get_buffer_offset() < input.size) {
881− throw DeserializationError("Some input bytes were not read")
882− }
883− return value
884− }
885− }
886−}
887−
888−/// A batch of effect requests from the Core to the Shell, as serialised by
889−/// [`Bridge::update`] and [`Bridge::resolve`].
890−///
891−/// The wire format is identical to `Vec<Request<Eff>>` (the newtype is
892−/// `serde(transparent)`), so existing shell code that already deserialises
893−/// a `Vec<Request>` remains binary-compatible.
894−///
895−/// Registering this type with the type-generation system causes the code
896−/// generators to emit a `Requests` type (with a `value` field containing the
897−/// list) together with a top-level `bincodeDeserialize` / `BincodeDeserialize`
898−/// helper, replacing the hand-written extension files that were previously
899−/// appended by `add_extensions()`.
900−data class Requests(
901− val value: List<soy.iko.opencode.core.Request>,
902−) {
903− fun serialize(serializer: Serializer) {
904− serializer.increase_container_depth()
905− value.serialize(serializer) {
906− it.serialize(serializer)
907− }
908− serializer.decrease_container_depth()
909− }
910−
911− fun bincodeSerialize(): ByteArray {
912− val serializer = BincodeSerializer()
913− serialize(serializer)
914− return serializer.get_bytes()
915− }
916−
917− companion object {
918− fun deserialize(deserializer: Deserializer): Requests {
919− deserializer.increase_container_depth()
920− val value =
921− deserializer.deserializeListOf {
922− soy.iko.opencode.core.Request.deserialize(deserializer)
923− }
924− deserializer.decrease_container_depth()
925− return Requests(value)
926− }
927−
928− @Throws(DeserializationError::class)
929− fun bincodeDeserialize(input: ByteArray?): Requests {
930− if (input == null) {
931− throw DeserializationError("Cannot deserialize null array")
932− }
933− val deserializer = BincodeDeserializer(input)
934− val value = deserialize(deserializer)
935− if (deserializer.get_buffer_offset() < input.size) {
936− throw DeserializationError("Some input bytes were not read")
937− }
938− return value
939− }
940− }
941−}
942−
943−enum class Screen {
944− CONNECT,
945− SESSIONS,
946− CHAT;
947−
948− fun serialize(serializer: Serializer) {
949− serializer.increase_container_depth()
950− serializer.serialize_variant_index(ordinal)
951− serializer.decrease_container_depth()
952− }
953−
954− fun bincodeSerialize(): ByteArray {
955− val serializer = BincodeSerializer()
956− serialize(serializer)
957− return serializer.get_bytes()
958− }
959−
960− companion object {
961− @Throws(DeserializationError::class)
962− fun deserialize(deserializer: Deserializer): Screen {
963− deserializer.increase_container_depth()
964− val index = deserializer.deserialize_variant_index()
965− deserializer.decrease_container_depth()
966− return when (index) {
967− 0 -> CONNECT
968− 1 -> SESSIONS
969− 2 -> CHAT
970− else -> throw DeserializationError("Unknown variant index for Screen: $index")
971− }
972− }
973−
974− @Throws(DeserializationError::class)
975− fun bincodeDeserialize(input: ByteArray?): Screen {
976− if (input == null) {
977− throw DeserializationError("Cannot deserialize null array")
978− }
979− val deserializer = BincodeDeserializer(input)
980− val value = deserialize(deserializer)
981− if (deserializer.get_buffer_offset() < input.size) {
982− throw DeserializationError("Some input bytes were not read")
983− }
984− return value
985− }
986− }
987−}
988−
989−data class SessionView(
990− val id: String,
991− val title: String,
992−) {
993− fun serialize(serializer: Serializer) {
994− serializer.increase_container_depth()
995− serializer.serialize_str(id)
996− serializer.serialize_str(title)
997− serializer.decrease_container_depth()
998− }
999−
1000− fun bincodeSerialize(): ByteArray {
1001− val serializer = BincodeSerializer()
1002− serialize(serializer)
1003− return serializer.get_bytes()
1004− }
1005−
1006− companion object {
1007− fun deserialize(deserializer: Deserializer): SessionView {
1008− deserializer.increase_container_depth()
1009− val id = deserializer.deserialize_str()
1010− val title = deserializer.deserialize_str()
1011− deserializer.decrease_container_depth()
1012− return SessionView(id, title)
1013− }
1014−
1015− @Throws(DeserializationError::class)
1016− fun bincodeDeserialize(input: ByteArray?): SessionView {
1017− if (input == null) {
1018− throw DeserializationError("Cannot deserialize null array")
1019− }
1020− val deserializer = BincodeDeserializer(input)
1021− val value = deserialize(deserializer)
1022− if (deserializer.get_buffer_offset() < input.size) {
1023− throw DeserializationError("Some input bytes were not read")
1024− }
1025− return value
1026− }
1027− }
1028−}
1029−
1030−data class ViewModel(
1031− val screen: soy.iko.opencode.core.Screen,
1032− val serverUrl: String,
1033− val connected: Boolean,
1034− val loading: Boolean,
1035− val error: String? = null,
1036− val sessions: List<soy.iko.opencode.core.SessionView>,
1037− val currentSessionId: String? = null,
1038− val messages: List<soy.iko.opencode.core.MessageView>,
1039− val draftMessage: String,
1040−) {
1041− fun serialize(serializer: Serializer) {
1042− serializer.increase_container_depth()
1043− screen.serialize(serializer)
1044− serializer.serialize_str(serverUrl)
1045− serializer.serialize_bool(connected)
1046− serializer.serialize_bool(loading)
1047− error.serializeOptionOf(serializer) {
1048− serializer.serialize_str(it)
1049− }
1050− sessions.serialize(serializer) {
1051− it.serialize(serializer)
1052− }
1053− currentSessionId.serializeOptionOf(serializer) {
1054− serializer.serialize_str(it)
1055− }
1056− messages.serialize(serializer) {
1057− it.serialize(serializer)
1058− }
1059− serializer.serialize_str(draftMessage)
1060− serializer.decrease_container_depth()
1061− }
1062−
1063− fun bincodeSerialize(): ByteArray {
1064− val serializer = BincodeSerializer()
1065− serialize(serializer)
1066− return serializer.get_bytes()
1067− }
1068−
1069− companion object {
1070− fun deserialize(deserializer: Deserializer): ViewModel {
1071− deserializer.increase_container_depth()
1072− val screen = soy.iko.opencode.core.Screen.deserialize(deserializer)
1073− val serverUrl = deserializer.deserialize_str()
1074− val connected = deserializer.deserialize_bool()
1075− val loading = deserializer.deserialize_bool()
1076− val error =
1077− deserializer.deserializeOptionOf {
1078− deserializer.deserialize_str()
1079− }
1080− val sessions =
1081− deserializer.deserializeListOf {
1082− soy.iko.opencode.core.SessionView.deserialize(deserializer)
1083− }
1084− val currentSessionId =
1085− deserializer.deserializeOptionOf {
1086− deserializer.deserialize_str()
1087− }
1088− val messages =
1089− deserializer.deserializeListOf {
1090− soy.iko.opencode.core.MessageView.deserialize(deserializer)
1091− }
1092− val draftMessage = deserializer.deserialize_str()
1093− deserializer.decrease_container_depth()
1094− return ViewModel(screen, serverUrl, connected, loading, error, sessions, currentSessionId, messages, draftMessage)
1095− }
1096−
1097− @Throws(DeserializationError::class)
1098− fun bincodeDeserialize(input: ByteArray?): ViewModel {
1099− if (input == null) {
1100− throw DeserializationError("Cannot deserialize null array")
1101− }
1102− val deserializer = BincodeDeserializer(input)
1103− val value = deserialize(deserializer)
1104− if (deserializer.get_buffer_offset() < input.size) {
1105− throw DeserializationError("Some input bytes were not read")
1106− }
1107− return value
1108− }
1109− }
1110−}
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+9−3
@@ -1,4 +1,10 @@
1−org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2+org.gradle.caching=true
3+org.gradle.configuration-cache=true
4+
5+# AndroidX
2android.useAndroidX=true
6android.useAndroidX=true
3−android.suppressUnsupportedCompileSdk=37.0
7+android.nonTransitiveRClass=true
4−android.aapt2FromMavenOverride=/nix/store/66v4wbs7lmdb534pvp3wffqg1k5bgwq0-androidsdk/libexec/android-sdk/build-tools/37.0.0/aapt2
8+
9+# Kotlin
10+kotlin.code.style=official
gradle/libs.versions.toml+23−29
@@ -1,50 +1,44 @@
1[versions]
1[versions]
2−agp = "8.13.2"
2+agp = "8.7.3"
3−kotlin = "2.3.0"
3+kotlin = "2.0.21"
4−coreKtx = "1.17.0"
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"
5junit = "4.13.2"
11junit = "4.13.2"
6−junitVersion = "1.3.0"
12+androidxJunit = "1.2.1"
7−espressoCore = "3.7.0"
8−lifecycleRuntimeKtx = "2.10.0"
9−activityCompose = "1.12.3"
10−composeBom = "2026.01.01"
11−lifecycle = "2.10.0"
12−ktor = "3.1.3"
13−kotlinxCoroutines = "1.10.2"
14−navigationCompose = "2.9.8"
15
13
16[libraries]
14[libraries]
17androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
15androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
18−junit = { group = "junit", name = "junit", version.ref = "junit" }
16+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
19−androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
17+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
20−androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
18+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
21−androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
19+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
22androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
20androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21+
23androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
22androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
24androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
23androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
25androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
24androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
26androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
25androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
27androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
26androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
28−androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
29−androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
30androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
27androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
31androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
28androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
32−lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
33−androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
34
29
35−ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
30+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
36−ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
37−ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
38−ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
31+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
39−ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
40−ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
41
32
42−kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
33+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
34+okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
35+
36+junit = { group = "junit", name = "junit", version.ref = "junit" }
43−kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
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" }
44
39
45[plugins]
40[plugins]
46android-application = { id = "com.android.application", version.ref = "agp" }
41android-application = { id = "com.android.application", version.ref = "agp" }
47−android-library = { id = "com.android.library", version.ref = "agp" }
48kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
42kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
49kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
43kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
50kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
44kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
gradle/wrapper/gradle-wrapper.jarnew file

Binary file not shown.

settings.gradle.kts+3−1
@@ -21,5 +21,7 @@ dependencyResolutionManagement {
21}
21}
22
22
23rootProject.name = "opencode-android"
23rootProject.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.
24include(":app")
27include(":app")
25−include(":shared")
shared/Cargo.lock+12−361
@@ -2,71 +2,12 @@
2# It is not intended for manual editing.
2# It is not intended for manual editing.
3version = 4
3version = 4
4
4
5−[[package]]
6−name = "aho-corasick"
7−version = "1.1.4"
8−source = "registry+https://github.com/rust-lang/crates.io-index"
9−checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10−dependencies = [
11− "memchr",
12−]
13−
14[[package]]
5[[package]]
15name = "allocator-api2"
6name = "allocator-api2"
16version = "0.2.21"
7version = "0.2.21"
17source = "registry+https://github.com/rust-lang/crates.io-index"
8source = "registry+https://github.com/rust-lang/crates.io-index"
18checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
9checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19
10
20−[[package]]
21−name = "anstream"
22−version = "1.0.0"
23−source = "registry+https://github.com/rust-lang/crates.io-index"
24−checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
25−dependencies = [
26− "anstyle",
27− "anstyle-parse",
28− "anstyle-query",
29− "anstyle-wincon",
30− "colorchoice",
31− "is_terminal_polyfill",
32− "utf8parse",
33−]
34−
35−[[package]]
36−name = "anstyle"
37−version = "1.0.14"
38−source = "registry+https://github.com/rust-lang/crates.io-index"
39−checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
40−
41−[[package]]
42−name = "anstyle-parse"
43−version = "1.0.0"
44−source = "registry+https://github.com/rust-lang/crates.io-index"
45−checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
46−dependencies = [
47− "utf8parse",
48−]
49−
50−[[package]]
51−name = "anstyle-query"
52−version = "1.1.5"
53−source = "registry+https://github.com/rust-lang/crates.io-index"
54−checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
55−dependencies = [
56− "windows-sys",
57−]
58−
59−[[package]]
60−name = "anstyle-wincon"
61−version = "3.0.11"
62−source = "registry+https://github.com/rust-lang/crates.io-index"
63−checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
64−dependencies = [
65− "anstyle",
66− "once_cell_polyfill",
67− "windows-sys",
68−]
69−
70[[package]]
11[[package]]
71name = "anyhow"
12name = "anyhow"
72version = "1.0.103"
13version = "1.0.103"
@@ -91,95 +32,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
91checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
32checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
92
33
93[[package]]
34[[package]]
94−name = "bincode"
35+name = "base64"
95−version = "1.3.3"
36+version = "0.22.1"
96−source = "registry+https://github.com/rust-lang/crates.io-index"
97−checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
98−dependencies = [
99− "serde",
100−]
101−
102−[[package]]
103−name = "boltffi"
104−version = "0.27.3"
105−source = "registry+https://github.com/rust-lang/crates.io-index"
106−checksum = "a52185e3efe73ae1e37d4a12ecfc51b03392b3bd6fa677b49bf4e6f4eb30d033"
107−dependencies = [
108− "boltffi_core",
109− "boltffi_macros",
110−]
111−
112−[[package]]
113−name = "boltffi_ast"
114−version = "0.27.3"
115source = "registry+https://github.com/rust-lang/crates.io-index"
37source = "registry+https://github.com/rust-lang/crates.io-index"
116−checksum = "447bff7f815366882b6c7152284cf60b35bda4ee4920c20f9ca702b3de5d517e"
38+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
117−dependencies = [
118− "serde",
119−]
120
39
121[[package]]
40[[package]]
122−name = "boltffi_binding"
41+name = "bincode"
123−version = "0.27.3"
42+version = "1.3.3"
124−source = "registry+https://github.com/rust-lang/crates.io-index"
125−checksum = "f162e2813a4168530ee144a411921c89b2148e2aedda2327b7f9c46cd57e50d0"
126−dependencies = [
127− "boltffi_ast",
128− "serde",
129− "serde_json",
130−]
131−
132−[[package]]
133−name = "boltffi_core"
134−version = "0.27.3"
135−source = "registry+https://github.com/rust-lang/crates.io-index"
136−checksum = "f6df0e010773b88cf3888511de299425dce2ed0458c9f9d02f454e23590ac1e8"
137−dependencies = [
138− "boltffi_macros",
139− "url",
140− "uuid",
141−]
142−
143−[[package]]
144−name = "boltffi_ffi_rules"
145−version = "0.27.3"
146source = "registry+https://github.com/rust-lang/crates.io-index"
43source = "registry+https://github.com/rust-lang/crates.io-index"
147−checksum = "8d72f6392c860c87c069c1b11c890d208cae4db143f72230d73ce53155ab2370"
44+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
148dependencies = [
45dependencies = [
149 "serde",
46 "serde",
150− "serde_json",
151− "syn 2.0.118",
152− "walkdir",
153−]
154−
155−[[package]]
156−name = "boltffi_macros"
157−version = "0.27.3"
158−source = "registry+https://github.com/rust-lang/crates.io-index"
159−checksum = "8b2f35b6e1b94218d5178916bdd59f0c358214c146f3ce189fc96ddaa019d435"
160−dependencies = [
161− "boltffi_ast",
162− "boltffi_binding",
163− "boltffi_ffi_rules",
164− "boltffi_scan",
165− "proc-macro2",
166− "quote",
167− "syn 2.0.118",
168−]
169−
170−[[package]]
171−name = "boltffi_scan"
172−version = "0.27.3"
173−source = "registry+https://github.com/rust-lang/crates.io-index"
174−checksum = "4476cba632b69b8736ea499a463a8eddc6fd311f320f98601e338ddc0c9476a0"
175−dependencies = [
176− "boltffi_ast",
177− "proc-macro2",
178− "quote",
179− "serde",
180− "serde_json",
181− "syn 2.0.118",
182− "walkdir",
183]
47]
184
48
185[[package]]
49[[package]]
@@ -190,9 +54,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
190
54
191[[package]]
55[[package]]
192name = "bytes"
56name = "bytes"
193−version = "1.12.0"
57+version = "1.12.1"
194source = "registry+https://github.com/rust-lang/crates.io-index"
58source = "registry+https://github.com/rust-lang/crates.io-index"
195−checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
59+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
196
60
197[[package]]
61[[package]]
198name = "cfg-if"
62name = "cfg-if"
@@ -200,52 +64,6 @@ version = "1.0.4"
200source = "registry+https://github.com/rust-lang/crates.io-index"
64source = "registry+https://github.com/rust-lang/crates.io-index"
201checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
65checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
202
66
203−[[package]]
204−name = "clap"
205−version = "4.6.1"
206−source = "registry+https://github.com/rust-lang/crates.io-index"
207−checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
208−dependencies = [
209− "clap_builder",
210− "clap_derive",
211−]
212−
213−[[package]]
214−name = "clap_builder"
215−version = "4.6.0"
216−source = "registry+https://github.com/rust-lang/crates.io-index"
217−checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
218−dependencies = [
219− "anstream",
220− "anstyle",
221− "clap_lex",
222− "strsim",
223−]
224−
225−[[package]]
226−name = "clap_derive"
227−version = "4.6.1"
228−source = "registry+https://github.com/rust-lang/crates.io-index"
229−checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
230−dependencies = [
231− "heck 0.5.0",
232− "proc-macro2",
233− "quote",
234− "syn 2.0.118",
235−]
236−
237−[[package]]
238−name = "clap_lex"
239−version = "1.1.0"
240−source = "registry+https://github.com/rust-lang/crates.io-index"
241−checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
242−
243−[[package]]
244−name = "colorchoice"
245−version = "1.0.5"
246−source = "registry+https://github.com/rust-lang/crates.io-index"
247−checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
248−
249[[package]]
67[[package]]
250name = "const-fnv1a-hash"
68name = "const-fnv1a-hash"
251version = "1.1.0"
69version = "1.1.0"
@@ -278,10 +96,8 @@ dependencies = [
278 "crossbeam-channel",
96 "crossbeam-channel",
279 "crux_macros",
97 "crux_macros",
280 "facet",
98 "facet",
281− "facet_generate",
282 "futures",
99 "futures",
283 "include_dir 0.7.4",
100 "include_dir 0.7.4",
284− "log",
285 "serde",
101 "serde",
286 "serde-generate",
102 "serde-generate",
287 "serde-reflection",
103 "serde-reflection",
@@ -533,26 +349,6 @@ dependencies = [
533 "unsynn",
349 "unsynn",
534]
350]
535
351
536−[[package]]
537−name = "facet_generate"
538−version = "0.17.2"
539−source = "registry+https://github.com/rust-lang/crates.io-index"
540−checksum = "4bab57b923bae6e204f5e47643054a9c5ad9c3bd9aadec66df741790de8a859f"
541−dependencies = [
542− "derive_builder",
543− "facet",
544− "facet-generate-attrs",
545− "heck 0.5.0",
546− "include_dir 0.7.4",
547− "indent",
548− "indoc",
549− "regex",
550− "serde",
551− "serde_json",
552− "textwrap 0.16.2",
553− "thiserror 2.0.18",
554−]
555−
556[[package]]
352[[package]]
557name = "fnv"
353name = "fnv"
558version = "1.0.7"
354version = "1.0.7"
@@ -884,27 +680,6 @@ dependencies = [
884 "quote",
680 "quote",
885]
681]
886
682
887−[[package]]
888−name = "indent"
889−version = "0.1.1"
890−source = "registry+https://github.com/rust-lang/crates.io-index"
891−checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
892−
893−[[package]]
894−name = "indoc"
895−version = "2.0.7"
896−source = "registry+https://github.com/rust-lang/crates.io-index"
897−checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
898−dependencies = [
899− "rustversion",
900−]
901−
902−[[package]]
903−name = "is_terminal_polyfill"
904−version = "1.70.2"
905−source = "registry+https://github.com/rust-lang/crates.io-index"
906−checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
907−
908[[package]]
683[[package]]
909name = "itoa"
684name = "itoa"
910version = "1.0.18"
685version = "1.0.18"
@@ -934,12 +709,6 @@ version = "0.8.2"
934source = "registry+https://github.com/rust-lang/crates.io-index"
709source = "registry+https://github.com/rust-lang/crates.io-index"
935checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
710checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
936
711
937−[[package]]
938−name = "log"
939−version = "0.4.33"
940−source = "registry+https://github.com/rust-lang/crates.io-index"
941−checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
942−
943[[package]]
712[[package]]
944name = "memchr"
713name = "memchr"
945version = "2.8.3"
714version = "2.8.3"
@@ -964,12 +733,6 @@ version = "1.21.4"
964source = "registry+https://github.com/rust-lang/crates.io-index"
733source = "registry+https://github.com/rust-lang/crates.io-index"
965checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
734checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
966
735
967−[[package]]
968−name = "once_cell_polyfill"
969−version = "1.70.2"
970−source = "registry+https://github.com/rust-lang/crates.io-index"
971−checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
972−
973[[package]]
736[[package]]
974name = "percent-encoding"
737name = "percent-encoding"
975version = "2.3.2"
738version = "2.3.2"
@@ -1122,35 +885,6 @@ dependencies = [
1122 "getrandom",
885 "getrandom",
1123]
886]
1124
887
1125−[[package]]
1126−name = "regex"
1127−version = "1.12.4"
1128−source = "registry+https://github.com/rust-lang/crates.io-index"
1129−checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1130−dependencies = [
1131− "aho-corasick",
1132− "memchr",
1133− "regex-automata",
1134− "regex-syntax",
1135−]
1136−
1137−[[package]]
1138−name = "regex-automata"
1139−version = "0.4.14"
1140−source = "registry+https://github.com/rust-lang/crates.io-index"
1141−checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1142−dependencies = [
1143− "aho-corasick",
1144− "memchr",
1145− "regex-syntax",
1146−]
1147−
1148−[[package]]
1149−name = "regex-syntax"
1150−version = "0.8.11"
1151−source = "registry+https://github.com/rust-lang/crates.io-index"
1152−checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1153−
1154[[package]]
888[[package]]
1155name = "rustc-hash"
889name = "rustc-hash"
1156version = "2.1.3"
890version = "2.1.3"
@@ -1169,15 +903,6 @@ version = "1.0.23"
1169source = "registry+https://github.com/rust-lang/crates.io-index"
903source = "registry+https://github.com/rust-lang/crates.io-index"
1170checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
904checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1171
905
1172−[[package]]
1173−name = "same-file"
1174−version = "1.0.6"
1175−source = "registry+https://github.com/rust-lang/crates.io-index"
1176−checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1177−dependencies = [
1178− "winapi-util",
1179−]
1180−
1181[[package]]
906[[package]]
1182name = "serde"
907name = "serde"
1183version = "1.0.228"
908version = "1.0.228"
@@ -1199,7 +924,7 @@ dependencies = [
1199 "phf",
924 "phf",
1200 "serde",
925 "serde",
1201 "serde-reflection",
926 "serde-reflection",
1202− "textwrap 0.13.4",
927+ "textwrap",
1203]
928]
1204
929
1205[[package]]
930[[package]]
@@ -1273,12 +998,11 @@ name = "shared"
1273version = "0.1.0"
998version = "0.1.0"
1274dependencies = [
999dependencies = [
1275 "anyhow",
1000 "anyhow",
1276− "boltffi",
1001+ "base64",
1277− "clap",
1278 "crux_core",
1002 "crux_core",
1279 "crux_http",
1003 "crux_http",
1280− "facet",
1281 "serde",
1004 "serde",
1005+ "serde_json",
1282]
1006]
1283
1007
1284[[package]]
1008[[package]]
@@ -1357,18 +1081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1357checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
1081checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
1358dependencies = [
1082dependencies = [
1359 "smawk",
1083 "smawk",
1360− "unicode-width 0.1.14",
1084+ "unicode-width",
1361−]
1362−
1363−[[package]]
1364−name = "textwrap"
1365−version = "0.16.2"
1366−source = "registry+https://github.com/rust-lang/crates.io-index"
1367−checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1368−dependencies = [
1369− "smawk",
1370− "unicode-linebreak",
1371− "unicode-width 0.2.2",
1372]
1085]
1373
1086
1374[[package]]
1087[[package]]
@@ -1427,12 +1140,6 @@ version = "1.0.24"
1427source = "registry+https://github.com/rust-lang/crates.io-index"
1140source = "registry+https://github.com/rust-lang/crates.io-index"
1428checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1141checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1429
1142
1430−[[package]]
1431−name = "unicode-linebreak"
1432−version = "0.1.5"
1433−source = "registry+https://github.com/rust-lang/crates.io-index"
1434−checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1435−
1436[[package]]
1143[[package]]
1437name = "unicode-segmentation"
1144name = "unicode-segmentation"
1438version = "1.13.3"
1145version = "1.13.3"
@@ -1445,12 +1152,6 @@ version = "0.1.14"
1445source = "registry+https://github.com/rust-lang/crates.io-index"
1152source = "registry+https://github.com/rust-lang/crates.io-index"
1446checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1153checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1447
1154
1448−[[package]]
1449−name = "unicode-width"
1450−version = "0.2.2"
1451−source = "registry+https://github.com/rust-lang/crates.io-index"
1452−checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1453−
1454[[package]]
1155[[package]]
1455name = "unsynn"
1156name = "unsynn"
1456version = "0.3.0"
1157version = "0.3.0"
@@ -1480,38 +1181,12 @@ version = "1.0.4"
1480source = "registry+https://github.com/rust-lang/crates.io-index"
1181source = "registry+https://github.com/rust-lang/crates.io-index"
1481checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1182checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1482
1183
1483−[[package]]
1484−name = "utf8parse"
1485−version = "0.2.2"
1486−source = "registry+https://github.com/rust-lang/crates.io-index"
1487−checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1488−
1489−[[package]]
1490−name = "uuid"
1491−version = "1.23.4"
1492−source = "registry+https://github.com/rust-lang/crates.io-index"
1493−checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
1494−dependencies = [
1495− "js-sys",
1496− "wasm-bindgen",
1497−]
1498−
1499[[package]]
1184[[package]]
1500name = "version_check"
1185name = "version_check"
1501version = "0.9.5"
1186version = "0.9.5"
1502source = "registry+https://github.com/rust-lang/crates.io-index"
1187source = "registry+https://github.com/rust-lang/crates.io-index"
1503checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1188checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1504
1189
1505−[[package]]
1506−name = "walkdir"
1507−version = "2.5.0"
1508−source = "registry+https://github.com/rust-lang/crates.io-index"
1509−checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1510−dependencies = [
1511− "same-file",
1512− "winapi-util",
1513−]
1514−
1515[[package]]
1190[[package]]
1516name = "wasi"
1191name = "wasi"
1517version = "0.11.1+wasi-snapshot-preview1"
1192version = "0.11.1+wasi-snapshot-preview1"
@@ -1573,30 +1248,6 @@ dependencies = [
1573 "wasm-bindgen",
1248 "wasm-bindgen",
1574]
1249]
1575
1250
1576−[[package]]
1577−name = "winapi-util"
1578−version = "0.1.11"
1579−source = "registry+https://github.com/rust-lang/crates.io-index"
1580−checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1581−dependencies = [
1582− "windows-sys",
1583−]
1584−
1585−[[package]]
1586−name = "windows-link"
1587−version = "0.2.1"
1588−source = "registry+https://github.com/rust-lang/crates.io-index"
1589−checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1590−
1591−[[package]]
1592−name = "windows-sys"
1593−version = "0.61.2"
1594−source = "registry+https://github.com/rust-lang/crates.io-index"
1595−checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1596−dependencies = [
1597− "windows-link",
1598−]
1599−
1600[[package]]
1251[[package]]
1601name = "writeable"
1252name = "writeable"
1602version = "0.6.3"
1253version = "0.6.3"
shared/Cargo.toml+10−10
@@ -1,31 +1,31 @@
1[package]
1[package]
2name = "shared"
2name = "shared"
3version = "0.1.0"
3version = "0.1.0"
4−edition = "2024"
4+edition = "2021"
5−rust-version = "1.90"
5+rust-version = "1.85"
6license = "Apache-2.0"
6license = "Apache-2.0"
7+description = "Crux core for the opencode Android client"
7
8
8[lib]
9[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"]
10
13
11[[bin]]
14[[bin]]
12name = "codegen"
15name = "codegen"
13−required-features = ["codegen"]
16+required-features = ["typegen"]
14
17
15[features]
18[features]
16−facet_typegen = ["crux_core/facet_typegen"]
19+typegen = ["crux_core/typegen", "dep:anyhow"]
17−codegen = ["facet_typegen", "dep:clap", "dep:anyhow", "crux_core/typegen"]
18
20
19[dependencies]
21[dependencies]
20−facet = "=0.44"
21−boltffi = "0.27"
22crux_core = "0.19"
22crux_core = "0.19"
23crux_http = "0.19"
23crux_http = "0.19"
24serde = { version = "1", features = ["derive"] }
24serde = { version = "1", features = ["derive"] }
25+serde_json = "1"
26+base64 = "0.22"
25
27
26−# Optional codegen deps
27anyhow = { version = "1", optional = true }
28anyhow = { version = "1", optional = true }
28−clap = { version = "4", optional = true, features = ["derive"] }
29
29
30[dev-dependencies]
30[dev-dependencies]
31crux_core = { version = "0.19", features = ["testing"] }
31crux_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+851−294
@@ -1,125 +1,288 @@
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+
11+use base64::Engine;
1use crux_core::{
12use crux_core::{
2− App, Command,
3 macros::effect,
13 macros::effect,
4− render::{RenderOperation, render},
14+ render::{render, RenderOperation},
15+ App, Command,
5};
16};
17+use crux_http::command::{Http, RequestBuilder};
6use crux_http::protocol::HttpRequest;
18use crux_http::protocol::HttpRequest;
7−use facet::Facet;
8use serde::{Deserialize, Serialize};
19use serde::{Deserialize, Serialize};
9
20
21+use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
22+
23+/// Result of an HTTP effect handed back to the core.
24+pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
25+
10−// ─── Events ────────────────────────────────────────────────────────────────
26+// ─── Events ──────────────────────────────────────────────────────────────────
11
27
12−#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
13−#[repr(C)]
14pub enum Event {
28pub enum Event {
15− // Lifecycle
29+ // Lifecycle / connect
16 Start,
30 Start,
17 ServerUrlChanged(String),
31 ServerUrlChanged(String),
32+ UsernameChanged(String),
33+ PasswordChanged(String),
18 Connect,
34 Connect,
19
35
20− // Session list
36+ // Sessions
21 LoadSessions,
37 LoadSessions,
22− // events local to the core
23− #[serde(skip)]
24− #[facet(skip)]
25− SessionsLoaded(#[facet(opaque)] HttpResult<String>),
26 SelectSession(String),
38 SelectSession(String),
27 CreateSession,
39 CreateSession,
28− SessionCreated(#[facet(opaque)] HttpResult<String>),
40+ DeleteSession(String),
29
41
30 // Chat
42 // Chat
31 LoadMessages(String),
43 LoadMessages(String),
32− MessagesLoaded(#[facet(opaque)] HttpResult<String>),
44+ DraftChanged(String),
33 SendMessage(String),
45 SendMessage(String),
34− MessageSent(#[facet(opaque)] HttpResult<String>),
46+ CancelGeneration,
35
47
36− // SSE event received
48+ // A raw event line from the shell's `/event` SSE subscription.
37− EventReceived(String),
49+ ServerEvent(String),
38
50
39− // Navigation
51+ // Navigation / misc
40− NavigateToChat(String),
41 NavigateToSessions,
52 NavigateToSessions,
42−
53+ NavigateToConnect,
43− // Errors
44 DismissError,
54 DismissError,
55+
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),
45}
64}
46
65
47−// ─── Effects ───────────────────────────────────────────────────────────────
66+// ─── Effects ─────────────────────────────────────────────────────────────────
48
67
49−#[effect(facet_typegen)]
68+#[effect]
50#[derive(Debug)]
69#[derive(Debug)]
51pub enum Effect {
70pub enum Effect {
52 Render(RenderOperation),
71 Render(RenderOperation),
53 Http(HttpRequest),
72 Http(HttpRequest),
54}
73}
55
74
56−// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
57−
58−pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
59−
60−// ─── Model ─────────────────────────────────────────────────────────────────
75+// ─── Model ───────────────────────────────────────────────────────────────────
61
76
62#[derive(Default)]
77#[derive(Default)]
63pub struct Model {
78pub struct Model {
64 server_url: String,
79 server_url: String,
80+ username: String,
81+ password: String,
82+ /// The server answered 401 to an unauthenticated probe — show credentials.
83+ auth_required: bool,
84+ /// Credentials were accepted; attach the auth header to every request.
85+ authed: bool,
65 connected: bool,
86 connected: bool,
66 loading: bool,
87 loading: bool,
67 error: Option<String>,
88 error: Option<String>,
89+ screen: Screen,
90+
68− sessions: Vec<SessionSummary>,
91+ sessions: Vec<SessionView>,
69 current_session_id: Option<String>,
92 current_session_id: Option<String>,
93+ current_session_title: String,
94+
70− messages: Vec<MessageView>,
95+ messages: Vec<MsgState>,
71− draft_message: String,
96+ draft: String,
97+ generating: bool,
98+
99+ /// Monotonic counter for optimistic (client-side) user-message ids.
100+ local_seq: u64,
72}
101}
73
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+ }
182+}
183+
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+
74−#[derive(Clone, Debug, Default)]
194+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
75−pub struct SessionSummary {
195+pub struct SessionView {
76 pub id: String,
196 pub id: String,
77 pub title: String,
197 pub title: String,
78}
198}
79
199
200+#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
201+pub enum MessageStatus {
202+ #[default]
203+ Sent,
204+ Pending,
205+ Failed,
206+}
207+
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+
80−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
215+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
81pub struct MessageView {
216pub struct MessageView {
82 pub id: String,
217 pub id: String,
83 pub role: String,
218 pub role: String,
84 pub text: String,
219 pub text: String,
220+ pub reasoning: Option<String>,
221+ pub tools: Vec<ToolView>,
85 pub time: u64,
222 pub time: u64,
223+ pub status: MessageStatus,
224+ pub streaming: bool,
86}
225}
87
226
88−// ─── ViewModel ─────────────────────────────────────────────────────────────
89−
90−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
227+#[derive(Serialize, Deserialize, Clone, Debug, Default)]
91pub struct ViewModel {
228pub struct ViewModel {
92 pub screen: Screen,
229 pub screen: Screen,
93 pub server_url: String,
230 pub server_url: String,
231+ pub username: String,
232+ pub password: String,
233+ pub auth_required: bool,
94 pub connected: bool,
234 pub connected: bool,
95 pub loading: bool,
235 pub loading: bool,
96 pub error: Option<String>,
236 pub error: Option<String>,
97 pub sessions: Vec<SessionView>,
237 pub sessions: Vec<SessionView>,
98 pub current_session_id: Option<String>,
238 pub current_session_id: Option<String>,
239+ pub current_session_title: String,
99 pub messages: Vec<MessageView>,
240 pub messages: Vec<MessageView>,
100− pub draft_message: String,
241+ pub draft: String,
242+ pub generating: bool,
101}
243}
102
244
103−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
104−#[repr(C)]
105−pub enum Screen {
106− #[default]
107− Connect,
108− Sessions,
109− Chat,
110−}
111−
112−#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
113−pub struct SessionView {
114− pub id: String,
115− pub title: String,
116−}
117−
118−// ─── App ───────────────────────────────────────────────────────────────────
245+// ─── App ─────────────────────────────────────────────────────────────────────
119
246
120#[derive(Default)]
247#[derive(Default)]
121pub struct OpencodeApp;
248pub struct OpencodeApp;
122
249
250+impl OpencodeApp {
251+ fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252+ let url = format!("{}{path}", model.server_url);
253+ self.with_auth(Http::get(&url).expect_string(), model)
254+ }
255+
256+ fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
257+ let url = format!("{}{path}", model.server_url);
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);
263+ self.with_auth(req, model)
264+ }
265+
266+ fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267+ let url = format!("{}{path}", model.server_url);
268+ self.with_auth(Http::delete(&url).expect_string(), model)
269+ }
270+
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>
273+ where
274+ E: 'static,
275+ {
276+ if model.has_credentials() {
277+ let creds = format!("{}:{}", model.username, model.password);
278+ let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279+ req.header("Authorization", format!("Basic {encoded}"))
280+ } else {
281+ req
282+ }
283+ }
284+}
285+
123impl App for OpencodeApp {
286impl App for OpencodeApp {
124 type Event = Event;
287 type Event = Event;
125 type Model = Model;
288 type Model = Model;
@@ -128,150 +291,246 @@ impl App for OpencodeApp {
128
291
129 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
292 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
130 match event {
293 match event {
294+ // ── Lifecycle / connect ─────────────────────────────────────────
131− Event::Start => {
295+ Event::Start => render(),
132− model.server_url = "http://localhost:4096".to_string();
133− render()
134− }
135
296
297+ // Store the field verbatim while typing; normalizing here would fight
298+ // the user's edits (e.g. eat the "://" as they type it). The URL is
299+ // normalized once, on Connect.
300+ // Clear any prior error as soon as the user edits a field, so a stale
301+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
136 Event::ServerUrlChanged(url) => {
302 Event::ServerUrlChanged(url) => {
137 model.server_url = url;
303 model.server_url = url;
304+ if model.error.is_some() {
305+ model.error = None;
306+ }
307+ render()
308+ }
309+ Event::UsernameChanged(u) => {
310+ model.username = u;
311+ if model.error.is_some() {
312+ model.error = None;
313+ }
314+ render()
315+ }
316+ Event::PasswordChanged(p) => {
317+ model.password = p;
318+ if model.error.is_some() {
319+ model.error = None;
320+ }
138 render()
321 render()
139 }
322 }
140
323
141 Event::Connect => {
324 Event::Connect => {
325+ model.server_url = normalize_url(&model.server_url);
326+ if model.server_url.is_empty() {
327+ model.error = Some("Enter a server URL".to_string());
328+ return render();
329+ }
142 model.loading = true;
330 model.loading = true;
143 model.error = None;
331 model.error = None;
144− let url = format!("{}/global/health", model.server_url);
332+ model.auth_required = false;
145− Http::get(&url).expect_string().build().then_send(|_| Event::LoadSessions)
333+ render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
146 }
334 }
147
335
336+ Event::HealthProbed(result) => self.on_health_probed(model, result),
337+
338+ // ── Sessions ────────────────────────────────────────────────────
148 Event::LoadSessions => {
339 Event::LoadSessions => {
149 model.loading = true;
340 model.loading = true;
150− let url = format!("{}/session", model.server_url);
151− Http::get(&url).expect_string().build().then_send(Event::SessionsLoaded)
341+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
152 }
342 }
153
343
154 Event::SessionsLoaded(result) => {
344 Event::SessionsLoaded(result) => {
155 model.loading = false;
345 model.loading = false;
156− match result {
346+ match ok_body(result) {
157− Ok(mut response) => {
347+ Ok(body) => {
158− let body = response.take_body().unwrap_or_default();
159− model.sessions = parse_sessions(&body);
348+ model.sessions = protocol::parse_sessions(&body)
160− model.connected = true;
349+ .into_iter()
161− render()
350+ .map(|s| SessionView {
351+ id: s.id,
352+ title: display_title(&s.title),
162− }
353+ })
163− Err(e) => {
354+ .collect();
164− model.error = Some(format!("Failed to load sessions: {e}"));
355+ // Impose a stable alphabetical order so the list doesn't shuffle
165− render()
356+ // with whatever order the server happens to return sessions in.
357+ model.sessions.sort_by_key(|s| s.title.to_lowercase());
166 }
358 }
359+ Err(e) => model.error = Some(e),
167 }
360 }
361+ render()
168 }
362 }
169
363
170 Event::SelectSession(id) => {
364 Event::SelectSession(id) => {
171− model.current_session_id = Some(id.clone());
365+ model.current_session_title = model
172− Command::event(Event::NavigateToChat(id))
366+ .sessions
173− }
367+ .iter()
174−
368+ .find(|s| s.id == id)
175− Event::NavigateToChat(id) => {
369+ .map(|s| s.title.clone())
370+ .unwrap_or_default();
176 model.current_session_id = Some(id.clone());
371 model.current_session_id = Some(id.clone());
177 model.messages.clear();
372 model.messages.clear();
373+ model.screen = Screen::Chat;
374+ model.loading = true;
375+ model.error = None;
178 render().and(Command::event(Event::LoadMessages(id)))
376 render().and(Command::event(Event::LoadMessages(id)))
179 }
377 }
180
378
181− Event::NavigateToSessions => {
182− model.current_session_id = None;
183− model.messages.clear();
184− render()
185− }
186−
187 Event::CreateSession => {
379 Event::CreateSession => {
188 model.loading = true;
380 model.loading = true;
189− let url = format!("{}/session", model.server_url);
381+ render().and(
382+ self.post_json(model, "/session", &serde_json::json!({}))
383+ .build()
190− Http::post(&url).expect_string().build().then_send(Event::SessionCreated)
384+ .then_send(Event::SessionCreated),
385+ )
191 }
386 }
192
387
193 Event::SessionCreated(result) => {
388 Event::SessionCreated(result) => {
194 model.loading = false;
389 model.loading = false;
195− match result {
390+ match ok_body(result) {
196− Ok(mut response) => {
391+ Ok(body) => {
197− let body = response.take_body().unwrap_or_default();
392+ if let Some(s) = protocol::parse_session(&body) {
198− let id = parse_session_id(&body);
393+ let view = SessionView {
394+ id: s.id.clone(),
395+ title: display_title(&s.title),
396+ };
397+ if !model.sessions.iter().any(|e| e.id == view.id) {
398+ model.sessions.insert(0, view);
399+ }
199− Command::event(Event::NavigateToChat(id))
400+ return render().and(Command::event(Event::SelectSession(s.id)));
200− }
401+ }
201− Err(e) => {
202− model.error = Some(format!("Failed to create session: {e}"));
402+ model.error = Some("Malformed session response".to_string());
203− render()
204 }
403 }
404+ Err(e) => model.error = Some(e),
205 }
405 }
406+ render()
206 }
407 }
207
408
409+ Event::DeleteSession(id) => {
410+ model.sessions.retain(|s| s.id != id);
411+ let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
412+ if model.is_current(&id) {
413+ model.current_session_id = None;
414+ model.messages.clear();
415+ model.screen = Screen::Sessions;
416+ }
417+ cmd = render().and(cmd);
418+ cmd
419+ }
420+
421+ Event::SessionDeleted(result) => {
422+ if let Err(e) = ok_body(result) {
423+ model.error = Some(e);
424+ }
425+ render()
426+ }
427+
428+ // ── Chat ────────────────────────────────────────────────────────
208− Event::LoadMessages(session_id) => {
429+ Event::LoadMessages(id) => {
209− model.loading = true;
430+ render().and(
210− let url = format!("{}/session/{}/message", model.server_url, session_id);
431+ self.get(model, &format!("/session/{id}/message"))
432+ .build()
211− Http::get(&url).expect_string().build().then_send(Event::MessagesLoaded)
433+ .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
434+ )
212 }
435 }
213
436
214− Event::MessagesLoaded(result) => {
437+ Event::MessagesLoaded(session_id, result) => {
438+ if !model.is_current(&session_id) {
439+ return render();
440+ }
215 model.loading = false;
441 model.loading = false;
216− match result {
442+ match ok_body(result) {
217− Ok(mut response) => {
443+ Ok(body) => {
218− let body = response.take_body().unwrap_or_default();
219− model.messages = parse_messages(&body);
444+ model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
220− render()
445+ model.generating = model.messages.iter().any(|m| m.streaming);
221− }
222− Err(e) => {
223− model.error = Some(format!("Failed to load messages: {e}"));
224− render()
225 }
446 }
447+ Err(e) => model.error = Some(e),
226 }
448 }
449+ render()
450+ }
451+
452+ Event::DraftChanged(text) => {
453+ model.draft = text;
454+ render()
227 }
455 }
228
456
229 Event::SendMessage(text) => {
457 Event::SendMessage(text) => {
458+ let text = text.trim().to_string();
230− let session_id = match &model.current_session_id {
459+ let Some(session_id) = model.current_session_id.clone() else {
231− Some(id) => id.clone(),
232− None => return render(),
460+ return render();
233 };
461 };
234− model.draft_message.clear();
462+ if text.is_empty() {
235− let url = format!(
463+ return render();
236− "{}/session/{}/prompt_async",
464+ }
237− model.server_url, session_id
465+ model.local_seq += 1;
238− );
466+ let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
239− let body = format!(
467+ user.status = MessageStatus::Pending;
468+ MsgState::upsert_part(&mut user.text_parts, "local", text.clone());
469+ model.messages.push(user);
470+ model.draft.clear();
471+ model.generating = true;
472+ model.error = None;
473+
240− r#"{{"sessionID":"{}","parts":[{{"type":"text","text":"{}"}}]}}"#,
474+ let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
241− session_id, text
475+ render().and(
242− );
476+ self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
243− Http::post(&url)
244− .header("Content-Type", "application/json")
245− .body_bytes(body.into_bytes())
246− .expect_string()
247− .build()
477+ .build()
248− .then_send(Event::MessageSent)
478+ .then_send(Event::PromptSent),
479+ )
249− }
480+ }
250−
481+
251− Event::MessageSent(result) => {
482+ Event::PromptSent(result) => {
252− match result {
483+ match ok_body(result) {
253− Ok(_) => {
484+ Ok(_) => set_last_pending(model, MessageStatus::Sent),
254− // Reload messages to get the response
255− match &model.current_session_id {
256− Some(id) => Command::event(Event::LoadMessages(id.clone())),
257− None => render(),
258− }
259− }
260 Err(e) => {
485 Err(e) => {
486+ set_last_pending(model, MessageStatus::Failed);
487+ model.generating = false;
261− model.error = Some(format!("Failed to send message: {e}"));
488+ model.error = Some(e);
262− render()
263 }
489 }
264 }
490 }
491+ render()
265 }
492 }
266
493
267− Event::EventReceived(_data) => {
494+ Event::CancelGeneration => {
268− // SSE event — reload messages to show updates
495+ model.generating = false;
269− match &model.current_session_id {
496+ match model.current_session_id.clone() {
270− Some(id) => Command::event(Event::LoadMessages(id.clone())),
497+ Some(id) => render().and(
498+ self.post_json(model, &format!("/session/{id}/abort"), &serde_json::json!({}))
499+ .build()
500+ .then_send(Event::Aborted),
501+ ),
271 None => render(),
502 None => render(),
272 }
503 }
273 }
504 }
274
505
506+ Event::Aborted(_) => render(),
507+
508+ // ── SSE ─────────────────────────────────────────────────────────
509+ Event::ServerEvent(json) => {
510+ if let Some(event) = protocol::parse_event(&json) {
511+ apply_server_event(model, event);
512+ }
513+ render()
514+ }
515+
516+ // ── Navigation / misc ───────────────────────────────────────────
517+ Event::NavigateToSessions => {
518+ model.screen = Screen::Sessions;
519+ model.current_session_id = None;
520+ model.messages.clear();
521+ model.generating = false;
522+ render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
523+ }
524+
525+ Event::NavigateToConnect => {
526+ model.screen = Screen::Connect;
527+ model.connected = false;
528+ model.sessions.clear();
529+ model.messages.clear();
530+ model.current_session_id = None;
531+ render()
532+ }
533+
275 Event::DismissError => {
534 Event::DismissError => {
276 model.error = None;
535 model.error = None;
277 render()
536 render()
@@ -281,214 +540,512 @@ impl App for OpencodeApp {
281
540
282 fn view(&self, model: &Model) -> ViewModel {
541 fn view(&self, model: &Model) -> ViewModel {
283 ViewModel {
542 ViewModel {
284− screen: if model.current_session_id.is_some() {
543+ screen: model.screen.clone(),
285− Screen::Chat
286− } else if model.connected {
287− Screen::Sessions
288− } else {
289− Screen::Connect
290− },
291 server_url: model.server_url.clone(),
544 server_url: model.server_url.clone(),
545+ username: model.username.clone(),
546+ password: model.password.clone(),
547+ auth_required: model.auth_required,
292 connected: model.connected,
548 connected: model.connected,
293 loading: model.loading,
549 loading: model.loading,
294 error: model.error.clone(),
550 error: model.error.clone(),
295− sessions: model
551+ sessions: model.sessions.clone(),
296− .sessions
297− .iter()
298− .map(|s| SessionView {
299− id: s.id.clone(),
300− title: s.title.clone(),
301− })
302− .collect(),
303 current_session_id: model.current_session_id.clone(),
552 current_session_id: model.current_session_id.clone(),
553+ current_session_title: model.current_session_title.clone(),
304− messages: model.messages.clone(),
554+ messages: model.messages.iter().map(MsgState::to_view).collect(),
305− draft_message: model.draft_message.clone(),
555+ draft: model.draft.clone(),
556+ generating: model.generating,
306 }
557 }
307 }
558 }
308}
559}
309
560
310−// ─── JSON parsing helpers (naive — the core stays pure) ────────────────────
561+impl OpencodeApp {
311−
562+ fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
312−fn parse_sessions(json: &str) -> Vec<SessionSummary> {
563+ let has_creds = model.has_credentials();
313− let mut sessions = Vec::new();
314− let mut depth = 0;
315− let mut start = 0;
316− let bytes = json.as_bytes();
317−
318− let mut i = 0;
319− while i < bytes.len() {
320− match bytes[i] {
564+ match result {
321− b'{' if depth == 0 => {
565+ Ok(response) => {
322− depth = 1;
566+ let status = response.status().as_u16();
323− start = i;
567+ if status == 401 {
324− }
568+ model.loading = false;
325− b'{' => depth += 1,
569+ if has_creds {
326− b'}' => {
570+ model.error = Some("Invalid credentials".to_string());
327− depth -= 1;
571+ } else {
328− if depth == 0 {
572+ model.auth_required = true;
329− let obj = &json[start..=i];
573+ }
330− let id = extract_string(obj, "id").unwrap_or_default();
574+ return render();
331− let title = extract_string(obj, "title").unwrap_or_default();
575+ }
332− if !id.is_empty() {
576+ if (200..300).contains(&status) {
333− sessions.push(SessionSummary { id, title });
577+ if has_creds {
578+ model.authed = true;
334 }
579 }
580+ model.connected = true;
581+ model.auth_required = false;
582+ model.error = None;
583+ model.screen = Screen::Sessions;
584+ model.loading = true;
585+ return render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded));
335 }
586 }
587+ model.loading = false;
588+ model.error = Some(format!("Server returned status {status}"));
589+ render()
590+ }
591+ Err(e) => {
592+ model.loading = false;
593+ model.error = Some(format!("Connection failed: {e}"));
594+ render()
336 }
595 }
337− _ => {}
338 }
596 }
339− i += 1;
340 }
597 }
341− sessions
342}
598}
343
599
344−fn parse_session_id(json: &str) -> String {
600+// ─── SSE application ─────────────────────────────────────────────────────────
345− extract_string(json, "id").unwrap_or_default()
601+
602+fn apply_server_event(model: &mut Model, event: ServerEvent) {
603+ match event {
604+ // Only assistant messages are created from the stream — the user's own
605+ // message is already shown optimistically, so ignoring the server's copy
606+ // avoids a duplicate bubble.
607+ ServerEvent::MessageUpdated { properties } => {
608+ let info = properties.info;
609+ if info.role != "assistant" || !model.is_current(&info.session_id) {
610+ return;
611+ }
612+ let streaming = info.time.completed.is_none() && info.error.is_none();
613+ if let Some(msg) = model.message_mut(&info.id) {
614+ msg.streaming = streaming;
615+ if msg.time == 0 {
616+ msg.time = info.time.created;
617+ }
618+ } else {
619+ let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
620+ msg.streaming = streaming;
621+ model.messages.push(msg);
622+ }
623+ if let Some(err) = info.error {
624+ model.error = Some(err.message());
625+ model.generating = false;
626+ }
346−}
627+ }
347
628
348−fn parse_messages(json: &str) -> Vec<MessageView> {
629+ // Apply only to a message we already track (an assistant message from the
349− let mut messages = Vec::new();
630+ // event above). Parts for unknown ids are ignored — see module docs.
350− let mut depth = 0;
631+ ServerEvent::MessagePartUpdated { properties } => {
351− let mut start = 0;
632+ let part = properties.part;
352− let bytes = json.as_bytes();
633+ let delta = properties.delta;
353−
634+ let msg_id = part.message_id().to_string();
354− let mut i = 0;
635+ let Some(msg) = model.message_mut(&msg_id) else {
355− while i < bytes.len() {
636+ return;
637+ };
356− match bytes[i] {
638+ match part {
357− b'{' if depth == 0 => {
639+ WirePart::Text { id, text, synthetic, .. } => {
358− depth = 1;
640+ if synthetic {
359− start = i;
641+ return;
360− }
361− b'{' => depth += 1,
362− b'}' => {
363− depth -= 1;
364− if depth == 0 {
365− let obj = &json[start..=i];
366− let id = extract_string(obj, "id").unwrap_or_default();
367− let role = extract_string(obj, "role").unwrap_or_default();
368− let time = extract_number(obj, "created").unwrap_or(0);
369− if !id.is_empty() && !role.is_empty() {
370− messages.push(MessageView {
371− id,
372− role,
373− text: String::new(),
374− time,
375− });
376 }
642 }
643+ let value = pick_text(text, &delta, &msg.text_parts, &id);
644+ MsgState::upsert_part(&mut msg.text_parts, &id, value);
645+ }
646+ WirePart::Reasoning { id, text, .. } => {
647+ let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
648+ MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
649+ }
650+ WirePart::Tool { id, tool, state, .. } => {
651+ let sv = protocol::tool_state_view(&state);
652+ msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
653+ }
654+ WirePart::Other => {}
655+ }
656+ }
657+
658+ ServerEvent::MessagePartRemoved { properties } => {
659+ if let Some(msg) = model.message_mut(&properties.message_id) {
660+ msg.remove_part(&properties.part_id);
661+ }
662+ }
663+
664+ ServerEvent::MessageRemoved { properties } => {
665+ model.messages.retain(|m| m.id != properties.message_id);
666+ }
667+
668+ ServerEvent::SessionIdle { properties } => {
669+ if model.is_current(&properties.session_id) {
670+ model.generating = false;
671+ for m in &mut model.messages {
672+ m.streaming = false;
673+ }
674+ }
675+ }
676+
677+ ServerEvent::SessionError { properties } => {
678+ let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
679+ if scoped {
680+ if let Some(err) = properties.error {
681+ model.error = Some(err.message());
682+ }
683+ model.generating = false;
684+ }
685+ }
686+
687+ ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
688+ let s = properties.info;
689+ let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
690+ if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
691+ existing.title = view.title.clone();
692+ } else {
693+ model.sessions.insert(0, view);
694+ }
695+ if model.is_current(&s.id) {
696+ model.current_session_title = display_title(&s.title);
697+ }
698+ }
699+
700+ ServerEvent::SessionDeleted { properties } => {
701+ let id = properties.session_id.or(properties.info.map(|i| i.id));
702+ if let Some(id) = id {
703+ model.sessions.retain(|s| s.id != id);
704+ if model.is_current(&id) {
705+ model.current_session_id = None;
706+ model.messages.clear();
707+ model.screen = Screen::Sessions;
708+ }
709+ }
710+ }
711+
712+ ServerEvent::Other => {}
713+ }
714+}
715+
716+// ─── Helpers ─────────────────────────────────────────────────────────────────
717+
718+/// `part.text` is the full current text when present; otherwise fall back to
719+/// accumulating the incremental `delta` onto whatever we already have.
720+fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
721+ if !text.is_empty() {
722+ return text;
723+ }
724+ let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
725+ match delta {
726+ Some(d) => format!("{prior}{d}"),
727+ None => prior,
728+ }
729+}
730+
731+fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
732+ let info = &env.info;
733+ let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
734+ msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
735+ for part in &env.parts {
736+ match part {
737+ WirePart::Text { id, text, synthetic, .. } => {
738+ if !synthetic {
739+ MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
377 }
740 }
378 }
741 }
379− _ => {}
742+ WirePart::Reasoning { id, text, .. } => {
743+ MsgState::upsert_part(&mut msg.reasoning_parts, id, text.clone());
744+ }
745+ WirePart::Tool { id, tool, state, .. } => {
746+ let sv = protocol::tool_state_view(state);
747+ msg.upsert_tool(id, ToolView { name: tool.clone(), status: sv.status, title: sv.title });
748+ }
749+ WirePart::Other => {}
380 }
750 }
381− i += 1;
382 }
751 }
383− messages
752+ msg
753+}
754+
755+fn set_last_pending(model: &mut Model, status: MessageStatus) {
756+ if let Some(msg) = model.messages.iter_mut().rev().find(|m| m.status == MessageStatus::Pending) {
757+ msg.status = status;
758+ }
384}
759}
385
760
386−fn extract_string(json: &str, key: &str) -> Option<String> {
761+/// Trim whitespace and any trailing `/` so paths concatenate cleanly, and
387− let pattern = format!("\"{key}\"");
762+/// default to an `http://` scheme when the user didn't type one (e.g.
388− let idx = json.find(&pattern)?;
763+/// "192.168.1.10:4096") — otherwise the shell's URL parser rejects it.
389− let rest = &json[idx + pattern.len()..];
764+fn normalize_url(url: &str) -> String {
390− let colon = rest.find(':')?;
765+ let trimmed = url.trim().trim_end_matches('/');
391− let rest = &rest[colon + 1..];
766+ if trimmed.is_empty() {
392− let quote = rest.find('"')?;
767+ String::new()
393− let rest = &rest[quote + 1..];
768+ } else if trimmed.contains("://") {
394− let end = rest.find('"')?;
769+ trimmed.to_string()
395− Some(rest[..end].to_string())
770+ } else {
771+ format!("http://{trimmed}")
772+ }
396}
773}
397
774
398−fn extract_number(json: &str, key: &str) -> Option<u64> {
775+fn display_title(title: &str) -> String {
399− let pattern = format!("\"{key}\"");
776+ let t = title.trim();
400− let idx = json.find(&pattern)?;
777+ if t.is_empty() {
401− let rest = &json[idx + pattern.len()..];
778+ "Untitled".to_string()
402− let colon = rest.find(':')?;
779+ } else {
403− let rest = rest[colon + 1..].trim_start();
780+ t.to_string()
404− let end = rest
781+ }
405− .find(|c: char| !c.is_ascii_digit())
406− .unwrap_or(rest.len());
407− rest[..end].parse().ok()
408}
782}
409
783
410−// ─── HTTP type alias for the command builder ───────────────────────────────
784+/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
785+fn ok_body(result: HttpResult) -> Result<String, String> {
786+ match result {
787+ Ok(mut response) => {
788+ let status = response.status().as_u16();
789+ if (200..300).contains(&status) {
790+ Ok(response.take_body().unwrap_or_default())
791+ } else {
792+ Err(format!("Server returned status {status}"))
793+ }
794+ }
795+ Err(e) => Err(format!("Request failed: {e}")),
796+ }
797+}
411
798
412−use crux_http::command::Http;
799+// ─── Tests ───────────────────────────────────────────────────────────────────
413
800
414#[cfg(test)]
801#[cfg(test)]
415−mod test {
802+mod tests {
416 use super::*;
803 use super::*;
417
804
805+ fn app() -> OpencodeApp {
806+ OpencodeApp::default()
807+ }
808+
809+ /// Put the model into a connected Chat view for the given session.
810+ fn in_chat(session_id: &str) -> Model {
811+ let mut model = Model::default();
812+ model.server_url = "http://localhost:4096".to_string();
813+ model.connected = true;
814+ model.screen = Screen::Chat;
815+ model.current_session_id = Some(session_id.to_string());
816+ model
817+ }
818+
819+ fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
820+ let _ = app.update(Event::ServerEvent(json.to_string()), model);
821+ }
822+
418 #[test]
823 #[test]
419− fn start_sets_default_server_url() {
824+ fn server_url_is_kept_raw_while_typing() {
420− let app = OpencodeApp;
825+ let app = app();
421 let mut model = Model::default();
826 let mut model = Model::default();
827+ // Mid-scheme input must survive verbatim — normalizing per keystroke
828+ // would eat the "://" the user is typing and make the field unusable.
422− app.update(Event::Start, &mut model).expect_only_render();
829+ let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
423− assert_eq!(model.server_url, "http://localhost:4096");
830+ assert_eq!(app.view(&model).server_url, "http:/");
424 }
831 }
425
832
426 #[test]
833 #[test]
427− fn server_url_change_updates_model() {
834+ fn connect_normalizes_scheme_less_url() {
428− let app = OpencodeApp;
835+ let app = app();
429 let mut model = Model::default();
836 let mut model = Model::default();
430− app.update(Event::Start, &mut model);
837+ // A scheme-less host (what a user often types) must gain http:// on
431− app.update(
838+ // connect, or the shell's URL parser throws and the attempt crashes.
432− Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
839+ let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
433− &mut model,
840+ assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
434− )
841+ let _ = app.update(Event::Connect, &mut model);
435− .expect_only_render();
436− assert_eq!(model.server_url, "http://10.0.0.1:4096");
842+ assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
437 }
843 }
438
844
439 #[test]
845 #[test]
440− fn connect_emits_http_effect() {
846+ fn connect_trims_and_keeps_explicit_scheme() {
441− let app = OpencodeApp;
847+ let app = app();
442 let mut model = Model::default();
848 let mut model = Model::default();
443− app.update(Event::Start, &mut model);
849+ let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
444− let mut cmd = app.update(Event::Connect, &mut model);
850+ let _ = app.update(Event::Connect, &mut model);
445− assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
851+ assert_eq!(app.view(&model).server_url, "https://host:4096");
446− assert!(model.loading);
447 }
852 }
448
853
449 #[test]
854 #[test]
450− fn parse_sessions_extracts_id_and_title() {
855+ fn connect_requires_a_url() {
451− let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
856+ let app = app();
452− let sessions = parse_sessions(json);
857+ let mut model = Model::default();
453− assert_eq!(sessions.len(), 2);
858+ let _ = app.update(Event::Connect, &mut model);
454− assert_eq!(sessions[0].id, "abc123");
859+ assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
455− assert_eq!(sessions[0].title, "My Session");
456− assert_eq!(sessions[1].id, "def456");
457 }
860 }
458
861
459 #[test]
862 #[test]
460− fn parse_session_id_extracts_id() {
863+ fn sending_adds_optimistic_user_message_and_sets_generating() {
461− let json = r#"{"id":"xyz789","title":"New"}"#;
864+ let app = app();
462− let id = parse_session_id(json);
865+ let mut model = in_chat("ses_1");
463− assert_eq!(id, "xyz789");
866+ let _ = app.update(Event::DraftChanged("hello there".into()), &mut model);
867+ let _ = app.update(Event::SendMessage("hello there".into()), &mut model);
868+
869+ let view = app.view(&model);
870+ assert!(view.generating);
871+ assert!(view.draft.is_empty());
872+ assert_eq!(view.messages.len(), 1);
873+ assert_eq!(view.messages[0].role, "user");
874+ assert_eq!(view.messages[0].text, "hello there");
875+ assert_eq!(view.messages[0].status, MessageStatus::Pending);
464 }
876 }
465
877
466 #[test]
878 #[test]
467− fn extract_string_finds_nested_keys() {
879+ fn empty_send_is_ignored() {
468− let json = r#"{"a":"b","id":"target","c":"d"}"#;
880+ let app = app();
469− assert_eq!(extract_string(json, "id"), Some("target".to_string()));
881+ let mut model = in_chat("ses_1");
470− assert_eq!(extract_string(json, "missing"), None);
882+ let _ = app.update(Event::SendMessage(" ".into()), &mut model);
883+ assert!(app.view(&model).messages.is_empty());
884+ assert!(!app.view(&model).generating);
471 }
885 }
472
886
473 #[test]
887 #[test]
474− fn extract_number_parses_values() {
888+ fn assistant_reply_streams_in_from_sse() {
475− let json = r#"{"created":1234567890}"#;
889+ let app = app();
476− assert_eq!(extract_number(json, "created"), Some(1234567890));
890+ let mut model = in_chat("ses_1");
891+
892+ // Assistant message shell arrives first.
893+ feed(&app, &mut model, serde_json::json!({
894+ "type": "message.updated",
895+ "properties": { "info": {
896+ "id": "msg_a", "role": "assistant", "sessionID": "ses_1",
897+ "time": { "created": 100 }
898+ }}
899+ }));
900+ assert_eq!(app.view(&model).messages.len(), 1);
901+ assert!(app.view(&model).messages[0].streaming);
902+
903+ // Two text deltas for the same part accumulate into full text.
904+ feed(&app, &mut model, serde_json::json!({
905+ "type": "message.part.updated",
906+ "properties": { "part": {
907+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
908+ "type": "text", "text": "Hello"
909+ }}
910+ }));
911+ feed(&app, &mut model, serde_json::json!({
912+ "type": "message.part.updated",
913+ "properties": { "part": {
914+ "id": "prt_1", "messageID": "msg_a", "sessionID": "ses_1",
915+ "type": "text", "text": "Hello, world"
916+ }}
917+ }));
918+
919+ let view = app.view(&model);
920+ assert_eq!(view.messages[0].role, "assistant");
921+ assert_eq!(view.messages[0].text, "Hello, world");
477 }
922 }
478
923
479 #[test]
924 #[test]
480− fn navigate_to_sessions_clears_state() {
925+ fn text_delta_accumulates_when_full_text_absent() {
481− let app = OpencodeApp;
926+ let app = app();
482− let mut model = Model::default();
927+ let mut model = in_chat("ses_1");
483− model.current_session_id = Some("test".to_string());
928+ feed(&app, &mut model, serde_json::json!({
484− model.messages.push(MessageView {
929+ "type": "message.updated",
485− id: "m1".to_string(),
930+ "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
486− role: "user".to_string(),
931+ }));
487− text: "hello".to_string(),
932+ for delta in ["Wor", "ld"] {
488− time: 0,
933+ feed(&app, &mut model, serde_json::json!({
934+ "type": "message.part.updated",
935+ "properties": {
936+ "part": { "id": "p", "messageID": "msg_a", "sessionID": "ses_1", "type": "text", "text": "" },
937+ "delta": delta
938+ }
939+ }));
940+ }
941+ assert_eq!(app.view(&model).messages[0].text, "World");
942+ }
943+
944+ #[test]
945+ fn user_message_from_sse_is_not_duplicated() {
946+ let app = app();
947+ let mut model = in_chat("ses_1");
948+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
949+ // Server echoes the user message on the stream — must be ignored.
950+ feed(&app, &mut model, serde_json::json!({
951+ "type": "message.updated",
952+ "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
953+ }));
954+ let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
955+ assert_eq!(users, 1);
956+ }
957+
958+ #[test]
959+ fn session_idle_clears_generating() {
960+ let app = app();
961+ let mut model = in_chat("ses_1");
962+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
963+ assert!(app.view(&model).generating);
964+ feed(&app, &mut model, serde_json::json!({
965+ "type": "session.idle", "properties": { "sessionID": "ses_1" }
966+ }));
967+ assert!(!app.view(&model).generating);
968+ }
969+
970+ #[test]
971+ fn idle_for_other_session_is_ignored() {
972+ let app = app();
973+ let mut model = in_chat("ses_1");
974+ let _ = app.update(Event::SendMessage("hi".into()), &mut model);
975+ feed(&app, &mut model, serde_json::json!({
976+ "type": "session.idle", "properties": { "sessionID": "ses_other" }
977+ }));
978+ assert!(app.view(&model).generating);
979+ }
980+
981+ #[test]
982+ fn reasoning_and_tool_parts_are_surfaced() {
983+ let app = app();
984+ let mut model = in_chat("ses_1");
985+ feed(&app, &mut model, serde_json::json!({
986+ "type": "message.updated",
987+ "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
988+ }));
989+ feed(&app, &mut model, serde_json::json!({
990+ "type": "message.part.updated",
991+ "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
992+ }));
993+ feed(&app, &mut model, serde_json::json!({
994+ "type": "message.part.updated",
995+ "properties": { "part": {
996+ "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
997+ "state": { "status": "completed", "title": "ls -la" }
998+ }}
999+ }));
1000+ let msg = &app.view(&model).messages[0];
1001+ assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
1002+ assert_eq!(msg.tools.len(), 1);
1003+ assert_eq!(msg.tools[0].name, "bash");
1004+ assert_eq!(msg.tools[0].status, "completed");
1005+ assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
1006+ }
1007+
1008+ #[test]
1009+ fn session_created_event_updates_list() {
1010+ let app = app();
1011+ let mut model = in_chat("ses_1");
1012+ feed(&app, &mut model, serde_json::json!({
1013+ "type": "session.created",
1014+ "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
1015+ }));
1016+ assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
1017+ }
1018+
1019+ #[test]
1020+ fn unknown_event_types_are_ignored() {
1021+ let app = app();
1022+ let mut model = in_chat("ses_1");
1023+ feed(&app, &mut model, serde_json::json!({
1024+ "type": "pty.created", "properties": { "whatever": true }
489− });
1025+ }));
490− app.update(Event::NavigateToSessions, &mut model);
1026+ feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
491− assert!(model.current_session_id.is_none());
1027+ // No panic, no state change.
492− assert!(model.messages.is_empty());
1028+ assert!(app.view(&model).messages.is_empty());
1029+ }
1030+
1031+ #[test]
1032+ fn messages_loaded_parses_info_and_parts() {
1033+ let app = app();
1034+ let mut model = in_chat("ses_1");
1035+ let body = serde_json::json!([
1036+ { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
1037+ "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
1038+ { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
1039+ "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
1040+ ]).to_string();
1041+ let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
1042+ let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
1043+
1044+ let view = app.view(&model);
1045+ assert_eq!(view.messages.len(), 2);
1046+ assert_eq!(view.messages[0].text, "Question?");
1047+ assert_eq!(view.messages[1].text, "Answer.");
1048+ assert!(!view.messages[1].streaming);
1049+ assert!(!view.generating);
493 }
1050 }
494}
1051}
shared/src/bin/codegen.rs+27−32
@@ -1,40 +1,35 @@
1−use std::path::PathBuf;
1+//! Crux type-generation for the shared domain (built with `--features typegen`).
2−
2+//!
3−use clap::{Parser, ValueEnum};
3+//! Demonstrates the Crux type-sharing pipeline: the core's view types are the
4−use crux_core::type_generation::facet::{Config, TypeRegistry};
4+//! single source of truth, and this binary emits matching JVM type definitions
5−use shared::OpencodeApp;
5+//! for the shell to consume across an FFI boundary. In this NDK-less build the
6−
6+//! Android shell hand-mirrors these types (see `app/.../core/ViewModel.kt`);
7−#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
7+//! wiring a real `.so` would have it consume the generated output instead.
8−enum Language {
8+//!
9− Kotlin,
9+//! Run: `cd shared && cargo run --features typegen --bin codegen`
10−}
10+//! Output: `generated/` (JVM/Java sources + the bincode serde runtime).
11
11
12−#[derive(Parser)]
13−#[command(version, about, long_about = None)]
14−struct Args {
15− #[arg(short, long, value_enum)]
16− language: Language,
17− #[arg(short, long)]
18− output_dir: PathBuf,
12+use std::path::PathBuf;
19−}
20
13
21−fn main() -> anyhow::Result<()> {
14+use anyhow::Result;
22− let args = Args::parse();
15+use crux_core::typegen::TypeGen;
16+use shared::{MessageStatus, MessageView, Screen, SessionView, ToolView, ViewModel};
23
17
24− let typegen_app = TypeRegistry::new()
18+fn main() -> Result<()> {
25− .register_app::<OpencodeApp>()?
19+ let mut gen = TypeGen::new();
26− .build()?;
27
20
28− let name = match args.language {
21+ // Register leaf types before the aggregates that reference them.
29− Language::Kotlin => "soy.iko.opencode.core",
22+ gen.register_type::<Screen>()?;
30− };
23+ gen.register_type::<MessageStatus>()?;
31− let config = Config::builder(name, &args.output_dir).build();
24+ gen.register_type::<ToolView>()?;
25+ gen.register_type::<SessionView>()?;
26+ gen.register_type::<MessageView>()?;
27+ gen.register_type::<ViewModel>()?;
32
28
33− match args.language {
29+ let out = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../generated");
34− Language::Kotlin => {
30+ std::fs::create_dir_all(&out)?;
35− typegen_app.kotlin(&config)?;
31+ gen.java("soy.iko.opencode.shared", &out)?;
36− }
37− }
38
32
33+ println!("Generated shared JVM types into {}", out.display());
39 Ok(())
34 Ok(())
40}
35}
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.
2
13
3mod app;
14mod app;
4−pub mod ffi;
15+mod protocol;
5
16
6pub use app::*;
17pub 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+}