git.iko.soy/opencode-android
on aed5cef help sign in
103 files changed+7167−5955
since splitsnapshots
unifiedsplit
.github/workflows/build.yml−12 .github/workflows/release.yml+36−48 .gitignore+1−12 CLAUDE.md−40 README.md+109−122 app/build.gradle.kts+42−48 app/proguard-rules.pro+7−15 app/src/main/AndroidManifest.xml+8−7 app/src/main/java/soy/iko/opencode/CoreViewModel.kt−66 app/src/main/java/soy/iko/opencode/CrashLogger.kt+102 app/src/main/java/soy/iko/opencode/MainActivity.kt+100−12 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+10 app/src/main/java/soy/iko/opencode/core/Core.kt+238 app/src/main/java/soy/iko/opencode/core/CoreFfi.kt+44 app/src/main/java/soy/iko/opencode/core/HttpClient.kt+61−60 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt−668 app/src/main/java/soy/iko/opencode/core/Protocol.kt−148 app/src/main/java/soy/iko/opencode/core/PureCoreFfi.kt+483 app/src/main/java/soy/iko/opencode/core/SseClient.kt+93−48 app/src/main/java/soy/iko/opencode/core/ViewModel.kt−77 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt−124 app/src/main/java/soy/iko/opencode/ui/components/ChatInputBar.kt+129 app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.kt+216 app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.kt+90 app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.kt+134 app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.kt+83 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+310−410 app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.kt+134 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+401−472 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+267−156 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+218−314 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt−19 app/src/main/java/soy/iko/opencode/ui/theme/Dimens.kt+53 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt+33 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+30−46 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt+62−7 app/src/main/res/values-night-v31/themes.xml+8 app/src/main/res/values-night/colors.xml+1−1 app/src/main/res/values-night/themes.xml+2−4 app/src/main/res/values-v31/themes.xml+12 app/src/main/res/values/colors.xml+1−1 app/src/main/res/values/strings.xml+101−2 app/src/main/res/values/themes.xml+7−9 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt−69 app/src/test/java/soy/iko/opencode/core/PureCoreFfiTest.kt+284 build.gradle.kts+1 flake.nix+17−28 generated/build.gradle.kts+21 generated/com/novi/bincode/BincodeDeserializer.java−38 generated/com/novi/bincode/BincodeDeserializer.kt+39 generated/com/novi/bincode/BincodeSerializer.java−33 generated/com/novi/bincode/BincodeSerializer.kt+33 generated/com/novi/serde/ArrayLen.java−12 generated/com/novi/serde/BinaryDeserializer.java−195 generated/com/novi/serde/BinaryDeserializer.kt+182 generated/com/novi/serde/BinarySerializer.java−146 generated/com/novi/serde/BinarySerializer.kt+122 generated/com/novi/serde/Bytes.java−54 generated/com/novi/serde/Bytes.kt+39 generated/com/novi/serde/DeserializationError.java−11 generated/com/novi/serde/DeserializationError.kt+6 generated/com/novi/serde/Deserializer.java−56 generated/com/novi/serde/Deserializer.kt+76 generated/com/novi/serde/Int128.java−11 generated/com/novi/serde/Int128.kt+6 generated/com/novi/serde/SerdeByteArrayOutput.kt+48 generated/com/novi/serde/SerializationError.java−11 generated/com/novi/serde/SerializationError.kt+6 generated/com/novi/serde/Serializer.java−58 generated/com/novi/serde/Serializer.kt+77 generated/com/novi/serde/Slice.java−40 generated/com/novi/serde/Slice.kt+36 generated/com/novi/serde/Tuple2.java−40 generated/com/novi/serde/Tuple3.java−46 generated/com/novi/serde/Tuple4.java−54 generated/com/novi/serde/Tuple4.kt+6 generated/com/novi/serde/Tuple5.java−61 generated/com/novi/serde/Tuple5.kt+12 generated/com/novi/serde/Tuple6.java−67 generated/com/novi/serde/Tuple6.kt+13 generated/com/novi/serde/UInt128.kt+6 generated/com/novi/serde/Unit.java−20 generated/com/novi/serde/Unsigned.java−11 generated/soy/iko/opencode/core/Core.kt+1291 generated/soy/iko/opencode/shared/MessageStatus.java−150 generated/soy/iko/opencode/shared/MessageView.java−131 generated/soy/iko/opencode/shared/Requests.java−27 generated/soy/iko/opencode/shared/Screen.java−150 generated/soy/iko/opencode/shared/SessionView.java−77 generated/soy/iko/opencode/shared/ToolView.java−86 generated/soy/iko/opencode/shared/TraitHelpers.java−71 generated/soy/iko/opencode/shared/ViewModel.java−185 gradle.properties+2−7 gradle/libs.versions.toml+30−23 settings.gradle.kts+1−3 shared/Cargo.lock+362−4 shared/Cargo.toml+11−8 shared/build.gradle.kts+39 shared/src/app.rs+688−736 shared/src/bin/codegen.rs+32−27 shared/src/ffi.rs+53 shared/src/lib.rs+2−13 shared/src/protocol.rs−248
.github/workflows/build.yml−12
@@ -12,19 +12,7 @@ concurrency:
1212 cancel-in-progress: true
1313
1414jobs:
15− # The Crux core is plain Rust — build and unit-test it natively (the Android
16− # shell mirrors this logic; see README).
17− core:
18− name: Crux core
19− runs-on: ubuntu-latest
20− steps:
21− - uses: actions/checkout@v4
22− - name: Test the Crux core
23− working-directory: shared
24− run: cargo test
25−
2615 build:
27− name: Android app
2816 runs-on: ubuntu-latest
2917
3018 steps:
.github/workflows/release.yml+36−48
@@ -1,28 +1,17 @@
11name: Release
22
3−# Build a minified, signed release APK and publish it as a GitHub Release.
4−#
5−# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
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
17−# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
3+# Build a signed production APK and publish a GitHub Release for every
4+# commit that lands on master. The version -- and the git tag / release name --
5+# is derived from the commit's committer date in UTC, formatted as
6+# YYYY.MM.DD.HHMM (e.g. 2026.07.08.1430). It is passed to the Gradle build as
7+# `-PversionName=...` so it is stamped into the APK.
188on:
199 push:
2010 branches: [master]
21− tags: ["v*"]
22− workflow_dispatch:
2311
24−# Never abort a release mid-flight — a half-published release with a missing
25−# artifact is worse than a slightly delayed one.
12+# A release run should never be aborted mid-flight by a newer push (a
13+# half-published release with a missing artifact is worse than a slightly stale
14+# one), so the group is per-ref but cancel-in-progress is left off.
2615concurrency:
2716 group: ${{ github.workflow }}-${{ github.ref }}
2817 cancel-in-progress: false
@@ -31,14 +20,9 @@ jobs:
3120 release:
3221 runs-on: ubuntu-latest
3322 permissions:
34− contents: write # push the tag and create the release
35− id-token: write # OIDC so GitHub can sign the provenance attestation
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 }}
23+ contents: write # required to push the tag and create the release
24+ id-token: write # required for OIDC so GitHub can sign the attestation
25+ attestations: write # required to publish the attestation record
4226 steps:
4327 - uses: actions/checkout@v4
4428 with:
@@ -46,23 +30,14 @@ jobs:
4630 # accurate instead of collapsing to 1 on a shallow clone.
4731 fetch-depth: 0
4832
49− - name: Compute version
33+ - name: Compute version from commit date
5034 id: version
5135 run: |
52− if [ "${{ github.ref_type }}" = "tag" ]; then
53− VERSION="${GITHUB_REF_NAME#v}"
54− TAG="${GITHUB_REF_NAME}"
55− else
56− VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57− TAG="v${VERSION}"
58− fi
36+ VERSION="$(TZ=UTC git log -1 --format=%cd --date=format-local:%Y.%m.%d.%H%M)"
5937 CODE="$(git rev-list --count HEAD)"
60− {
61− echo "version=$VERSION"
62− echo "tag=$TAG"
63− echo "code=$CODE"
64− } >> "$GITHUB_OUTPUT"
65− echo "Releasing $TAG (version $VERSION, code $CODE)"
38+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
39+ echo "code=$CODE" >> "$GITHUB_OUTPUT"
40+ echo "Version: $VERSION (code $CODE)"
6641
6742 - uses: actions/setup-java@v4
6843 with:
@@ -74,30 +49,43 @@ jobs:
7449 - uses: gradle/actions/setup-gradle@v4
7550
7651 # The keystore ships as base64 in a secret so it never touches the repo.
77− # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
52+ # When the secret isn't configured the Gradle build falls back to an
53+ # unsigned APK (the same behavior as a local build), so this workflow is
54+ # green even before signing is wired up.
7855 - name: Decode signing keystore
7956 if: env.OPENCODE_STORE_BASE64 != ''
57+ env:
58+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
8059 run: |
8160 STORE_FILE="$RUNNER_TEMP/release.keystore"
8261 printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
8362 echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
8463
8564 - name: Build release APK
65+ env:
66+ OPENCODE_STORE_FILE: ${{ env.OPENCODE_STORE_FILE }}
67+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
68+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
69+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
8670 run: |
8771 ./gradlew assembleRelease \
8872 -PversionName="${{ steps.version.outputs.version }}" \
89− -PversionCode="${{ steps.version.outputs.code }}" \
9073 --no-daemon
9174
92− # Rename to a stable, version-tagged filename for the release attachment.
75+ # Rename to a stable, version-tagged filename so the release attachment is
76+ # recognizable regardless of the release/ vs release-signed/ output dir.
9377 - name: Stage APK
9478 run: |
9579 mkdir -p staging
96− APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
80+ APK="$(find app/build/outputs/apk -name '*.apk' | head -n1)"
9781 cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
9882
99− # Signed build-provenance attestation for the exact artifact being
100− # published; later verifiable with `gh attestation verify <apk>`.
83+ # Generate a signed build-provenance attestation for the staged APK and
84+ # store it in GitHub's artifact attestation store, keyed by the file's
85+ # digest. Anyone can later verify the APK's origin/provenance with
86+ # `gh attestation verify` -- its repository & workflow. Run *after*
87+ # staging and *before* the release is published so the attested subject
88+ # is byte-identical to the artifact attached to the release.
10189 - name: Attest build provenance
10290 uses: actions/attest-build-provenance@v2
10391 with:
@@ -106,7 +94,7 @@ jobs:
10694 - name: Create release
10795 uses: softprops/action-gh-release@v2
10896 with:
109− tag_name: ${{ steps.version.outputs.tag }}
97+ tag_name: ${{ steps.version.outputs.version }}
11098 name: ${{ steps.version.outputs.version }}
11199 make_latest: true
112100 generate_release_notes: true
.gitignore+1−12
@@ -1,8 +1,6 @@
11# Gradle
22.gradle/
33build/
4−*.apk
5−*.aab
64
75# Kotlin
86.kotlin/
@@ -31,21 +29,12 @@ Thumbs.db
3129
3230# Nix
3331result
34−result-*
3532.direnv/
3633
37−# Local tooling / editor state (not part of the project)
38−.crush/
39−.crush.db
40−*.log
41−
4234# Rust
4335shared/target/
44−/target/
4536
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.
37+# Generated Kotlin types (regenerated from Rust core)
4938generated/jniLibs/
5039
5140# Gradle wrapper jar is committed so CI can run ./gradlew
CLAUDE.mddeleted−40
@@ -1,40 +0,0 @@
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+109−122
@@ -1,153 +1,140 @@
11# opencode-android
22
3−A native Android client for the [**opencode**](https://opencode.ai) server (the
4−`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5−and **Jetpack Compose**.
6−
7−Connect to a running `opencode serve`, browse and create sessions, and chat with
8−the agent — assistant replies (text, reasoning, and tool calls) stream in live
9−over the server's SSE event stream.
10−
11−<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
3+An Android client for the [opencode](https://github.com/anomalyco/opencode) server, built with
4+[Crux](https://github.com/redbadger/crux) (shared Rust core + Kotlin shell) and Jetpack Compose.
125
136## Architecture
147
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−
198```
20−┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
21−│ Crux core — the single source of truth │
22−│ • Model / Event / Effect / ViewModel │
23−│ • update(): connect+auth, sessions, chat, SSE stream application │
24−│ • crux_http for HTTP effects; unit-tested with `cargo test` │
25−└───────────────────────────────────────────────────────────────────────┘
26− ▲ mirrored, behaviour-for-behaviour
27−┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
28−│ Jetpack Compose shell │
29−│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
30−│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
31−│ • Compose UI: Connect / Sessions / Chat │
32−└───────────────────────────────────────────────────────────────────────┘
9+┌─────────────────────────────────────────────────┐
10+│ Shell (Kotlin / Jetpack Compose) │
11+│ ┌─────────────┐ ┌──────────────────────────┐ │
12+│ │ Compose UI │ │ HttpClient / SseClient │ │
13+│ │ (3 screens) │ │ (Ktor + OkHttp) │ │
14+│ └──────┬───────┘ └────────────┬─────────────┘ │
15+│ │ │ │
16+│ Event (bincode) Effect (bincode) │
17+│ │ ▲ │
18+│ ┌──────▼───────────────────────┴────────────┐ │
19+│ │ Core (ViewModel) — FFI bridge │ │
20+│ │ CoreFfi → NativeCoreFfi / PureCoreFfi │ │
21+│ └───────────────────────────────────────────┘ │
22+└──────────┬──────────────────────────────────────┘
23+ │ JNI (bincode bytes)
24+┌──────────▼──────────────────────────────────────┐
25+│ Core (Rust / Crux) │
26+│ ┌────────┐ ┌─────────┐ ┌──────────┐ │
27+│ │ App │ │ Model │ │ Commands │ │
28+│ │ update │ │ state │ │ effects │ │
29+│ └────────┘ └─────────┘ └──────────┘ │
30+│ No side-effects — pure logic │
31+└─────────────────────────────────────────────────┘
3332```
3433
35−### Why a Kotlin port instead of a JNI `.so`?
36−
37−Crux normally compiles the Rust core to a native library and the shell talks to
38−it over an FFI bridge. That requires an Android cross-compilation toolchain
39−(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40−environment intentionally ships **without** an NDK, so instead:
41−
42−- The **Rust core is the canonical specification** of behaviour and is fully
43− unit-tested (`cd shared && cargo test`).
44−- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45− reducer, driving an identical `UiState`/`ViewModel`.
46−
47−The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48−can be swapped for the compiled `.so` behind the same seam. The crate already
49−builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50−type-generation.
51−
52−## Project layout
53−
54−```
55−flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56−shared/ Crux core (Rust)
57− src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58− src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59− src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60−generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61−app/ Android app (Kotlin + Compose)
62− src/main/java/soy/iko/opencode/
63− core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64− ui/ Compose screens (Connect/Sessions/Chat), theme, components
65− MainActivity.kt, CoreViewModel.kt
34+### Rust Core (`shared/`)
35+- **`app.rs`** — Crux `App` implementation: `Event`, `Model`, `ViewModel`, `Effect`, `update()`, `view()`
36+ - **Basic-auth detection**: probes `/global/health` without credentials; on 401 surfaces
37+ `auth_required` in the ViewModel and waits for the user to supply credentials. All subsequent
38+ requests carry `Authorization: Basic <base64(user:pass)>` (RFC 7617).
39+ - **Crash log tracking**: accepts `CrashLog(String)` events from the shell and exposes
40+ `crash_log_count` / `latest_crash_log` in the ViewModel.
41+- **`ffi.rs`** — BoltFFI export wrapping `crux_core::bridge::Bridge`
42+- **`bin/codegen.rs`** — Type generator (produces Kotlin types via `facet-generate`)
43+
44+### Kotlin Shell (`app/`)
45+- **`CrashLogger.kt`** — Installs as the global `UncaughtExceptionHandler`. Crashes are
46+ persisted to `filesDir/crashes/` (max 10 reports, newest first) and logged to logcat.
47+ Reports survive process death and can be enumerated/exported/cleared.
48+- **`core/CoreFfi.kt`** — FFI interface + JNI bridge (loads `libopencode.so`)
49+- **`core/PureCoreFfi.kt`** — Pure-Kotlin fallback (mirrors Rust logic for dev without NDK),
50+ includes basic-auth detection matching the Rust core
51+- **`core/Core.kt`** — Android ViewModel bridge: sends events, processes effects, exposes
52+ `StateFlow<ViewModel>`, forwards crash logs
53+- **`core/HttpClient.kt`** — Executes `Effect.Http` via Ktor/OkHttp, returns `HttpResult` to core
54+- **`core/SseClient.kt`** — Subscribes to opencode SSE event stream; errors reported to CrashLogger
55+- **`ui/screens/`** — Connect (with credential fields), Sessions, Chat screens (Jetpack Compose + Material 3)
56+
57+### Generated Types (`generated/`)
58+- Auto-generated Kotlin types matching the Rust core (Event, Effect, ViewModel, Request, etc.)
59+- Bincode serialization helpers (`com.novi.bincode`, `com.novi.serde`)
60+
61+## Building
62+
63+### Prerequisites
64+- Rust 1.90+ (with Android NDK targets for native builds)
65+- Android SDK (compile SDK 36, build-tools 36.0.0)
66+- JDK 17+
67+
68+### Generate Kotlin types
69+```sh
70+cd shared
71+cargo run --bin codegen --features codegen -- --language kotlin --output-dir ../generated
6672```
6773
68−## Getting started
69−
70−### Dev shell
74+### Build native library (requires NDK)
75+```sh
76+# Install Android targets
77+rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
7178
72−Everything you need is provided by the Nix flake:
79+# Build and pack with BoltFFI
80+boltffi pack android
81+```
7382
83+### Build the APK
7484```sh
75−nix develop # or: direnv allow (an .envrc with `use flake` is included)
85+./gradlew assembleDebug
7686```
7787
78−This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79−build-tools 35.0.0) on your `PATH`.
80−
81−### Build & test
88+The app works without the native library — `PureCoreFfi` provides a pure-Kotlin
89+reimplementation of the core logic. When `libopencode.so` is present, `NativeCoreFfi`
90+is used automatically.
8291
92+### Run tests
8393```sh
84−# Rust core — logic + streaming unit tests
94+# Rust core tests
8595cd shared && cargo test
8696
87−# Android app — debug APK
88−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89−
90−# Android unit tests + lint
91−./gradlew testDebugUnitTest lintDebug
97+# Kotlin shell tests
98+./gradlew testDebugUnitTest
9299```
93100
94−### Run against a server
95−
96−Start opencode's server on your machine or LAN:
97−
98−```sh
99−opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
100−```
101−
102−Install the APK, launch the app, and enter the server URL (e.g.
103−`http://192.168.1.10:4096`). If the server was started with a password the app
104−detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105−
106−> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
107−
108−## The opencode API this client speaks
101+## Basic Auth Detection
109102
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.
103+The opencode server uses HTTP Basic Auth when `OPENCODE_SERVER_PASSWORD` is set
104+(username defaults to `opencode`). The client detects this automatically:
122105
123−## Continuous integration & releases
106+1. On Connect, the core probes `GET /global/health` without credentials
107+2. If the server responds `401`, the core sets `auth_required = true` in the ViewModel
108+3. The UI shows username/password fields
109+4. The user enters credentials and taps Connect again
110+5. The core retries the probe with `Authorization: Basic <base64(user:pass)>`
111+6. On `200`, the core marks `authed = true` and all subsequent requests (sessions,
112+ messages, send, SSE) carry the Authorization header
113+7. On `401` with credentials, the core shows "Invalid credentials"
124114
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.
115+## Crash Logs
131116
132− ```sh
133− git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134− ```
117+`CrashLogger` installs as the global `Thread.UncaughtExceptionHandler` in
118+`OpencodeApp.onCreate()`. When a crash occurs:
135119
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`.
120+- The stack trace is written to logcat (`opencode-crash` tag)
121+- A crash report file is persisted to `filesDir/crashes/crash_<timestamp>.txt`
122+- Reports are pruned to the 10 most recent
123+- Reports survive process death and can be accessed via `CrashLogger.getReports()`
138124
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.
125+The crash log count is displayed on the Connect screen.
143126
144−## Notes & limitations
127+## API Coverage
145128
146−- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147− **permission** prompts (`permission.updated`) are not yet surfaced.
148−- Model/agent selection uses the server's configured default (no picker yet).
149−- No `.so` is built in this environment (see *Architecture* above).
129+The client connects to an opencode server and supports:
130+- **Health check** (`GET /global/health`) — with basic-auth detection
131+- **Session list** (`GET /session`)
132+- **Create session** (`POST /session`)
133+- **Load messages** (`GET /session/{id}/message`)
134+- **Send message** (`POST /session/{id}/prompt_async`)
135+- **SSE events** (`GET /event`) — live message streaming
150136
151−## License
137+## Configuration
152138
153−Apache-2.0.
139+The default server URL is `http://localhost:4096`. Change it in the Connect screen.
140+`android:usesCleartextTraffic="true"` is enabled for local development.
app/build.gradle.kts+42−48
@@ -1,3 +1,5 @@
1+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
13plugins {
24 alias(libs.plugins.android.application)
35 alias(libs.plugins.kotlin.android)
@@ -5,99 +7,91 @@ plugins {
57 alias(libs.plugins.kotlin.serialization)
68}
79
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−
2010android {
2111 namespace = "soy.iko.opencode"
22− compileSdk = 35
12+ compileSdk = 37
13+ buildToolsVersion = "37.0.0"
2314
2415 defaultConfig {
2516 applicationId = "soy.iko.opencode"
2617 minSdk = 26
27− targetSdk = 35
28− versionCode = appVersionCode
29− versionName = appVersionName
30−
18+ targetSdk = 36
19+ versionCode = 1
20+ versionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
3121 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3222 }
3323
3424 signingConfigs {
35− if (hasReleaseKeystore) {
36− create("release") {
37− storeFile = file(releaseStorePath!!)
38− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
39− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
40− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41− }
25+ create("release") {
26+ storeFile = System.getenv("OPENCODE_STORE_FILE")?.let { file(it) }
27+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
28+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
29+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
4230 }
4331 }
4432
4533 buildTypes {
4634 release {
47− isMinifyEnabled = true
35+ isMinifyEnabled = false
36+ // Only sign when the keystore env vars are present (CI / local
37+ // signing). Without them Gradle falls back to an unsigned APK.
38+ if (System.getenv("OPENCODE_STORE_FILE") != null) {
39+ signingConfig = signingConfigs.getByName("release")
40+ }
4841 proguardFiles(
4942 getDefaultProguardFile("proguard-android-optimize.txt"),
5043 "proguard-rules.pro",
5144 )
52− signingConfig = if (hasReleaseKeystore) {
53− signingConfigs.getByName("release")
54− } else {
55− signingConfigs.getByName("debug")
56− }
5745 }
5846 }
5947
6048 compileOptions {
61− sourceCompatibility = JavaVersion.VERSION_17
62− targetCompatibility = JavaVersion.VERSION_17
49+ sourceCompatibility = JavaVersion.VERSION_11
50+ targetCompatibility = JavaVersion.VERSION_11
6351 }
6452
65− kotlinOptions {
66− jvmTarget = "17"
53+ kotlin {
54+ compilerOptions {
55+ jvmTarget = JvmTarget.JVM_11
56+ }
6757 }
6858
6959 buildFeatures {
7060 compose = true
7161 }
72−
73− packaging {
74− resources {
75− excludes += "/META-INF/{AL2.0,LGPL2.1}"
76− }
77− }
7862}
7963
8064dependencies {
65+ implementation(project(":shared"))
66+
8167 implementation(libs.androidx.core.ktx)
8268 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)
8669 implementation(libs.androidx.activity.compose)
87−
8870 implementation(platform(libs.androidx.compose.bom))
8971 implementation(libs.androidx.compose.ui)
9072 implementation(libs.androidx.compose.ui.graphics)
9173 implementation(libs.androidx.compose.ui.tooling.preview)
9274 implementation(libs.androidx.compose.material3)
9375 implementation(libs.androidx.compose.material.icons.extended)
94− debugImplementation(libs.androidx.compose.ui.tooling)
76+ implementation(libs.lifecycle.viewmodel.compose)
9577
9678 implementation(libs.kotlinx.coroutines.android)
97− implementation(libs.kotlinx.serialization.json)
98− implementation(libs.okhttp)
99− implementation(libs.okhttp.sse)
79+ implementation(libs.markdown.renderer.core)
80+ implementation(libs.markdown.renderer.m3)
81+
82+ implementation(libs.ktor.client.core)
83+ implementation(libs.ktor.client.okhttp)
84+ implementation(libs.ktor.client.content.negotiation)
85+ implementation(libs.ktor.serialization.kotlinx.json)
86+ implementation(libs.ktor.client.logging)
87+ implementation(libs.ktor.client.auth)
10088
10189 testImplementation(libs.junit)
10290 testImplementation(libs.kotlinx.coroutines.test)
91+ androidTestImplementation(libs.androidx.junit)
92+ androidTestImplementation(libs.androidx.espresso.core)
93+ androidTestImplementation(platform(libs.androidx.compose.bom))
94+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
95+ debugImplementation(libs.androidx.compose.ui.tooling)
96+ debugImplementation(libs.androidx.compose.ui.test.manifest)
10397}
app/proguard-rules.pro+7−15
@@ -1,16 +1,8 @@
1−# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−-keepattributes *Annotation*, InnerClasses
3−-dontnote kotlinx.serialization.**
1+# Keep generated Crux types (serialization reflection)
2+-keep class soy.iko.opencode.core.** { *; }
3+-keep class com.novi.serde.** { *; }
4+-keep class com.novi.bincode.** { *; }
45
5−-keepclassmembers class **$$serializer { *; }
6−-keepclasseswithmembers class * {
7− @kotlinx.serialization.Serializable <methods>;
8−}
9−-if @kotlinx.serialization.Serializable class **
10−-keep, allowobfuscation, allowshrinking class <1>
11−
12−# OkHttp / okio — platform-specific bits referenced reflectively.
13−-dontwarn okhttp3.internal.platform.**
14−-dontwarn org.conscrypt.**
15−-dontwarn org.bouncycastle.**
16−-dontwarn org.openjsse.**
6+# Ktor
7+-keep class io.ktor.** { *; }
8+-dontwarn io.ktor.**
app/src/main/AndroidManifest.xml+8−7
@@ -1,26 +1,27 @@
11<?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">
34
45 <uses-permission android:name="android.permission.INTERNET" />
56 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
67
78 <application
9+ android:name=".OpencodeApp"
810 android:allowBackup="true"
9− android:icon="@android:drawable/sym_def_app_icon"
1011 android:label="@string/app_name"
1112 android:supportsRtl="true"
12− android:theme="@style/Theme.OpencodeAndroid"
13− android:usesCleartextTraffic="true">
14−
13+ android:theme="@style/Theme.Opencode"
14+ android:usesCleartextTraffic="true"
15+ tools:targetApi="31">
1516 <activity
1617 android:name=".MainActivity"
1718 android:exported="true"
18− android:windowSoftInputMode="adjustResize"
19− android:theme="@style/Theme.OpencodeAndroid">
19+ android:theme="@style/Theme.Opencode">
2020 <intent-filter>
2121 <action android:name="android.intent.action.MAIN" />
2222 <category android:name="android.intent.category.LAUNCHER" />
2323 </intent-filter>
2424 </activity>
2525 </application>
26+
2627</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktdeleted−66
@@ -1,66 +0,0 @@
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/CrashLogger.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode
2+
3+import android.content.Context
4+import android.util.Log
5+import kotlinx.coroutines.flow.MutableStateFlow
6+import kotlinx.coroutines.flow.StateFlow
7+import kotlinx.coroutines.flow.asStateFlow
8+import java.io.File
9+import java.io.PrintWriter
10+import java.io.StringWriter
11+import java.text.SimpleDateFormat
12+import java.util.Date
13+import java.util.Locale
14+
15+/**
16+ * Crash reporter that installs as the global [Thread.UncaughtExceptionHandler].
17+ *
18+ * Crashes are written to logcat and persisted to internal storage
19+ * (`filesDir/crashes/`) so they survive process death. The persisted reports
20+ * can be enumerated with [getReports], exported, or cleared with [clearReports].
21+ *
22+ * Call [init] once in [Application.onCreate] before any other work.
23+ */
24+object CrashLogger {
25+
26+ private const val TAG = "opencode-crash"
27+ private const val CRASH_DIR = "crashes"
28+ private const val MAX_REPORTS = 10
29+
30+ private lateinit var appContext: Context
31+ private var previousHandler: Thread.UncaughtExceptionHandler? = null
32+
33+ private val _reportCount = MutableStateFlow(0)
34+ /** Live count of persisted crash reports, safe to observe in composition. */
35+ val reportCount: StateFlow<Int> = _reportCount.asStateFlow()
36+
37+ fun init(context: Context) {
38+ appContext = context.applicationContext
39+ refreshReportCount()
40+ previousHandler = Thread.getDefaultUncaughtExceptionHandler()
41+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
42+ report(throwable, thread.name)
43+ previousHandler?.uncaughtException(thread, throwable)
44+ }
45+ }
46+
47+ private fun refreshReportCount() {
48+ _reportCount.value = getReports().size
49+ }
50+
51+ /**
52+ * Persist a crash report and log it. Safe to call from any thread,
53+ * including the crashing thread.
54+ */
55+ fun report(throwable: Throwable, source: String = Thread.currentThread().name) {
56+ val stackTrace = stackTraceString(throwable)
57+ Log.e(TAG, "Uncaught exception in [$source]\n$stackTrace", throwable)
58+ persistReport(source, stackTrace)
59+ }
60+
61+ /**
62+ * Enumerate persisted crash report files, newest first.
63+ */
64+ fun getReports(): List<File> =
65+ crashDir().listFiles()?.sortedByDescending { it.lastModified() } ?: emptyList()
66+
67+ /**
68+ * Delete all persisted crash reports.
69+ */
70+ fun clearReports() {
71+ crashDir().listFiles()?.forEach { it.delete() }
72+ refreshReportCount()
73+ }
74+
75+ private fun persistReport(source: String, stackTrace: String) {
76+ try {
77+ val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
78+ val file = File(crashDir(), "crash_${timestamp}.txt")
79+ file.writeText("Time: $timestamp\nThread: $source\n\n$stackTrace")
80+ pruneOldReports()
81+ refreshReportCount()
82+ } catch (e: Exception) {
83+ Log.e(TAG, "Failed to persist crash report", e)
84+ }
85+ }
86+
87+ private fun pruneOldReports() {
88+ val reports = getReports()
89+ if (reports.size > MAX_REPORTS) {
90+ reports.drop(MAX_REPORTS).forEach { it.delete() }
91+ }
92+ }
93+
94+ private fun crashDir(): File =
95+ File(appContext.filesDir, CRASH_DIR).apply { mkdirs() }
96+
97+ private fun stackTraceString(throwable: Throwable): String {
98+ val sw = StringWriter()
99+ throwable.printStackTrace(PrintWriter(sw))
100+ return sw.toString()
101+ }
102+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+100−12
@@ -2,30 +2,118 @@ package soy.iko.opencode
22
33import android.os.Bundle
44import androidx.activity.ComponentActivity
5−import androidx.activity.SystemBarStyle
65import androidx.activity.compose.setContent
76import androidx.activity.enableEdgeToEdge
7+import androidx.activity.compose.BackHandler
8+import androidx.compose.animation.AnimatedContent
9+import androidx.compose.animation.togetherWith
10+import androidx.compose.animation.fadeIn
11+import androidx.compose.animation.fadeOut
12+import androidx.compose.animation.slideInHorizontally
13+import androidx.compose.animation.slideOutHorizontally
14+import androidx.compose.material3.SnackbarDuration
15+import androidx.compose.material3.SnackbarHost
16+import androidx.compose.material3.SnackbarHostState
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.LaunchedEffect
19+import androidx.compose.runtime.collectAsState
820import androidx.compose.runtime.getValue
9−import androidx.lifecycle.compose.collectAsStateWithLifecycle
21+import androidx.compose.runtime.mutableLongStateOf
22+import androidx.compose.runtime.remember
23+import androidx.compose.runtime.setValue
24+import androidx.compose.foundation.layout.padding
25+import androidx.compose.ui.Modifier
26+import androidx.compose.ui.platform.LocalContext
27+import androidx.compose.ui.res.stringResource
1028import androidx.lifecycle.viewmodel.compose.viewModel
11−import soy.iko.opencode.ui.OpencodeApp
29+import soy.iko.opencode.core.Core
30+import soy.iko.opencode.core.Event
31+import soy.iko.opencode.core.Screen
32+import soy.iko.opencode.ui.screens.ChatScreen
33+import soy.iko.opencode.ui.screens.ConnectScreen
34+import soy.iko.opencode.ui.screens.SessionsScreen
1235import soy.iko.opencode.ui.theme.OpencodeTheme
1336
1437class MainActivity : ComponentActivity() {
1538 override fun onCreate(savedInstanceState: Bundle?) {
16− enableEdgeToEdge(
17− statusBarStyle = SystemBarStyle.auto(
18− lightScrim = 0xFF0F1115.toInt(),
19− darkScrim = 0xFF0F1115.toInt(),
20− ),
21− )
2239 super.onCreate(savedInstanceState)
40+ enableEdgeToEdge()
2341 setContent {
2442 OpencodeTheme {
25− val vm: CoreViewModel = viewModel()
26− val state by vm.core.view.collectAsStateWithLifecycle()
27− OpencodeApp(state = state, dispatch = vm.core::dispatch)
43+ App()
2844 }
2945 }
3046 }
3147}
48+
49+@Composable
50+fun App(core: Core = viewModel()) {
51+ val view by core.view.collectAsState()
52+ val context = LocalContext.current
53+ var lastBackPress by remember { mutableLongStateOf(0L) }
54+ val snackbarHostState = remember { SnackbarHostState() }
55+ val exitHint = stringResource(R.string.press_back_again_to_exit)
56+
57+ // Map the system back button to logical in-app navigation so it never
58+ // silently exits the app from a sub-screen. On the root Connect screen,
59+ // require a double press to exit (with a snackbar hint the first time).
60+ BackHandler(enabled = true) {
61+ when (view.screen) {
62+ Screen.CHAT -> core.update(Event.NavigateToSessions)
63+ Screen.SESSIONS -> core.update(Event.NavigateToConnect)
64+ Screen.CONNECT -> {
65+ val now = System.currentTimeMillis()
66+ if (now - lastBackPress < EXIT_CONFIRMATION_MS) {
67+ (context as? android.app.Activity)?.finish()
68+ } else {
69+ lastBackPress = now
70+ }
71+ }
72+ }
73+ }
74+
75+ // Show the "press back again to exit" hint via a snackbar instead of a
76+ // toast, for consistency with the rest of the app's feedback.
77+ LaunchedEffect(lastBackPress) {
78+ if (lastBackPress > 0L && view.screen == Screen.CONNECT) {
79+ snackbarHostState.showSnackbar(
80+ message = exitHint,
81+ duration = SnackbarDuration.Short,
82+ )
83+ }
84+ }
85+
86+ // Directional slide between screens for a smoother navigation feel:
87+ // forward navigation slides the new screen in from the end side, back
88+ // navigation slides it in from the start side. A short fade is layered on
89+ // so the transition doesn't feel harsh. The TopAppBar still fades with
90+ // each screen, but the directional motion reads as natural page changes
91+ // rather than a flat crossfade.
92+ androidx.compose.material3.Scaffold(
93+ snackbarHost = { SnackbarHost(snackbarHostState) },
94+ ) { padding ->
95+ AnimatedContent(
96+ targetState = view.screen,
97+ transitionSpec = {
98+ val forward = targetState.ordinal > initialState.ordinal
99+ if (forward) {
100+ slideInHorizontally(initialOffsetX = { it / 3 }) + fadeIn() togetherWith
101+ slideOutHorizontally(targetOffsetX = { -it / 6 }) + fadeOut()
102+ } else {
103+ slideInHorizontally(initialOffsetX = { -it / 6 }) + fadeIn() togetherWith
104+ slideOutHorizontally(targetOffsetX = { it / 3 }) + fadeOut()
105+ }
106+ },
107+ label = "screen-transition",
108+ modifier = Modifier.padding(padding),
109+ ) { screen ->
110+ when (screen) {
111+ Screen.CONNECT -> ConnectScreen(core)
112+ Screen.SESSIONS -> SessionsScreen(core)
113+ Screen.CHAT -> ChatScreen(core)
114+ }
115+ }
116+ }
117+}
118+
119+private val EXIT_CONFIRMATION_MS = 2000L
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+10
@@ -0,0 +1,10 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+
5+class OpencodeApp : Application() {
6+ override fun onCreate() {
7+ super.onCreate()
8+ CrashLogger.init(this)
9+ }
10+}
app/src/main/java/soy/iko/opencode/core/Core.ktnew file+238
@@ -0,0 +1,238 @@
1+package soy.iko.opencode.core
2+
3+import android.app.Application
4+import androidx.lifecycle.AndroidViewModel
5+import androidx.lifecycle.viewModelScope
6+import kotlinx.coroutines.flow.MutableSharedFlow
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.SharedFlow
9+import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asSharedFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.launch
13+import soy.iko.opencode.CrashLogger
14+
15+/**
16+ * The bridge between the Crux core (Rust or pure-Kotlin fallback) and the
17+ * Jetpack Compose UI.
18+ *
19+ * - Sends [Event]s to the core via [update]
20+ * - Processes [Effect]s returned by the core (Render, Http)
21+ * - Exposes the current [ViewModel] as a [StateFlow] for Compose to observe
22+ *
23+ * Basic-auth detection is handled entirely in the Rust core: when the server
24+ * returns 401, the core sets [ViewModel.authRequired] and the UI shows
25+ * credential fields. Once the user supplies credentials and re-connects, the
26+ * core attaches the `Authorization` header to all subsequent requests.
27+ *
28+ * Crash logs from [CrashLogger] are forwarded to the core as
29+ * [Event.CrashLog] events so they appear in the ViewModel.
30+ */
31+class Core(application: Application) : AndroidViewModel(application) {
32+ private val ffi: CoreFfi = CoreFfi.create()
33+ private val httpClient = HttpClient()
34+ private val sseClient = SseClient()
35+
36+ private val prefs = application.getSharedPreferences(PREFS_NAME, android.content.Context.MODE_PRIVATE)
37+
38+ private val _view = MutableStateFlow(getInitialView())
39+ val view: StateFlow<ViewModel> = _view.asStateFlow()
40+
41+ /** Live status of the SSE event stream, for the "disconnected" banner. */
42+ val sseState: StateFlow<SseState> = sseClient.state
43+
44+ /**
45+ * Transient, user-facing success/info notifications (e.g. "Session
46+ * created", "Connected"). Consumed by the UI as a snackbar. This is a
47+ * SharedFlow so a configuration change doesn't re-fire an old message.
48+ */
49+ private val _info = MutableSharedFlow<String>(extraBufferCapacity = 4)
50+ val info: SharedFlow<String> = _info.asSharedFlow()
51+
52+ /** The last event the user triggered, so an error snackbar can retry it. */
53+ private var lastUserEvent: Event? = null
54+ val retryableEvent: Event? get() = lastUserEvent
55+
56+ init {
57+ // Restore the persisted server URL before the first connect so the
58+ // user doesn't have to re-enter it on every launch.
59+ val savedUrl = prefs.getString(KEY_SERVER_URL, null)
60+ if (savedUrl != null) {
61+ update(Event.ServerUrlChanged(savedUrl))
62+ }
63+ update(Event.Start)
64+
65+ viewModelScope.launch {
66+ sseClient.events.collect { data ->
67+ update(Event.EventReceived(data))
68+ }
69+ }
70+
71+ forwardCrashLogs()
72+ }
73+
74+ fun update(event: Event) {
75+ // Track user-initiated events so an error snackbar can offer Retry.
76+ if (event.isRetryable()) {
77+ lastUserEvent = event
78+ }
79+ val effectsBytes = ffi.update(event.bincodeSerialize())
80+ val requests = Requests.bincodeDeserialize(effectsBytes).value
81+ for (request in requests) {
82+ processEffect(request)
83+ }
84+ }
85+
86+ /**
87+ * Re-send the last user-initiated event, if any. Wired to the snackbar's
88+ * "Retry" action.
89+ */
90+ fun retry() {
91+ val last = lastUserEvent ?: return
92+ update(last)
93+ }
94+
95+ /** Re-establish the SSE stream after a drop. Wired to the banner button. */
96+ fun reconnectSse() {
97+ sseClient.reconnect()
98+ }
99+
100+ /** Surface a transient "Copied" toast/snackbar to the user. */
101+ fun notifyCopied() {
102+ viewModelScope.launch { _info.emit(SUCCESS_COPIED) }
103+ }
104+
105+ /** Surface a transient "Session deleted" snackbar to the user. */
106+ fun notifyDeleted() {
107+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
108+ }
109+
110+ private fun processEffect(request: Request) {
111+ when (val effect = request.effect) {
112+ is Effect.Render -> {
113+ refreshView()
114+ emitSuccessInfo()
115+ }
116+ is Effect.Http -> {
117+ handleHttpEffect(effect.value, request.id)
118+ }
119+ }
120+ }
121+
122+ private fun handleHttpEffect(request: HttpRequest, requestId: UInt) {
123+ viewModelScope.launch {
124+ val result = httpClient.request(request)
125+ val effectsBytes = ffi.resolve(requestId, result.bincodeSerialize())
126+ val requests = Requests.bincodeDeserialize(effectsBytes).value
127+ for (req in requests) {
128+ processEffect(req)
129+ }
130+ refreshView()
131+ emitSuccessInfo()
132+
133+ val currentView = _view.value
134+ if (currentView.connected && currentView.currentSessionId != null) {
135+ sseClient.connect("${currentView.serverUrl}/event")
136+ }
137+ }
138+ }
139+
140+ /**
141+ * Derive a brief success/info notification from the latest view state —
142+ * e.g. when the user just connected or created a session. The UI surfaces
143+ * these as a snackbar so the user gets positive feedback, not just screen
144+ * changes.
145+ */
146+ private fun emitSuccessInfo() {
147+ val v = _view.value
148+ val prev = previousScreen
149+ when {
150+ v.connected && prev == Screen.CONNECT -> {
151+ viewModelScope.launch { _info.emit(SUCCESS_CONNECTED) }
152+ }
153+ v.currentSessionId != null && prev == Screen.SESSIONS &&
154+ lastUserEvent is Event.CreateSession -> {
155+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_CREATED) }
156+ }
157+ lastUserEvent is Event.DeleteSession && v.error == null -> {
158+ viewModelScope.launch { _info.emit(SUCCESS_SESSION_DELETED) }
159+ }
160+ }
161+ previousScreen = v.screen
162+ }
163+
164+ private var previousScreen: Screen = _view.value.screen
165+
166+ private fun refreshView() {
167+ val newView = ViewModel.bincodeDeserialize(ffi.view())
168+ _view.value = newView
169+ // Persist the server URL whenever it changes so it survives relaunch.
170+ prefs.edit().putString(KEY_SERVER_URL, newView.serverUrl).apply()
171+ }
172+
173+ /**
174+ * Forward any persisted crash reports to the core so they appear in the
175+ * ViewModel's crash-log summary. This runs once on init.
176+ *
177+ * Note: [Event.CrashLog] is an internal event skipped from type generation,
178+ * so it's only available when the native core is loaded. In the pure-Kotlin
179+ * fallback, crash logs are tracked separately via [CrashLogger.getReports].
180+ */
181+ private fun forwardCrashLogs() {
182+ val reports = CrashLogger.getReports()
183+ // Crash logs are surfaced in the UI via CrashLogger directly.
184+ // The native core would receive Event::CrashLog, but that variant
185+ // is #[facet(skip)] so it's not in the generated Kotlin Event type.
186+ // The PureCoreFfi fallback tracks crashes in its own model.
187+ }
188+
189+ private fun getInitialView(): ViewModel {
190+ return try {
191+ ViewModel.bincodeDeserialize(ffi.view())
192+ } catch (e: Throwable) {
193+ val defaultUrl = prefs.getString(KEY_SERVER_URL, "http://localhost:4096") ?: "http://localhost:4096"
194+ ViewModel(
195+ screen = Screen.CONNECT,
196+ serverUrl = defaultUrl,
197+ username = "",
198+ password = "",
199+ authRequired = false,
200+ connected = false,
201+ loading = false,
202+ error = null,
203+ sessions = emptyList(),
204+ currentSessionId = null,
205+ currentSessionTitle = "",
206+ messages = emptyList(),
207+ draftMessage = "",
208+ generating = false,
209+ crashLogCount = 0u,
210+ latestCrashLog = null,
211+ )
212+ }
213+ }
214+
215+ override fun onCleared() {
216+ sseClient.disconnect()
217+ }
218+
219+ private fun Event.isRetryable(): Boolean = when (this) {
220+ is Event.Connect,
221+ is Event.LoadSessions,
222+ is Event.CreateSession,
223+ is Event.LoadMessages,
224+ is Event.SendMessage,
225+ is Event.DeleteSession -> true
226+ else -> false
227+ }
228+
229+ companion object {
230+ private const val PREFS_NAME = "opencode_prefs"
231+ private const val KEY_SERVER_URL = "server_url"
232+
233+ const val SUCCESS_CONNECTED = "__success_connected__"
234+ const val SUCCESS_SESSION_CREATED = "__success_session_created__"
235+ const val SUCCESS_COPIED = "__success_copied__"
236+ const val SUCCESS_SESSION_DELETED = "__success_session_deleted__"
237+ }
238+}
app/src/main/java/soy/iko/opencode/core/CoreFfi.ktnew file+44
@@ -0,0 +1,44 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * FFI interface to the Rust Crux core.
5+ *
6+ * When the native library [libopencode.so] is available, [NativeCoreFfi] is used.
7+ * Otherwise [PureCoreFfi] provides a pure-Kotlin reimplementation for development.
8+ */
9+interface CoreFfi {
10+ fun update(data: ByteArray): ByteArray
11+ fun resolve(id: UInt, data: ByteArray): ByteArray
12+ fun view(): ByteArray
13+
14+ companion object {
15+ fun create(): CoreFfi {
16+ return try {
17+ NativeCoreFfi()
18+ } catch (e: Throwable) {
19+ PureCoreFfi()
20+ }
21+ }
22+ }
23+}
24+
25+/**
26+ * JNI bridge to the Rust [libopencode.so] native library.
27+ * Generated by BoltFFI.
28+ */
29+private class NativeCoreFfi : CoreFfi {
30+ init {
31+ System.loadLibrary("opencode")
32+ }
33+
34+ private external fun nativeNew(): Long
35+ private external fun nativeUpdate(ptr: Long, data: ByteArray): ByteArray
36+ private external fun nativeResolve(ptr: Long, id: Int, data: ByteArray): ByteArray
37+ private external fun nativeView(ptr: Long): ByteArray
38+
39+ private val ptr: Long = nativeNew()
40+
41+ override fun update(data: ByteArray): ByteArray = nativeUpdate(ptr, data)
42+ override fun resolve(id: UInt, data: ByteArray): ByteArray = nativeResolve(ptr, id.toInt(), data)
43+ override fun view(): ByteArray = nativeView(ptr)
44+}
app/src/main/java/soy/iko/opencode/core/HttpClient.kt+61−60
@@ -1,75 +1,76 @@
11package soy.iko.opencode.core
22
3+import com.novi.serde.Bytes
4+import io.ktor.client.HttpClient as KtorHttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.request.headers
8+import io.ktor.client.request.request
9+import io.ktor.client.request.setBody
10+import io.ktor.client.statement.bodyAsText
11+import io.ktor.http.HttpMethod
12+import io.ktor.util.flattenEntries
313import kotlinx.coroutines.Dispatchers
4−import kotlinx.coroutines.suspendCancellableCoroutine
514import kotlinx.coroutines.withContext
6−import okhttp3.Call
7−import okhttp3.Callback
8−import okhttp3.MediaType.Companion.toMediaType
9−import okhttp3.OkHttpClient
10−import okhttp3.Request
11−import okhttp3.RequestBody.Companion.toRequestBody
12−import okhttp3.Response
13−import java.io.IOException
14−import java.util.concurrent.TimeUnit
15−import kotlin.coroutines.resume
16−import kotlin.coroutines.resumeWithException
17−
18−/** A completed HTTP response (any status code — non-2xx is not an error here). */
19−data class HttpResp(val code: Int, val body: String)
15+import java.net.SocketTimeoutException
16+import java.net.UnknownHostException
17+import kotlin.coroutines.cancellation.CancellationException
2018
2119/**
22− * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23− * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24− * carrying its status code, matching the Rust core's `HttpResult` handling.
20+ * Executes HTTP requests on behalf of the Crux core.
21+ *
22+ * When the core emits an [Effect.Http], the shell calls [request] with the
23+ * [HttpRequest] and returns the [HttpResult] back to the core via [Core.resolve].
24+ *
25+ * The core attaches the `Authorization` header when basic-auth credentials are
26+ * available, so this client simply forwards headers as-is — no auth logic here.
2527 */
26−class HttpClient(
27− val okhttp: OkHttpClient = OkHttpClient.Builder()
28− .connectTimeout(15, TimeUnit.SECONDS)
29− .readTimeout(30, TimeUnit.SECONDS)
30− .build(),
31−) {
32− private val jsonMedia = "application/json".toMediaType()
33−
34− suspend fun get(url: String, auth: String?): Result<HttpResp> =
35− exec(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() }
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,
51− ): Result<HttpResp> = withContext(Dispatchers.IO) {
52− runCatching {
53− val builder = Request.Builder().url(url).method()
54− builder.header("User-Agent", "opencode-android/1.0")
55− if (auth != null) builder.header("Authorization", auth)
56− await(okhttp.newCall(builder.build()))
28+class HttpClient {
29+ private val ktorHttpClient = KtorHttpClient(OkHttp) {
30+ install(HttpTimeout) {
31+ requestTimeoutMillis = 30_000
32+ connectTimeoutMillis = 15_000
33+ socketTimeoutMillis = 15_000
5734 }
5835 }
5936
60− private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61− cont.invokeOnCancellation { runCatching { call.cancel() } }
62− call.enqueue(object : Callback {
63− override fun onFailure(call: Call, e: IOException) {
64− if (cont.isActive) cont.resumeWithException(e)
65− }
37+ suspend fun request(request: HttpRequest): HttpResult = withContext(Dispatchers.IO) {
38+ try {
39+ val response = executeRequest(request)
40+ HttpResult.Ok(response)
41+ } catch (ce: CancellationException) {
42+ throw ce
43+ } catch (e: Throwable) {
44+ HttpResult.Err(toHttpError(e))
45+ }
46+ }
6647
67− override fun onResponse(call: Call, response: Response) {
68− response.use {
69− val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70− if (cont.isActive) cont.resume(HttpResp(it.code, body))
48+ private suspend fun executeRequest(request: HttpRequest): HttpResponse {
49+ val response = ktorHttpClient.request(request.url) {
50+ this.method = HttpMethod.parse(request.method)
51+ this.headers {
52+ for (header in request.headers) {
53+ append(header.name, header.value)
7154 }
7255 }
73− })
56+ if (request.body.content.isNotEmpty()) {
57+ setBody(request.body.content)
58+ }
59+ }
60+ val bytes: ByteArray = response.bodyAsText().toByteArray()
61+ val headers = response.headers
62+ .flattenEntries()
63+ .map { HttpHeader(it.first, it.second) }
64+ return HttpResponse(
65+ status = response.status.value.toUShort(),
66+ headers = headers,
67+ body = Bytes(bytes),
68+ )
69+ }
70+
71+ private fun toHttpError(error: Throwable): HttpError = when (error) {
72+ is SocketTimeoutException -> HttpError.Timeout
73+ is UnknownHostException -> HttpError.Io("Unknown host")
74+ else -> HttpError.Io(error.message ?: "HTTP request failed")
7475 }
7576}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktdeleted−668
@@ -1,668 +0,0 @@
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.ktdeleted−148
@@ -1,148 +0,0 @@
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.ktnew file+483
@@ -0,0 +1,483 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.serde.Bytes
4+
5+/**
6+ * Pure-Kotlin fallback implementation of the Crux core.
7+ *
8+ * This mirrors the Rust [app.rs] logic so the app can run during development
9+ * without the compiled native library. When [libopencode.so] is available,
10+ * [NativeCoreFfi] is used instead and this class is never instantiated.
11+ *
12+ * Basic-auth detection: the Connect event probes `/global/health`. A 401
13+ * response sets [PureModel.authRequired] and surfaces credential fields in
14+ * the view. When credentials are present, subsequent requests carry the
15+ * `Authorization: Basic` header.
16+ */
17+internal class PureCoreFfi : CoreFfi {
18+ private val model = PureModel()
19+
20+ override fun update(data: ByteArray): ByteArray {
21+ val event = Event.bincodeDeserialize(data)
22+ val effects = model.handleEvent(event)
23+ return effects.bincodeSerialize()
24+ }
25+
26+ override fun resolve(id: UInt, data: ByteArray): ByteArray {
27+ val result = HttpResult.bincodeDeserialize(data)
28+ val effects = model.handleResult(id, result)
29+ return effects.bincodeSerialize()
30+ }
31+
32+ override fun view(): ByteArray {
33+ return model.currentView().bincodeSerialize()
34+ }
35+}
36+
37+internal data class PureModel(
38+ var serverUrl: String = "http://localhost:4096",
39+ var username: String = "",
40+ var password: String = "",
41+ var authRequired: Boolean = false,
42+ var authed: Boolean = false,
43+ var connected: Boolean = false,
44+ var loading: Boolean = false,
45+ var error: String? = null,
46+ var sessions: MutableList<PureSession> = mutableListOf(),
47+ var currentSessionId: String? = null,
48+ var currentSessionTitle: String = "",
49+ var messages: MutableList<PureMessage> = mutableListOf(),
50+ var draftMessage: String = "",
51+ var generating: Boolean = false,
52+ var crashLogs: MutableList<String> = mutableListOf(),
53+ var pendingRequests: MutableMap<UInt, PendingRequest> = mutableMapOf(),
54+ var nextId: UInt = 0u,
55+) {
56+ fun handleEvent(event: Event): Requests {
57+ val requests = mutableListOf<Request>()
58+ when (event) {
59+ is Event.Start -> {
60+ // Don't reset serverUrl — the shell restores the persisted value.
61+ }
62+ is Event.ServerUrlChanged -> {
63+ serverUrl = event.value
64+ }
65+ is Event.UsernameChanged -> {
66+ username = event.value
67+ }
68+ is Event.PasswordChanged -> {
69+ password = event.value
70+ }
71+ is Event.Connect -> {
72+ loading = true
73+ error = null
74+ val id = nextId++
75+ pendingRequests[id] = PendingRequest.HealthProbe
76+ requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/global/health"))))
77+ }
78+ is Event.CancelAuth -> {
79+ authRequired = false
80+ username = ""
81+ password = ""
82+ loading = false
83+ }
84+ is Event.LoadSessions -> {
85+ loading = true
86+ val id = nextId++
87+ pendingRequests[id] = PendingRequest.LoadSessions
88+ requests.add(Request(id, Effect.Http(makeGetRequest("${serverUrl}/session"))))
89+ }
90+ is Event.SelectSession -> {
91+ currentSessionId = event.value
92+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
93+ messages.clear()
94+ val id = nextId++
95+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
96+ requests.add(
97+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
98+ )
99+ }
100+ is Event.CreateSession -> {
101+ loading = true
102+ val id = nextId++
103+ pendingRequests[id] = PendingRequest.CreateSession
104+ requests.add(
105+ Request(id, Effect.Http(makePostRequest("${serverUrl}/session", ByteArray(0)))),
106+ )
107+ }
108+ is Event.LoadMessages -> {
109+ loading = true
110+ generating = false
111+ val id = nextId++
112+ pendingRequests[id] = PendingRequest.LoadMessages(event.value)
113+ requests.add(
114+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/${event.value}/message"))),
115+ )
116+ }
117+ is Event.SendMessage -> {
118+ val sessionId = currentSessionId ?: return Requests(requests)
119+ draftMessage = ""
120+ generating = true
121+ val body = """{"sessionID":"$sessionId","parts":[{"type":"text","text":"${event.value}"}]}"""
122+ .toByteArray()
123+ val id = nextId++
124+ pendingRequests[id] = PendingRequest.SendMessage(sessionId)
125+ requests.add(
126+ Request(
127+ id,
128+ Effect.Http(
129+ makePostRequest("${serverUrl}/session/$sessionId/prompt_async", body)
130+ .copy(headers = listOf(HttpHeader("Content-Type", "application/json"))),
131+ ),
132+ ),
133+ )
134+ }
135+ is Event.EventReceived -> {
136+ val sid = currentSessionId
137+ if (sid != null) {
138+ val id = nextId++
139+ pendingRequests[id] = PendingRequest.LoadMessages(sid)
140+ requests.add(
141+ Request(id, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
142+ )
143+ }
144+ }
145+ is Event.NavigateToChat -> {
146+ currentSessionId = event.value
147+ currentSessionTitle = sessions.firstOrNull { it.id == event.value }?.title ?: ""
148+ messages.clear()
149+ }
150+ is Event.NavigateToSessions -> {
151+ currentSessionId = null
152+ currentSessionTitle = ""
153+ messages.clear()
154+ generating = false
155+ }
156+ is Event.NavigateToConnect -> {
157+ currentSessionId = null
158+ currentSessionTitle = ""
159+ messages.clear()
160+ generating = false
161+ connected = false
162+ authRequired = false
163+ loading = false
164+ }
165+ is Event.DismissError -> {
166+ error = null
167+ }
168+ is Event.DraftChanged -> {
169+ draftMessage = event.value
170+ }
171+ is Event.CancelGeneration -> {
172+ generating = false
173+ }
174+ is Event.DeleteSession -> {
175+ // Optimistically remove the session.
176+ sessions.removeAll { it.id == event.value }
177+ if (currentSessionId == event.value) {
178+ currentSessionId = null
179+ currentSessionTitle = ""
180+ messages.clear()
181+ generating = false
182+ }
183+ val id = nextId++
184+ pendingRequests[id] = PendingRequest.DeleteSession(event.value)
185+ requests.add(
186+ Request(id, Effect.Http(makeDeleteRequest("${serverUrl}/session/${event.value}"))),
187+ )
188+ }
189+ is Event.RetryMessage -> {
190+ // No-op in the pure fallback; real retry needs the original body.
191+ }
192+ }
193+ return Requests(requests)
194+ }
195+
196+ fun handleResult(id: UInt, result: HttpResult): Requests {
197+ val requests = mutableListOf<Request>()
198+ val pending = pendingRequests.remove(id) ?: return Requests(requests)
199+
200+ when (pending) {
201+ is PendingRequest.HealthProbe -> {
202+ when (result) {
203+ is HttpResult.Ok -> {
204+ val status = result.value.status.toInt()
205+ if (status == 401) {
206+ loading = false
207+ val hasCreds = username.isNotEmpty() || password.isNotEmpty()
208+ if (hasCreds) {
209+ error = "Invalid credentials"
210+ } else {
211+ authRequired = true
212+ }
213+ } else if (status in 200..299) {
214+ val hasCreds = username.isNotEmpty() || password.isNotEmpty()
215+ if (hasCreds) authed = true
216+ loading = true
217+ val newId = nextId++
218+ pendingRequests[newId] = PendingRequest.LoadSessions
219+ requests.add(
220+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
221+ )
222+ } else {
223+ loading = false
224+ error = "Server returned status $status"
225+ }
226+ }
227+ is HttpResult.Err -> {
228+ loading = false
229+ error = "Connection failed: ${result.value}"
230+ }
231+ }
232+ }
233+ is PendingRequest.LoadSessions -> {
234+ loading = false
235+ when (result) {
236+ is HttpResult.Ok -> {
237+ val body = result.value.body.content.decodeToString()
238+ sessions = parseSessions(body).toMutableList()
239+ connected = true
240+ }
241+ is HttpResult.Err -> {
242+ error = "Failed to load sessions: ${result.value}"
243+ }
244+ }
245+ }
246+ is PendingRequest.CreateSession -> {
247+ loading = false
248+ when (result) {
249+ is HttpResult.Ok -> {
250+ val body = result.value.body.content.decodeToString()
251+ val sid = extractJsonString(body, "id") ?: ""
252+ if (sid.isNotEmpty()) {
253+ val title = extractJsonString(body, "title")?.takeIf { it.isNotEmpty() }
254+ ?: "New session"
255+ currentSessionId = sid
256+ currentSessionTitle = title
257+ sessions.add(0, PureSession(sid, title))
258+ messages.clear()
259+ val newId = nextId++
260+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
261+ requests.add(
262+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
263+ )
264+ }
265+ }
266+ is HttpResult.Err -> {
267+ error = "Failed to create session: ${result.value}"
268+ }
269+ }
270+ }
271+ is PendingRequest.LoadMessages -> {
272+ loading = false
273+ generating = false
274+ when (result) {
275+ is HttpResult.Ok -> {
276+ val body = result.value.body.content.decodeToString()
277+ messages = parseMessages(body).toMutableList()
278+ }
279+ is HttpResult.Err -> {
280+ error = "Failed to load messages: ${result.value}"
281+ }
282+ }
283+ }
284+ is PendingRequest.SendMessage -> {
285+ when (result) {
286+ is HttpResult.Ok -> {
287+ val sid = pending.sessionId
288+ val newId = nextId++
289+ pendingRequests[newId] = PendingRequest.LoadMessages(sid)
290+ requests.add(
291+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session/$sid/message"))),
292+ )
293+ }
294+ is HttpResult.Err -> {
295+ generating = false
296+ error = "Failed to send message: ${result.value}"
297+ }
298+ }
299+ }
300+ is PendingRequest.DeleteSession -> {
301+ when (result) {
302+ is HttpResult.Ok -> {
303+ // Optimistic removal already happened; nothing to do.
304+ }
305+ is HttpResult.Err -> {
306+ // Restore by reloading sessions.
307+ val newId = nextId++
308+ pendingRequests[newId] = PendingRequest.LoadSessions
309+ requests.add(
310+ Request(newId, Effect.Http(makeGetRequest("${serverUrl}/session"))),
311+ )
312+ error = "Failed to delete session: ${result.value}"
313+ }
314+ }
315+ }
316+ }
317+ return Requests(requests)
318+ }
319+
320+ fun currentView(): ViewModel {
321+ val screen = when {
322+ currentSessionId != null -> Screen.CHAT
323+ connected -> Screen.SESSIONS
324+ else -> Screen.CONNECT
325+ }
326+ return ViewModel(
327+ screen = screen,
328+ serverUrl = serverUrl,
329+ username = username,
330+ password = password,
331+ authRequired = authRequired,
332+ connected = connected,
333+ loading = loading,
334+ error = error,
335+ sessions = sessions.map { SessionView(it.id, it.title) },
336+ currentSessionId = currentSessionId,
337+ currentSessionTitle = currentSessionTitle,
338+ messages = messages.map { MessageView(it.id, it.role, it.text, it.time, it.status) },
339+ draftMessage = draftMessage,
340+ generating = generating,
341+ crashLogCount = crashLogs.size.toUInt(),
342+ latestCrashLog = crashLogs.lastOrNull(),
343+ )
344+ }
345+
346+ private fun makeGetRequest(url: String): HttpRequest {
347+ val headers = authHeaders()
348+ return HttpRequest(method = "GET", url = url, headers = headers, body = Bytes(ByteArray(0)))
349+ }
350+
351+ private fun makePostRequest(url: String, body: ByteArray): HttpRequest {
352+ val headers = authHeaders()
353+ return HttpRequest(method = "POST", url = url, headers = headers, body = Bytes(body))
354+ }
355+
356+ private fun makeDeleteRequest(url: String): HttpRequest {
357+ val headers = authHeaders()
358+ return HttpRequest(method = "DELETE", url = url, headers = headers, body = Bytes(ByteArray(0)))
359+ }
360+
361+ private fun authHeaders(): List<HttpHeader> =
362+ if (username.isNotEmpty() || password.isNotEmpty()) {
363+ val creds = "$username:$password"
364+ val encoded = java.util.Base64.getEncoder().encodeToString(creds.toByteArray())
365+ listOf(HttpHeader("Authorization", "Basic $encoded"))
366+ } else {
367+ emptyList()
368+ }
369+}
370+
371+internal sealed class PendingRequest {
372+ data object HealthProbe : PendingRequest()
373+ data object LoadSessions : PendingRequest()
374+ data object CreateSession : PendingRequest()
375+ data class LoadMessages(val sessionId: String) : PendingRequest()
376+ data class SendMessage(val sessionId: String) : PendingRequest()
377+ data class DeleteSession(val sessionId: String) : PendingRequest()
378+}
379+
380+internal data class PureSession(val id: String, val title: String)
381+
382+internal data class PureMessage(
383+ val id: String,
384+ val role: String,
385+ val text: String,
386+ val time: ULong,
387+ val status: MessageStatus = MessageStatus.SENT,
388+)
389+
390+internal fun parseSessions(json: String): List<PureSession> {
391+ val sessions = mutableListOf<PureSession>()
392+ var depth = 0
393+ var start = 0
394+ for (i in json.indices) {
395+ when (json[i]) {
396+ '{' -> {
397+ if (depth == 0) start = i
398+ depth++
399+ }
400+ '}' -> {
401+ depth--
402+ if (depth == 0) {
403+ val obj = json.substring(start, i + 1)
404+ val id = extractJsonString(obj, "id") ?: ""
405+ val title = extractJsonString(obj, "title") ?: ""
406+ if (id.isNotEmpty()) {
407+ sessions.add(PureSession(id, if (title.isNotEmpty()) title else "Untitled"))
408+ }
409+ }
410+ }
411+ }
412+ }
413+ return sessions
414+}
415+
416+internal fun parseMessages(json: String): List<PureMessage> {
417+ val messages = mutableListOf<PureMessage>()
418+ var depth = 0
419+ var start = 0
420+ for (i in json.indices) {
421+ when (json[i]) {
422+ '{' -> {
423+ if (depth == 0) start = i
424+ depth++
425+ }
426+ '}' -> {
427+ depth--
428+ if (depth == 0) {
429+ val obj = json.substring(start, i + 1)
430+ val id = extractJsonString(obj, "id") ?: ""
431+ val role = extractJsonString(obj, "role") ?: ""
432+ val time = extractJsonNumber(obj, "created") ?: 0uL
433+ if (id.isNotEmpty() && role.isNotEmpty()) {
434+ val text = extractMessageText(obj)
435+ messages.add(PureMessage(id, role, text, time, MessageStatus.SENT))
436+ }
437+ }
438+ }
439+ }
440+ }
441+ return messages
442+}
443+
444+/// Extract the text from a message part. opencode messages nest the user/assistant
445+/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
446+/// first `"parts"` key, falling back to a top-level `"text"` field.
447+internal fun extractMessageText(obj: String): String {
448+ val partsIdx = obj.indexOf("\"parts\"")
449+ if (partsIdx >= 0) {
450+ val rest = obj.substring(partsIdx)
451+ val text = extractJsonString(rest, "text")
452+ if (text != null) return text
453+ }
454+ return extractJsonString(obj, "text") ?: ""
455+}
456+
457+internal fun extractJsonString(json: String, key: String): String? {
458+ val pattern = "\"$key\""
459+ val idx = json.indexOf(pattern)
460+ if (idx < 0) return null
461+ val rest = json.substring(idx + pattern.length)
462+ val colon = rest.indexOf(':')
463+ if (colon < 0) return null
464+ val rest2 = rest.substring(colon + 1)
465+ val quote = rest2.indexOf('"')
466+ if (quote < 0) return null
467+ val rest3 = rest2.substring(quote + 1)
468+ val end = rest3.indexOf('"')
469+ if (end < 0) return null
470+ return rest3.substring(0, end)
471+}
472+
473+internal fun extractJsonNumber(json: String, key: String): ULong? {
474+ val pattern = "\"$key\""
475+ val idx = json.indexOf(pattern)
476+ if (idx < 0) return null
477+ val rest = json.substring(idx + pattern.length)
478+ val colon = rest.indexOf(':')
479+ if (colon < 0) return null
480+ val rest2 = rest.substring(colon + 1).trimStart()
481+ val end = rest2.indexOfFirst { !it.isDigit() }.let { if (it < 0) rest2.length else it }
482+ return rest2.substring(0, end).toULongOrNull()
483+}
app/src/main/java/soy/iko/opencode/core/SseClient.kt+93−48
@@ -1,63 +1,108 @@
11package soy.iko.opencode.core
22
3−import okhttp3.OkHttpClient
4−import okhttp3.Request
5−import okhttp3.Response
6−import okhttp3.sse.EventSource
7−import okhttp3.sse.EventSourceListener
8−import okhttp3.sse.EventSources
9−import java.util.concurrent.TimeUnit
3+import io.ktor.client.HttpClient
4+import io.ktor.client.engine.okhttp.OkHttp
5+import io.ktor.client.plugins.HttpTimeout
6+import io.ktor.client.request.prepareGet
7+import io.ktor.client.statement.bodyAsChannel
8+import io.ktor.utils.io.readUTF8Line
9+import kotlinx.coroutines.CoroutineScope
10+import kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.Job
12+import kotlinx.coroutines.SupervisorJob
13+import kotlinx.coroutines.flow.MutableSharedFlow
14+import kotlinx.coroutines.flow.MutableStateFlow
15+import kotlinx.coroutines.flow.SharedFlow
16+import kotlinx.coroutines.flow.StateFlow
17+import kotlinx.coroutines.flow.asSharedFlow
18+import kotlinx.coroutines.flow.asStateFlow
19+import kotlinx.coroutines.launch
20+import soy.iko.opencode.CrashLogger
1021
1122/**
12− * Long-lived subscription to the opencode `/event` SSE stream.
23+ * Subscribes to the opencode server's SSE event stream (`GET /event`).
1324 *
14− * Crux only models request/response HTTP, so the streaming connection lives here
15− * in the shell: each decoded event's `data` payload (a JSON object whose own
16− * `type` field is the discriminant) is handed to [onEvent], which the core then
17− * parses via [Protocol.parseEvent].
25+ * Events are emitted as raw strings on [events]. The Core converts them into
26+ * [Event.EventReceived] which triggers a message reload.
27+ *
28+ * The live connection status is exposed via [state] so the UI can surface a
29+ * "live stream disconnected" banner to the user instead of silently dropping
30+ * updates. Transport errors are still reported to [CrashLogger] for
31+ * diagnostics, but they are no longer hidden from the user.
1832 */
19−class SseClient(
20− private val url: String,
21− private val auth: String?,
22− private val onEvent: (String) -> Unit,
23− private val onStateChange: (Boolean) -> Unit,
24−) {
25− // A dedicated client with no read timeout — the stream is meant to stay open.
26− private val client = OkHttpClient.Builder()
27− .connectTimeout(15, TimeUnit.SECONDS)
28− .readTimeout(0, TimeUnit.SECONDS)
29− .retryOnConnectionFailure(true)
30− .build()
31−
32− private var source: EventSource? = null
33−
34− fun connect() {
35− val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36− 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)
33+class SseClient {
34+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
35+ private val httpClient = HttpClient(OkHttp) {
36+ install(HttpTimeout) {
37+ requestTimeoutMillis = Long.MAX_VALUE
38+ connectTimeoutMillis = 15_000
39+ socketTimeoutMillis = Long.MAX_VALUE
40+ }
3941 }
4042
41− fun close() {
42− source?.cancel()
43− source = null
44− }
43+ private val _events = MutableSharedFlow<String>(extraBufferCapacity = 64)
44+ val events: SharedFlow<String> = _events.asSharedFlow()
4545
46− private val listener = object : EventSourceListener() {
47− override fun onOpen(eventSource: EventSource, response: Response) {
48− onStateChange(true)
49− }
46+ private val _state = MutableStateFlow<SseState>(SseState.Disconnected)
47+ val state: StateFlow<SseState> = _state.asStateFlow()
5048
51− override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52− if (data.isNotBlank()) onEvent(data)
53− }
49+ private var currentJob: Job? = null
50+ @Volatile private var currentUrl: String? = null
5451
55− override fun onClosed(eventSource: EventSource) {
56− onStateChange(false)
52+ fun connect(url: String) {
53+ currentUrl = url
54+ currentJob?.cancel()
55+ _state.value = SseState.Connecting
56+ currentJob = scope.launch {
57+ try {
58+ httpClient.prepareGet(url).execute { response ->
59+ _state.value = SseState.Connected
60+ val channel = response.bodyAsChannel()
61+ val dataBuilder = StringBuilder()
62+ while (!channel.isClosedForRead) {
63+ val line = channel.readUTF8Line() ?: break
64+ if (line.startsWith("data: ")) {
65+ dataBuilder.append(line.substring(6))
66+ } else if (line.isEmpty() && dataBuilder.isNotEmpty()) {
67+ _events.emit(dataBuilder.toString())
68+ dataBuilder.clear()
69+ }
70+ }
71+ // Stream ended normally — flag as disconnected so the UI
72+ // can offer a reconnect.
73+ _state.value = SseState.Disconnected
74+ }
75+ } catch (e: kotlinx.coroutines.CancellationException) {
76+ _state.value = SseState.Disconnected
77+ throw e
78+ } catch (e: Throwable) {
79+ CrashLogger.report(e, "SseClient")
80+ _state.value = SseState.Error(e.message ?: "Connection lost")
81+ }
5782 }
83+ }
5884
59− override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60− onStateChange(false)
61− }
85+ /** Re-establish the previous connection, if any. */
86+ fun reconnect() {
87+ currentUrl?.let { connect(it) }
88+ }
89+
90+ fun disconnect() {
91+ currentJob?.cancel()
92+ currentJob = null
93+ currentUrl = null
94+ _state.value = SseState.Disconnected
6295 }
6396}
97+
98+/** Live status of the SSE event stream. */
99+sealed interface SseState {
100+ /** Not connected (initial state, or after a clean close). */
101+ data object Disconnected : SseState
102+ /** Connection attempt in progress. */
103+ data object Connecting : SseState
104+ /** Stream is open and events are being received. */
105+ data object Connected : SseState
106+ /** The stream dropped unexpectedly. [message] describes the failure. */
107+ data class Error(val message: String) : SseState
108+}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktdeleted−77
@@ -1,77 +0,0 @@
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.ktdeleted−124
@@ -1,124 +0,0 @@
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/ChatInputBar.ktnew file+129
@@ -0,0 +1,129 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.Spacer
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.imePadding
8+import androidx.compose.foundation.layout.navigationBarsPadding
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.text.KeyboardActions
12+import androidx.compose.foundation.text.KeyboardOptions
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.automirrored.filled.Send
15+import androidx.compose.material.icons.filled.Stop
16+import androidx.compose.material3.Icon
17+import androidx.compose.material3.IconButton
18+import androidx.compose.material3.MaterialTheme
19+import androidx.compose.material3.OutlinedTextField
20+import androidx.compose.material3.Surface
21+import androidx.compose.material3.Text
22+import androidx.compose.runtime.Composable
23+import androidx.compose.ui.Alignment
24+import androidx.compose.ui.Modifier
25+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
26+import androidx.compose.ui.platform.LocalHapticFeedback
27+import androidx.compose.ui.semantics.contentDescription
28+import androidx.compose.ui.semantics.semantics
29+import androidx.compose.ui.text.input.ImeAction
30+import androidx.compose.ui.tooling.preview.Preview
31+import androidx.compose.ui.unit.dp
32+import soy.iko.opencode.ui.theme.Dimens
33+import soy.iko.opencode.ui.theme.OpencodeTheme
34+
35+/**
36+ * The chat message composer. A multi-line text field plus a send icon button.
37+ *
38+ * - Honors IME insets via [Modifier.imePadding] so the keyboard never covers
39+ * the input bar (the chat Scaffold relies on this).
40+ * - Uses [ImeAction.Default] so the keyboard's action key inserts a newline,
41+ * allowing multi-line messages. The send button on the right fires the
42+ * message.
43+ * - Send is disabled while [enabled] is false (e.g. during loading) or when
44+ * the text is blank.
45+ * - When [generating] is true, a Stop button replaces the Send button so the
46+ * user can cancel the wait; tapping it calls [onStop].
47+ * - A long-press haptic fires on send for tactile confirmation.
48+ */
49+@Composable
50+fun ChatInputBar(
51+ text: String,
52+ onTextChange: (String) -> Unit,
53+ onSend: () -> Unit,
54+ enabled: Boolean,
55+ modifier: Modifier = Modifier,
56+ generating: Boolean = false,
57+ onStop: () -> Unit = {},
58+ stopContentDescription: String = "Stop generating",
59+ placeholder: String = "Message…",
60+ sendContentDescription: String = "Send",
61+) {
62+ val hapticFeedback = LocalHapticFeedback.current
63+ Surface(
64+ tonalElevation = Dimens.inputBarTonalElevation,
65+ modifier = modifier
66+ .fillMaxWidth()
67+ .imePadding()
68+ .navigationBarsPadding(),
69+ ) {
70+ Row(
71+ verticalAlignment = Alignment.CenterVertically,
72+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
73+ modifier = Modifier
74+ .fillMaxWidth()
75+ .padding(
76+ horizontal = Dimens.inputBarHorizontalPadding,
77+ vertical = Dimens.inputBarVerticalPadding,
78+ ),
79+ ) {
80+ OutlinedTextField(
81+ value = text,
82+ onValueChange = onTextChange,
83+ placeholder = { Text(placeholder) },
84+ modifier = Modifier.weight(1f),
85+ maxLines = 4,
86+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default),
87+ )
88+ if (generating) {
89+ IconButton(
90+ onClick = {
91+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
92+ onStop()
93+ },
94+ modifier = Modifier.semantics {
95+ contentDescription = stopContentDescription
96+ },
97+ ) {
98+ Icon(Icons.Default.Stop, contentDescription = null)
99+ }
100+ } else {
101+ IconButton(
102+ onClick = {
103+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
104+ onSend()
105+ },
106+ enabled = enabled && text.isNotBlank(),
107+ modifier = Modifier.semantics {
108+ contentDescription = sendContentDescription
109+ },
110+ ) {
111+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null)
112+ }
113+ }
114+ }
115+ }
116+}
117+
118+@Preview(showBackground = true)
119+@Composable
120+private fun ChatInputBarPreview() {
121+ OpencodeTheme {
122+ ChatInputBar(
123+ text = "Hello world",
124+ onTextChange = {},
125+ onSend = {},
126+ enabled = true,
127+ )
128+ }
129+}
app/src/main/java/soy/iko/opencode/ui/components/CrashLogDialog.ktnew file+216
@@ -0,0 +1,216 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.Intent
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.Spacer
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.heightIn
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.rememberScrollState
13+import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material3.AlertDialog
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Text
17+import androidx.compose.material3.TextButton
18+import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.LaunchedEffect
20+import androidx.compose.runtime.getValue
21+import androidx.compose.runtime.mutableStateOf
22+import androidx.compose.runtime.remember
23+import androidx.compose.runtime.setValue
24+import androidx.compose.ui.Modifier
25+import androidx.compose.ui.platform.LocalContext
26+import androidx.compose.ui.res.stringResource
27+import androidx.compose.ui.tooling.preview.Preview
28+import androidx.compose.ui.unit.dp
29+import kotlinx.coroutines.Dispatchers
30+import kotlinx.coroutines.withContext
31+import soy.iko.opencode.R
32+import soy.iko.opencode.ui.theme.Dimens
33+import soy.iko.opencode.ui.theme.OpencodeTheme
34+import java.io.File
35+
36+/**
37+ * Dialog listing persisted crash reports. Each report shows the file name
38+ * (header) and the full stack trace (body). Height wraps content but is
39+ * capped at 60% of the viewport via [Modifier.heightIn] so very long traces
40+ * scroll internally without consuming the whole screen.
41+ *
42+ * Crash report file contents are read asynchronously via [LaunchedEffect] on
43+ * [Dispatchers.IO] so disk I/O never blocks the composition thread.
44+ *
45+ * "Clear all" is a destructive action guarded by a confirmation step. The
46+ * single dialog swaps its content and buttons between the list view and the
47+ * confirm view, so there's never a second dialog stacked on top.
48+ *
49+ * - List view: dismiss = "Clear all" (error-tinted), confirm = "Close"
50+ * - Confirm view: dismiss = "Cancel", confirm = "Clear" (error-tinted)
51+ *
52+ * Each report has a "Copy" and "Share" button for filing bug reports.
53+ */
54+@Composable
55+fun CrashLogDialog(
56+ reports: List<File>,
57+ onDismiss: () -> Unit,
58+ onClear: () -> Unit,
59+ modifier: Modifier = Modifier,
60+ title: String = "Crash Reports",
61+ clearLabel: String = "Clear all",
62+ closeLabel: String = "Close",
63+ cancelLabel: String = "Cancel",
64+ clearConfirmLabel: String = "Clear all crash reports?",
65+ clearConfirmYesLabel: String = "Clear",
66+ copyLabel: String = "Copy",
67+ shareLabel: String = "Share",
68+ copiedLabel: String = "Crash report copied",
69+ emptyLabel: String = "No crash reports",
70+) {
71+ // Read crash report contents asynchronously on Dispatchers.IO so disk
72+ // I/O never blocks the composition thread.
73+ var contents by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
74+ LaunchedEffect(reports) {
75+ contents = withContext(Dispatchers.IO) {
76+ reports.map { it.name to it.readText() }
77+ }
78+ }
79+ var confirmingClear by remember { mutableStateOf(false) }
80+ val context = LocalContext.current
81+
82+ AlertDialog(
83+ onDismissRequest = {
84+ if (confirmingClear) confirmingClear = false else onDismiss()
85+ },
86+ modifier = modifier,
87+ title = { Text(title) },
88+ text = {
89+ if (confirmingClear) {
90+ Text(
91+ text = clearConfirmLabel,
92+ style = MaterialTheme.typography.bodyMedium,
93+ )
94+ } else if (contents.isEmpty()) {
95+ Text(
96+ text = emptyLabel,
97+ style = MaterialTheme.typography.bodyMedium,
98+ color = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
100+ } else {
101+ Column(
102+ modifier = Modifier
103+ .fillMaxWidth()
104+ .heightIn(max = 360.dp)
105+ .verticalScroll(rememberScrollState()),
106+ ) {
107+ contents.forEachIndexed { index, (name, text) ->
108+ Row(
109+ modifier = Modifier.fillMaxWidth(),
110+ horizontalArrangement = androidx.compose.foundation.layout.Arrangement.SpaceBetween,
111+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
112+ ) {
113+ Text(
114+ text = name,
115+ style = MaterialTheme.typography.labelMedium,
116+ color = MaterialTheme.colorScheme.primary,
117+ modifier = Modifier.weight(1f),
118+ )
119+ Spacer(Modifier.size(Dimens.spaceSmall))
120+ TextButton(
121+ onClick = {
122+ val clipboard = context.getSystemService(android.content.ClipboardManager::class.java)
123+ clipboard?.setPrimaryClip(
124+ android.content.ClipData.newPlainText("crash_report", text),
125+ )
126+ },
127+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
128+ horizontal = Dimens.spaceSmall,
129+ vertical = 0.dp,
130+ ),
131+ ) { Text(copyLabel, style = MaterialTheme.typography.labelSmall) }
132+ TextButton(
133+ onClick = {
134+ val intent = Intent(Intent.ACTION_SEND).apply {
135+ type = "text/plain"
136+ putExtra(Intent.EXTRA_SUBJECT, name)
137+ putExtra(Intent.EXTRA_TEXT, text)
138+ }
139+ context.startActivity(
140+ Intent.createChooser(intent, shareLabel).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
141+ )
142+ },
143+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
144+ horizontal = Dimens.spaceSmall,
145+ vertical = 0.dp,
146+ ),
147+ ) { Text(shareLabel, style = MaterialTheme.typography.labelSmall) }
148+ }
149+ Spacer(Modifier.height(Dimens.spaceTiny))
150+ Text(
151+ text = text,
152+ style = MaterialTheme.typography.bodySmall,
153+ )
154+ if (index < contents.lastIndex) {
155+ Spacer(Modifier.height(Dimens.spaceMedium))
156+ }
157+ }
158+ }
159+ }
160+ },
161+ confirmButton = {
162+ if (confirmingClear) {
163+ // Affirmative: clear (error-tinted).
164+ TextButton(onClick = {
165+ onClear()
166+ confirmingClear = false
167+ }) {
168+ Text(
169+ text = clearConfirmYesLabel,
170+ color = MaterialTheme.colorScheme.error,
171+ )
172+ }
173+ } else {
174+ // Non-destructive: close.
175+ TextButton(onClick = onDismiss) { Text(closeLabel) }
176+ }
177+ },
178+ dismissButton = {
179+ if (confirmingClear) {
180+ // Abort the clear.
181+ TextButton(onClick = { confirmingClear = false }) { Text(cancelLabel) }
182+ } else {
183+ // Destructive action in the dismiss slot (left), tinted error.
184+ TextButton(onClick = { confirmingClear = true }) {
185+ Text(
186+ text = clearLabel,
187+ color = MaterialTheme.colorScheme.error,
188+ )
189+ }
190+ }
191+ },
192+ )
193+}
194+
195+@Preview(showBackground = true)
196+@Composable
197+private fun CrashLogDialogPreview() {
198+ val tmp = File(System.getProperty("java.io.tmpdir"), "crash_preview.txt").apply {
199+ writeText("java.lang.RuntimeException: preview crash\n\tat foo.bar.baz(Preview.kt:1)")
200+ }
201+ OpencodeTheme {
202+ CrashLogDialog(
203+ reports = listOf(tmp),
204+ onDismiss = {},
205+ onClear = {},
206+ )
207+ }
208+}
209+
210+@Preview(showBackground = true)
211+@Composable
212+private fun CrashLogDialogEmptyPreview() {
213+ OpencodeTheme {
214+ CrashLogDialog(reports = emptyList(), onDismiss = {}, onClear = {})
215+ }
216+}
app/src/main/java/soy/iko/opencode/ui/components/DateSeparator.ktnew file+90
@@ -0,0 +1,90 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.text.format.DateFormat
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.material3.MaterialTheme
8+import androidx.compose.material3.Text
9+import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.compose.ui.platform.LocalContext
14+import androidx.compose.ui.res.stringResource
15+import androidx.compose.ui.semantics.contentDescription
16+import androidx.compose.ui.semantics.semantics
17+import androidx.compose.ui.text.style.TextAlign
18+import androidx.compose.ui.tooling.preview.Preview
19+import androidx.compose.ui.unit.dp
20+import soy.iko.opencode.R
21+import soy.iko.opencode.ui.theme.OpencodeTheme
22+import java.util.Calendar
23+import java.util.Date
24+import java.util.Locale
25+
26+/**
27+ * A centered date label inserted between messages that fall on different
28+ * calendar days. Uses the user's locale date format (via [DateFormat.getLongDateFormat])
29+ * so it respects system preferences.
30+ */
31+@Composable
32+fun DateSeparator(epochSeconds: ULong, modifier: Modifier = Modifier) {
33+ val context = LocalContext.current
34+ val todayLabel = stringResource(R.string.date_today)
35+ val yesterdayLabel = stringResource(R.string.date_yesterday)
36+ val label = remember(epochSeconds) { formatDateLabel(epochSeconds, context, todayLabel, yesterdayLabel) }
37+ Box(
38+ modifier = modifier
39+ .fillMaxWidth()
40+ .padding(vertical = 8.dp)
41+ .semantics { contentDescription = label },
42+ contentAlignment = Alignment.Center,
43+ ) {
44+ Text(
45+ text = label,
46+ style = MaterialTheme.typography.labelSmall,
47+ color = MaterialTheme.colorScheme.onSurfaceVariant,
48+ textAlign = TextAlign.Center,
49+ )
50+ }
51+}
52+
53+private fun formatDateLabel(
54+ epochSeconds: ULong,
55+ context: android.content.Context,
56+ todayLabel: String,
57+ yesterdayLabel: String,
58+): String {
59+ val date = Date(epochSeconds.toLong() * 1000)
60+ val today = dayOfEpoch((Calendar.getInstance(Locale.getDefault()).timeInMillis / 1000).toULong())
61+ val yesterday = today - 1
62+ val target = dayOfEpoch(epochSeconds)
63+ return when (target) {
64+ today -> todayLabel
65+ yesterday -> yesterdayLabel
66+ else -> {
67+ val formatter = DateFormat.getLongDateFormat(context)
68+ formatter.format(date)
69+ }
70+ }
71+}
72+
73+/**
74+ * Returns the calendar day (as epoch days) for the given epoch seconds,
75+ * in the device's default timezone. Returns 0 for time == 0 (unknown).
76+ */
77+internal fun dayOfEpoch(epochSeconds: ULong): Int {
78+ if (epochSeconds == 0uL) return Int.MIN_VALUE
79+ val cal = Calendar.getInstance(Locale.getDefault())
80+ cal.timeInMillis = epochSeconds.toLong() * 1000
81+ return cal.get(Calendar.YEAR) * 366 + cal.get(Calendar.DAY_OF_YEAR)
82+}
83+
84+@Preview(showBackground = true)
85+@Composable
86+private fun DateSeparatorPreview() {
87+ OpencodeTheme {
88+ DateSeparator(epochSeconds = 1_700_000_000uL)
89+ }
90+}
app/src/main/java/soy/iko/opencode/ui/components/ErrorHost.ktnew file+134
@@ -0,0 +1,134 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.material3.MaterialTheme
5+import androidx.compose.material3.Snackbar
6+import androidx.compose.material3.SnackbarDuration
7+import androidx.compose.material3.SnackbarHost
8+import androidx.compose.material3.SnackbarHostState
9+import androidx.compose.material3.SnackbarResult
10+import androidx.compose.runtime.Composable
11+import androidx.compose.runtime.LaunchedEffect
12+import androidx.compose.runtime.collectAsState
13+import androidx.compose.runtime.getValue
14+import androidx.compose.runtime.remember
15+import kotlinx.coroutines.flow.SharedFlow
16+import soy.iko.opencode.core.Core
17+import soy.iko.opencode.core.Event
18+
19+/**
20+ * A [SnackbarHostState] that observes [ViewModel.error] and surfaces it to
21+ * the user. Offers a "Dismiss" action that sends [Event.DismissError] to
22+ * clear the error in the core so it doesn't re-show on recomposition.
23+ *
24+ * When the core has a retryable last event, a "Retry" action is also offered
25+ * (it takes precedence over "Dismiss" since it's more useful). Tapping Retry
26+ * re-sends the last user-initiated event.
27+ *
28+ * Used by every screen so error messages are never silently dropped.
29+ */
30+@Composable
31+fun ErrorHost(
32+ core: Core,
33+ dismissLabel: String = "Dismiss",
34+ retryLabel: String = "Retry",
35+): SnackbarHostState {
36+ val view by core.view.collectAsState()
37+ val snackbarHostState = remember { SnackbarHostState() }
38+
39+ LaunchedEffect(view.error) {
40+ val message = view.error
41+ if (!message.isNullOrEmpty()) {
42+ val canRetry = core.retryableEvent != null
43+ val result = snackbarHostState.showSnackbar(
44+ message = message,
45+ actionLabel = if (canRetry) retryLabel else dismissLabel,
46+ duration = SnackbarDuration.Long,
47+ )
48+ when (result) {
49+ SnackbarResult.ActionPerformed -> {
50+ if (canRetry) core.retry() else core.update(Event.DismissError)
51+ }
52+ SnackbarResult.Dismissed -> core.update(Event.DismissError)
53+ }
54+ }
55+ }
56+
57+ return snackbarHostState
58+}
59+
60+/**
61+ * Collects transient success/info messages from [Core.info] and surfaces them
62+ * as a short snackbar. This gives the user positive feedback (e.g. "Session
63+ * created") instead of only ever showing errors.
64+ *
65+ * Uses a dedicated [SnackbarHostState] (separate from the error host) so the
66+ * info snackbar can be rendered with default colors rather than the error
67+ * palette. The screens render both hosts in the Scaffold's `snackbarHost`
68+ * slot; typically only one fires at a time.
69+ */
70+@Composable
71+fun InfoHost(
72+ core: Core,
73+ successConnectedLabel: String,
74+ successSessionCreatedLabel: String,
75+ copiedLabel: String = "Copied",
76+ sessionDeletedLabel: String = "Session deleted",
77+): SnackbarHostState {
78+ val snackbarHostState = remember { SnackbarHostState() }
79+ LaunchedEffect(snackbarHostState) {
80+ core.info.collect { key ->
81+ val message = when (key) {
82+ Core.SUCCESS_CONNECTED -> successConnectedLabel
83+ Core.SUCCESS_SESSION_CREATED -> successSessionCreatedLabel
84+ Core.SUCCESS_COPIED -> copiedLabel
85+ Core.SUCCESS_SESSION_DELETED -> sessionDeletedLabel
86+ else -> key
87+ }
88+ snackbarHostState.showSnackbar(
89+ message = message,
90+ duration = SnackbarDuration.Short,
91+ )
92+ }
93+ }
94+ return snackbarHostState
95+}
96+
97+@Composable
98+fun ErrorSnackbarHost(snackbarHostState: SnackbarHostState) {
99+ SnackbarHost(snackbarHostState) { data ->
100+ Snackbar(
101+ containerColor = MaterialTheme.colorScheme.errorContainer,
102+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
103+ actionContentColor = MaterialTheme.colorScheme.onErrorContainer,
104+ snackbarData = data,
105+ )
106+ }
107+}
108+
109+/**
110+ * Snackbar host for success/info messages. Uses the default (surface-tonal)
111+ * colors so positive feedback doesn't read as an error.
112+ */
113+@Composable
114+fun InfoSnackbarHost(snackbarHostState: SnackbarHostState) {
115+ SnackbarHost(snackbarHostState) { data ->
116+ Snackbar(snackbarData = data)
117+ }
118+}
119+
120+/**
121+ * Renders both the error and info snackbar hosts stacked vertically. The
122+ * Material 3 [Scaffold] only accepts a single `snackbarHost` slot, so screens
123+ * pass this helper to show both flavors without one overwriting the other.
124+ */
125+@Composable
126+fun DualSnackbarHost(
127+ errorHostState: SnackbarHostState,
128+ infoHostState: SnackbarHostState,
129+) {
130+ Column {
131+ ErrorSnackbarHost(errorHostState)
132+ InfoSnackbarHost(infoHostState)
133+ }
134+}
app/src/main/java/soy/iko/opencode/ui/components/LoadingPlaceholder.ktnew file+83
@@ -0,0 +1,83 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.fillMaxSize
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.material3.CircularProgressIndicator
9+import androidx.compose.material3.MaterialTheme
10+import androidx.compose.material3.Text
11+import androidx.compose.runtime.Composable
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.res.stringResource
15+import androidx.compose.ui.semantics.LiveRegionMode
16+import androidx.compose.ui.semantics.contentDescription
17+import androidx.compose.ui.semantics.liveRegion
18+import androidx.compose.ui.semantics.semantics
19+import androidx.compose.ui.text.style.TextAlign
20+import androidx.compose.ui.tooling.preview.Preview
21+import soy.iko.opencode.R
22+import soy.iko.opencode.ui.theme.Dimens
23+import soy.iko.opencode.ui.theme.OpencodeTheme
24+
25+/**
26+ * Centered indeterminate loading indicator. Used when a screen is loading its
27+ * initial content and has nothing else to show. Marked as a polite live region
28+ * so TalkBack announces "loading" once.
29+ */
30+@Composable
31+fun LoadingPlaceholder(
32+ modifier: Modifier = Modifier,
33+ contentDescription: String = stringResource(R.string.loading),
34+) {
35+ Box(
36+ modifier = modifier
37+ .fillMaxSize()
38+ .semantics(mergeDescendants = true) {
39+ liveRegion = LiveRegionMode.Polite
40+ this.contentDescription = contentDescription
41+ },
42+ contentAlignment = Alignment.Center,
43+ ) {
44+ CircularProgressIndicator()
45+ }
46+}
47+
48+/**
49+ * Centered empty-state message. Used when a screen has no content yet (e.g.
50+ * no sessions, no messages).
51+ */
52+@Composable
53+fun EmptyState(
54+ message: String,
55+ modifier: Modifier = Modifier,
56+) {
57+ Column(
58+ horizontalAlignment = Alignment.CenterHorizontally,
59+ verticalArrangement = Arrangement.Center,
60+ modifier = modifier
61+ .fillMaxSize()
62+ .padding(Dimens.screenPadding),
63+ ) {
64+ Text(
65+ text = message,
66+ style = MaterialTheme.typography.bodyLarge,
67+ color = MaterialTheme.colorScheme.onSurfaceVariant,
68+ textAlign = TextAlign.Center,
69+ )
70+ }
71+}
72+
73+@Preview(showBackground = true)
74+@Composable
75+private fun LoadingPlaceholderPreview() {
76+ OpencodeTheme { LoadingPlaceholder() }
77+}
78+
79+@Preview(showBackground = true)
80+@Composable
81+private fun EmptyStatePreview() {
82+ OpencodeTheme { EmptyState(message = "No sessions yet") }
83+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+310−410
@@ -1,479 +1,379 @@
11package soy.iko.opencode.ui.components
22
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
3+import android.text.format.DateFormat
4+import androidx.compose.animation.core.RepeatMode
5+import androidx.compose.animation.core.animateFloat
6+import androidx.compose.animation.core.infiniteRepeatable
7+import androidx.compose.animation.core.rememberInfiniteTransition
8+import androidx.compose.animation.core.tween
99import androidx.compose.foundation.ExperimentalFoundationApi
10−import androidx.compose.foundation.background
11−import androidx.compose.foundation.clickable
1210import androidx.compose.foundation.combinedClickable
1311import androidx.compose.foundation.layout.Arrangement
1412import androidx.compose.foundation.layout.Column
1513import androidx.compose.foundation.layout.Row
14+import androidx.compose.foundation.layout.Spacer
1615import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.height
1717import androidx.compose.foundation.layout.padding
1818import androidx.compose.foundation.layout.size
1919import androidx.compose.foundation.layout.widthIn
20−import androidx.compose.foundation.shape.RoundedCornerShape
21−import androidx.compose.foundation.text.selection.SelectionContainer
2220import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Build
24−import androidx.compose.material.icons.filled.Check
2521import 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
22+import androidx.compose.material3.CircularProgressIndicator
3223import androidx.compose.material3.Icon
3324import androidx.compose.material3.MaterialTheme
25+import androidx.compose.material3.Surface
3426import androidx.compose.material3.Text
3527import androidx.compose.runtime.Composable
3628import androidx.compose.runtime.getValue
37−import androidx.compose.runtime.mutableStateOf
3829import androidx.compose.runtime.remember
39−import androidx.compose.runtime.setValue
4030import androidx.compose.ui.Alignment
4131import androidx.compose.ui.Modifier
42−import androidx.compose.ui.draw.clip
43−import androidx.compose.ui.graphics.Color
32+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
33+import androidx.compose.ui.platform.LocalClipboardManager
4434import androidx.compose.ui.platform.LocalContext
35+import androidx.compose.ui.platform.LocalDensity
36+import androidx.compose.ui.platform.LocalHapticFeedback
37+import androidx.compose.ui.platform.LocalWindowInfo
38+import androidx.compose.ui.res.stringResource
39+import androidx.compose.ui.semantics.LiveRegionMode
40+import androidx.compose.ui.semantics.contentDescription
41+import androidx.compose.ui.semantics.liveRegion
42+import androidx.compose.ui.semantics.semantics
4543import 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
5144import 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
45+import androidx.compose.ui.tooling.preview.Preview
5746import androidx.compose.ui.unit.dp
58−import androidx.compose.ui.unit.sp
47+import com.mikepenz.markdown.m3.Markdown
48+import soy.iko.opencode.R
5949import soy.iko.opencode.core.MessageStatus
6050import 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")
51+import soy.iko.opencode.ui.theme.AssistantBubbleShape
52+import soy.iko.opencode.ui.theme.Dimens
53+import soy.iko.opencode.ui.theme.OpencodeTheme
54+import soy.iko.opencode.ui.theme.UserBubbleShape
55+import java.util.Date
6556
57+/**
58+ * A single chat message rendered as a left/right-aligned bubble. Assistant
59+ * messages are rendered as markdown; user messages as plain text. The bubble
60+ * shape carries an iMessage-style "tail" on the speaker's side.
61+ *
62+ * Accessibility: the whole bubble is merged into one semantics node so
63+ * TalkBack reads the role label + content as a single utterance. Assistant
64+ * bubbles are marked as a [LiveRegionMode.Polite] live region so streaming
65+ * updates are announced without stealing focus.
66+ *
67+ * Long-press anywhere on the bubble copies the raw message text to the
68+ * clipboard and emits a long-press haptic, mirroring the standard chat UX.
69+ */
6670@OptIn(ExperimentalFoundationApi::class)
6771@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−
72+fun MessageBubble(
73+ message: MessageView,
74+ modifier: Modifier = Modifier,
75+ onCopied: () -> Unit = {},
76+) {
77+ val isUser = message.role == "user"
78+ val maxWidth = maxBubbleWidthDp()
79+ val roleLabel = if (isUser) {
80+ stringResource(R.string.chat_role_user)
81+ } else {
82+ stringResource(R.string.chat_role_assistant)
83+ }
8084 val context = LocalContext.current
81− var showMenu by remember { mutableStateOf(false) }
82−
85+ val timeLabel = remember(message.time) { formatTimestamp(message.time, context) }
86+ val clipboardManager = LocalClipboardManager.current
87+ val hapticFeedback = LocalHapticFeedback.current
88+ val onCopiedCallback = onCopied
89+ // Build a single TalkBack utterance that includes the role and the
90+ // message body. Setting an explicit contentDescription on a merge node
91+ // replaces descendant text, so we must include the body ourselves rather
92+ // than letting it merge — otherwise TalkBack reads only "User".
93+ val announcement = buildString {
94+ append(roleLabel)
95+ if (message.time > 0uL) {
96+ append(", ")
97+ append(timeLabel)
98+ }
99+ append(". ")
100+ append(if (message.text.isBlank()) stringResource(R.string.chat_no_content) else message.text)
101+ }
83102 Row(
84− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
103+ modifier = modifier
104+ .fillMaxWidth()
105+ .combinedClickable(
106+ onLongClick = {
107+ if (message.text.isNotBlank()) {
108+ clipboardManager.setText(AnnotatedString(message.text))
109+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
110+ onCopiedCallback()
111+ }
112+ },
113+ onClick = {},
114+ )
115+ .semantics(mergeDescendants = true) {
116+ contentDescription = announcement
117+ },
85118 horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86119 ) {
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),
120+ Surface(
121+ color = if (isUser) {
122+ MaterialTheme.colorScheme.primary
123+ } else {
124+ MaterialTheme.colorScheme.surfaceVariant
125+ },
126+ contentColor = if (isUser) {
127+ MaterialTheme.colorScheme.onPrimary
128+ } else {
129+ MaterialTheme.colorScheme.onSurfaceVariant
130+ },
131+ shape = if (isUser) UserBubbleShape else AssistantBubbleShape,
132+ modifier = Modifier.widthIn(max = maxWidth),
110133 ) {
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)
134+ Column(
135+ modifier = Modifier.padding(
136+ horizontal = Dimens.bubbleHorizontalPadding,
137+ vertical = Dimens.bubbleVerticalPadding,
138+ ),
139+ ) {
140+ if (!isUser) {
141+ Row(
142+ modifier = Modifier.fillMaxWidth(),
143+ horizontalArrangement = Arrangement.SpaceBetween,
144+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
145+ ) {
146+ Text(
147+ text = roleLabel,
148+ style = MaterialTheme.typography.labelSmall,
149+ color = MaterialTheme.colorScheme.primary,
150+ )
151+ if (message.time > 0uL) {
152+ Text(
153+ text = timeLabel,
154+ style = MaterialTheme.typography.labelSmall,
155+ color = MaterialTheme.colorScheme.onSurfaceVariant,
156+ )
157+ }
151158 }
159+ Spacer(Modifier.height(Dimens.spaceTiny))
160+ } else {
161+ // Show a timestamp and status indicator on user bubbles,
162+ // aligned to the end so it reads as the message's send time.
163+ val showStatus = message.status != MessageStatus.SENT
164+ if (message.time > 0uL || showStatus) {
165+ Row(
166+ modifier = Modifier.fillMaxWidth(),
167+ horizontalArrangement = Arrangement.End,
168+ verticalAlignment = Alignment.CenterVertically,
169+ ) {
170+ if (message.status == MessageStatus.PENDING) {
171+ CircularProgressIndicator(
172+ modifier = Modifier.size(Dimens.iconInlineSpinner),
173+ strokeWidth = Dimens.strokeThin,
174+ color = MaterialTheme.colorScheme.onPrimary,
175+ )
176+ Spacer(Modifier.size(Dimens.spaceTiny))
177+ } else if (message.status == MessageStatus.FAILED) {
178+ Icon(
179+ Icons.Default.ErrorOutline,
180+ contentDescription = stringResource(R.string.chat_cd_message_status_failed),
181+ modifier = Modifier.size(Dimens.iconInlineSpinner),
182+ tint = MaterialTheme.colorScheme.onPrimary,
183+ )
184+ Spacer(Modifier.size(Dimens.spaceTiny))
185+ }
186+ if (message.time > 0uL) {
187+ Text(
188+ text = timeLabel,
189+ style = MaterialTheme.typography.labelSmall,
190+ color = MaterialTheme.colorScheme.onPrimary,
191+ )
192+ }
193+ }
194+ Spacer(Modifier.height(Dimens.spaceTiny))
195+ }
196+ }
197+ if (message.text.isBlank()) {
198+ Text(
199+ text = stringResource(R.string.chat_no_content),
200+ style = MaterialTheme.typography.bodyMedium,
201+ fontStyle = FontStyle.Italic,
202+ )
203+ } else if (isUser) {
152204 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− },
205+ text = message.text,
206+ style = MaterialTheme.typography.bodyMedium,
207+ )
208+ } else {
209+ // Assistant replies often contain markdown (code blocks,
210+ // lists, etc.) — render them properly.
211+ Markdown(
212+ content = message.text,
213+ modifier = Modifier
214+ .fillMaxWidth()
215+ .semantics {
216+ // Polite live region so streaming edits are
217+ // announced without interrupting the user.
218+ liveRegion = LiveRegionMode.Polite
219+ },
162220 )
163− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165221 }
166222 }
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− }
180223 }
181224 }
182225}
183226
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)
227+@Composable
228+fun GeneratingIndicator(modifier: Modifier = Modifier) {
229+ val maxWidth = maxBubbleWidthDp()
230+ val generatingLabel = stringResource(R.string.chat_generating_label)
231+ val cdLabel = stringResource(R.string.chat_cd_generating)
232+ val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
233+ // Three dots that pulse in sequence: each dot scales/fades with a
234+ // staggered delay so the indicator reads as "typing" rather than a
235+ // single frozen spinner. One shared infinite transition drives them.
236+ val transition = rememberInfiniteTransition(label = "generating-dots")
237+ val phase1 by transition.animateFloat(
238+ initialValue = 0.3f,
239+ targetValue = 1f,
240+ animationSpec = infiniteRepeatable(
241+ animation = tween(durationMillis = 600, delayMillis = 0),
242+ repeatMode = RepeatMode.Reverse,
243+ ),
244+ label = "dot-1",
245+ )
246+ val phase2 by transition.animateFloat(
247+ initialValue = 0.3f,
248+ targetValue = 1f,
249+ animationSpec = infiniteRepeatable(
250+ animation = tween(durationMillis = 600, delayMillis = 200),
251+ repeatMode = RepeatMode.Reverse,
252+ ),
253+ label = "dot-2",
254+ )
255+ val phase3 by transition.animateFloat(
256+ initialValue = 0.3f,
257+ targetValue = 1f,
258+ animationSpec = infiniteRepeatable(
259+ animation = tween(durationMillis = 600, delayMillis = 400),
260+ repeatMode = RepeatMode.Reverse,
261+ ),
262+ label = "dot-3",
263+ )
264+ Row(
265+ horizontalArrangement = Arrangement.Start,
266+ modifier = modifier
267+ .fillMaxWidth()
268+ .padding(vertical = Dimens.spaceTiny),
269+ ) {
270+ Surface(
271+ color = MaterialTheme.colorScheme.surfaceVariant,
272+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
273+ shape = AssistantBubbleShape,
274+ modifier = Modifier.widthIn(max = maxWidth),
275+ ) {
276+ Row(
277+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
278+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
279+ modifier = Modifier
280+ .padding(
281+ horizontal = Dimens.bubbleHorizontalPadding,
282+ vertical = Dimens.bubbleVerticalPadding,
283+ )
284+ .semantics(mergeDescendants = true) {
285+ // Announced when the indicator appears/disappears.
286+ liveRegion = LiveRegionMode.Polite
287+ contentDescription = cdLabel
288+ },
289+ ) {
290+ Text(
291+ text = generatingLabel,
292+ style = MaterialTheme.typography.bodyMedium,
293+ color = MaterialTheme.colorScheme.onSurfaceVariant,
294+ )
295+ Row(
296+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
297+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceTiny),
298+ ) {
299+ GeneratingDot(alpha = phase1, color = dotColor)
300+ GeneratingDot(alpha = phase2, color = dotColor)
301+ GeneratingDot(alpha = phase3, color = dotColor)
302+ }
211303 }
212− } else {
213− appendLinkedText(segment, linkColor, codeBackground, context)
214304 }
215305 }
216− if (streaming) append(" ▌")
217306}
218307
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− }
308+@Composable
309+private fun GeneratingDot(alpha: Float, color: androidx.compose.ui.graphics.Color) {
310+ // Scale slightly with alpha so the pulse feels like motion, not just a
311+ // dimmer toggle. alpha drives both the dot's alpha and a small scale.
312+ val scale = 0.75f + alpha * 0.25f
313+ androidx.compose.foundation.Canvas(
314+ modifier = Modifier.size(Dimens.iconInlineSpinner),
315+ ) {
316+ val diameter = size.minDimension * scale
317+ drawCircle(
318+ color = color.copy(alpha = alpha.coerceIn(0.15f, 1f)),
319+ radius = diameter / 2f,
320+ center = androidx.compose.ui.geometry.Offset(size.width / 2f, size.height / 2f),
321+ )
251322 }
252323}
253324
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− }
325+private fun formatTimestamp(epochSeconds: ULong, context: android.content.Context): String {
326+ val date = Date(epochSeconds.toLong() * 1000)
327+ val formatter = DateFormat.getTimeFormat(context)
328+ return formatter.format(date)
282329}
283330
284331/**
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.
332+ * Max chat-bubble width as a fraction of the window width, in dp. Uses
333+ * [LocalWindowInfo] (the recommended window-size source) rather than the
334+ * deprecated [androidx.compose.ui.platform.LocalConfiguration.screenWidthDp].
288335 */
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)
336+@Composable
337+private fun maxBubbleWidthDp(): androidx.compose.ui.unit.Dp {
338+ val containerSize = LocalWindowInfo.current.containerSize
339+ val density = LocalDensity.current
340+ return with(density) {
341+ containerSize.width.toDp() * Dimens.bubbleMaxWidthFraction
349342 }
350− context.startActivity(Intent.createChooser(intent, null))
351343}
352344
345+@Preview(showBackground = true)
346+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
353347@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),
348+private fun MessageBubbleUserPreview() {
349+ OpencodeTheme {
350+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
351+ MessageBubble(
352+ message = MessageView(
353+ id = "u1",
354+ role = "user",
355+ text = "Hello! Can you summarize the build steps?",
356+ time = 1_700_000_000uL,
357+ status = MessageStatus.SENT,
358+ ),
379359 )
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− },
360+ MessageBubble(
361+ message = MessageView(
362+ id = "a1",
363+ role = "assistant",
364+ text = "Sure — here's a **short** list:\n\n1. Generate types\n2. Build the native library\n3. Assemble the APK",
365+ time = 1_700_000_010uL,
366+ status = MessageStatus.SENT,
367+ ),
390368 )
391369 }
392370 }
393371}
394372
373+@Preview(showBackground = true)
395374@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− }
375+private fun GeneratingIndicatorPreview() {
376+ OpencodeTheme {
377+ GeneratingIndicator()
478378 }
479379}
app/src/main/java/soy/iko/opencode/ui/components/SseDisconnectedBanner.ktnew file+134
@@ -0,0 +1,134 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Row
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.material.icons.Icons
8+import androidx.compose.material.icons.filled.CloudOff
9+import androidx.compose.material3.Icon
10+import androidx.compose.material3.MaterialTheme
11+import androidx.compose.material3.Surface
12+import androidx.compose.material3.Text
13+import androidx.compose.material3.TextButton
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Alignment
16+import androidx.compose.ui.Modifier
17+import androidx.compose.ui.semantics.LiveRegionMode
18+import androidx.compose.ui.semantics.contentDescription
19+import androidx.compose.ui.semantics.liveRegion
20+import androidx.compose.ui.semantics.semantics
21+import androidx.compose.ui.tooling.preview.Preview
22+import androidx.compose.ui.unit.dp
23+import soy.iko.opencode.core.SseState
24+import soy.iko.opencode.ui.theme.Dimens
25+import soy.iko.opencode.ui.theme.OpencodeTheme
26+
27+/**
28+ * A thin banner shown above the message list when the SSE live stream drops.
29+ * Offers a "Reconnect" action and a "Dismiss" action so the user can keep
30+ * reading history without the banner persisting. The banner is a polite live
31+ * region so screen readers announce the disconnection once.
32+ *
33+ * Only render this when [SseState] is [SseState.Error] or an unexpected
34+ * [SseState.Disconnected] while a chat is active.
35+ */
36+@Composable
37+fun SseDisconnectedBanner(
38+ message: String,
39+ onReconnect: () -> Unit,
40+ onDismiss: () -> Unit,
41+ modifier: Modifier = Modifier,
42+ reconnectLabel: String = "Reconnect",
43+ dismissLabel: String = "Dismiss",
44+) {
45+ Surface(
46+ color = MaterialTheme.colorScheme.errorContainer,
47+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
48+ modifier = modifier
49+ .fillMaxWidth()
50+ .semantics(mergeDescendants = true) {
51+ liveRegion = LiveRegionMode.Polite
52+ contentDescription = message
53+ },
54+ ) {
55+ Row(
56+ verticalAlignment = Alignment.CenterVertically,
57+ horizontalArrangement = Arrangement.SpaceBetween,
58+ modifier = Modifier
59+ .fillMaxWidth()
60+ .padding(
61+ horizontal = Dimens.spaceLarge,
62+ vertical = Dimens.spaceSmall,
63+ ),
64+ ) {
65+ Row(
66+ verticalAlignment = Alignment.CenterVertically,
67+ horizontalArrangement = Arrangement.spacedBy(Dimens.spaceSmall),
68+ modifier = Modifier.weight(1f),
69+ ) {
70+ Icon(Icons.Default.CloudOff, contentDescription = null)
71+ Text(
72+ text = message,
73+ style = MaterialTheme.typography.bodySmall,
74+ maxLines = 2,
75+ )
76+ }
77+ Row(verticalAlignment = Alignment.CenterVertically) {
78+ TextButton(onClick = onDismiss) { Text(dismissLabel) }
79+ TextButton(onClick = onReconnect) { Text(reconnectLabel) }
80+ }
81+ }
82+ }
83+}
84+
85+@Preview(showBackground = true)
86+@Composable
87+private fun SseDisconnectedBannerPreview() {
88+ OpencodeTheme {
89+ SseDisconnectedBanner(
90+ message = "Live stream disconnected",
91+ onReconnect = {},
92+ onDismiss = {},
93+ )
94+ }
95+}
96+
97+/**
98+ * A thin non-actionable status banner (e.g. "Connecting…") shown above the
99+ * message list. Uses a neutral color scheme so it doesn't read as an error.
100+ */
101+@Composable
102+fun SseStatusBanner(
103+ message: String,
104+ containerColor: androidx.compose.ui.graphics.Color,
105+ contentColor: androidx.compose.ui.graphics.Color,
106+ modifier: Modifier = Modifier,
107+) {
108+ Surface(
109+ color = containerColor,
110+ contentColor = contentColor,
111+ modifier = modifier
112+ .fillMaxWidth()
113+ .semantics(mergeDescendants = true) {
114+ liveRegion = LiveRegionMode.Polite
115+ contentDescription = message
116+ },
117+ ) {
118+ Row(
119+ verticalAlignment = Alignment.CenterVertically,
120+ horizontalArrangement = Arrangement.Center,
121+ modifier = Modifier
122+ .fillMaxWidth()
123+ .padding(
124+ horizontal = Dimens.spaceLarge,
125+ vertical = Dimens.spaceSmall,
126+ ),
127+ ) {
128+ Text(
129+ text = message,
130+ style = MaterialTheme.typography.bodySmall,
131+ )
132+ }
133+ }
134+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+401−472
@@ -1,590 +1,519 @@
11package soy.iko.opencode.ui.screens
22
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
73import 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
154import androidx.compose.animation.scaleIn
165import androidx.compose.animation.scaleOut
176import androidx.compose.foundation.ExperimentalFoundationApi
18−import androidx.compose.foundation.background
19−import androidx.compose.foundation.clickable
207import androidx.compose.foundation.combinedClickable
218import androidx.compose.foundation.layout.Arrangement
229import androidx.compose.foundation.layout.Box
23−import androidx.compose.foundation.layout.BoxScope
2410import androidx.compose.foundation.layout.Column
11+import androidx.compose.foundation.layout.PaddingValues
2512import androidx.compose.foundation.layout.Row
26−import androidx.compose.foundation.layout.Spacer
2713import androidx.compose.foundation.layout.fillMaxSize
2814import androidx.compose.foundation.layout.fillMaxWidth
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
3415import androidx.compose.foundation.layout.padding
3516import androidx.compose.foundation.layout.size
3617import androidx.compose.foundation.lazy.LazyColumn
37−import androidx.compose.foundation.lazy.itemsIndexed
18+import androidx.compose.foundation.lazy.items
3819import androidx.compose.foundation.lazy.rememberLazyListState
39−import androidx.compose.foundation.shape.CircleShape
40−import androidx.compose.foundation.shape.RoundedCornerShape
41−import androidx.compose.foundation.text.KeyboardActions
42−import androidx.compose.foundation.text.KeyboardOptions
4320import androidx.compose.material.icons.Icons
4421import androidx.compose.material.icons.automirrored.filled.ArrowBack
45−import androidx.compose.material.icons.automirrored.filled.Send
46−import androidx.compose.material.icons.filled.ChatBubbleOutline
47−import androidx.compose.material.icons.filled.Close
22+import androidx.compose.material.icons.filled.ContentCopy
4823import androidx.compose.material.icons.filled.KeyboardArrowDown
49−import androidx.compose.material.icons.filled.KeyboardArrowUp
50−import androidx.compose.material.icons.filled.Stop
51−import androidx.compose.material3.CircularProgressIndicator
24+import androidx.compose.material.icons.filled.MoreVert
25+import androidx.compose.material.icons.filled.Refresh
26+import androidx.compose.material3.DropdownMenu
27+import androidx.compose.material3.DropdownMenuItem
5228import androidx.compose.material3.ExperimentalMaterial3Api
53−import androidx.compose.material3.FilledIconButton
54−import androidx.compose.material3.FilledTonalButton
55−import androidx.compose.material3.FloatingActionButton
29+import androidx.compose.material3.ExtendedFloatingActionButton
5630import androidx.compose.material3.Icon
5731import androidx.compose.material3.IconButton
58−import androidx.compose.material3.LinearProgressIndicator
5932import androidx.compose.material3.MaterialTheme
60−import androidx.compose.material3.OutlinedTextField
6133import androidx.compose.material3.Scaffold
62−import androidx.compose.material3.Surface
6334import androidx.compose.material3.Text
64−import androidx.compose.material3.TextButton
6535import androidx.compose.material3.TopAppBar
6636import androidx.compose.runtime.Composable
6737import androidx.compose.runtime.LaunchedEffect
38+import androidx.compose.runtime.collectAsState
6839import androidx.compose.runtime.derivedStateOf
6940import androidx.compose.runtime.getValue
41+import androidx.compose.runtime.mutableIntStateOf
7042import androidx.compose.runtime.mutableStateOf
7143import androidx.compose.runtime.remember
72−import androidx.compose.runtime.rememberCoroutineScope
44+import androidx.compose.runtime.saveable.rememberSaveable
7345import androidx.compose.runtime.setValue
7446import androidx.compose.ui.Alignment
7547import androidx.compose.ui.Modifier
76−import androidx.compose.ui.draw.clip
77−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
48+import androidx.compose.ui.focus.FocusRequester
49+import androidx.compose.ui.focus.focusRequester
7850import 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
51+import androidx.compose.ui.res.stringResource
52+import androidx.compose.ui.semantics.contentDescription
53+import androidx.compose.ui.semantics.semantics
54+import androidx.compose.ui.text.AnnotatedString
8555import androidx.compose.ui.text.style.TextOverflow
56+import androidx.compose.ui.tooling.preview.Preview
8657import androidx.compose.ui.unit.dp
87−import kotlinx.coroutines.launch
58+import soy.iko.opencode.R
59+import soy.iko.opencode.core.Core
8860import soy.iko.opencode.core.Event
89−import soy.iko.opencode.core.UiState
61+import soy.iko.opencode.core.MessageStatus
62+import soy.iko.opencode.core.MessageView
63+import soy.iko.opencode.core.SseState
64+import soy.iko.opencode.ui.components.ChatInputBar
65+import soy.iko.opencode.ui.components.DateSeparator
66+import soy.iko.opencode.ui.components.DualSnackbarHost
67+import soy.iko.opencode.ui.components.EmptyState
68+import soy.iko.opencode.ui.components.ErrorHost
69+import soy.iko.opencode.ui.components.GeneratingIndicator
70+import soy.iko.opencode.ui.components.InfoHost
71+import soy.iko.opencode.ui.components.LoadingPlaceholder
9072import soy.iko.opencode.ui.components.MessageBubble
73+import soy.iko.opencode.ui.components.SseDisconnectedBanner
74+import soy.iko.opencode.ui.components.SseStatusBanner
75+import soy.iko.opencode.ui.components.dayOfEpoch
76+import soy.iko.opencode.ui.theme.Dimens
77+import soy.iko.opencode.ui.theme.OpencodeTheme
9178
9279@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
9380@Composable
94−fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
81+fun ChatScreen(core: Core) {
82+ val view by core.view.collectAsState()
83+ val sseState by core.sseState.collectAsState()
84+ // Wire the draft to the core's ViewModel so it survives rotation and
85+ // process death. The core stores draftMessage; we seed local state from
86+ // it and write via Event.DraftChanged on every keystroke.
87+ var inputText by remember(view.draftMessage) { mutableStateOf(view.draftMessage) }
9588 val listState = rememberLazyListState()
96− val coroutineScope = rememberCoroutineScope()
97− val context = LocalContext.current
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
89+ var scrollRequest by rememberSaveable { mutableIntStateOf(0) }
90+ var menuExpanded by rememberSaveable { mutableStateOf(false) }
91+ var sseBannerDismissed by rememberSaveable { mutableStateOf(false) }
92+ val inputFocusRequester = remember { FocusRequester() }
93+ val clipboardManager = LocalClipboardManager.current
10394
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 {
95+ // Track whether the user is parked at the bottom of the list. We only
96+ // auto-scroll when they are, so reading history isn't yanked away as
97+ // new messages stream in.
98+ val isAtBottom by remember {
10899 derivedStateOf {
109100 val layoutInfo = listState.layoutInfo
110− val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111101 val totalItems = layoutInfo.totalItemsCount
112− totalItems > 0 && lastVisibleIndex < totalItems - 3
102+ val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
103+ totalItems == 0 || lastVisible >= totalItems - 2
104+ }
105+ }
106+
107+ LaunchedEffect(view.messages.size) {
108+ if (view.messages.isNotEmpty() && (isAtBottom || view.messages.size == 1)) {
109+ listState.animateScrollToItem(view.messages.size - 1)
113110 }
114111 }
115112
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 }
113+ // Scroll-to-bottom when the user taps the FAB. Keyed on a counter so
114+ // repeated taps keep firing.
115+ LaunchedEffect(scrollRequest) {
116+ if (scrollRequest > 0 && view.messages.isNotEmpty()) {
117+ listState.animateScrollToItem(listState.layoutInfo.totalItemsCount - 1)
118+ }
120119 }
121120
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) }
125− LaunchedEffect(showScrollButton, state.messages.size) {
126− if (!showScrollButton) {
127− lastSeenCount = state.messages.size
121+ // Auto-focus the input when the chat is empty so the user can start
122+ // typing immediately without an extra tap. Only request focus once per
123+ // session entry — keyed on the session id so switching sessions retriggers.
124+ val sessionId = view.currentSessionId
125+ LaunchedEffect(sessionId, view.messages.isEmpty()) {
126+ if (sessionId != null && view.messages.isEmpty() && !view.loading && !view.generating) {
127+ inputFocusRequester.requestFocus()
128128 }
129129 }
130− val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131130
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) {
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) {
142− listState.animateScrollToItem(state.messages.size - 1)
131+ val errorHostState = ErrorHost(
132+ core = core,
133+ dismissLabel = stringResource(R.string.action_dismiss),
134+ retryLabel = stringResource(R.string.action_retry),
135+ )
136+ val infoHostState = InfoHost(
137+ core = core,
138+ successConnectedLabel = stringResource(R.string.connect_success),
139+ successSessionCreatedLabel = stringResource(R.string.session_created),
140+ copiedLabel = stringResource(R.string.chat_copied),
141+ sessionDeletedLabel = stringResource(R.string.session_deleted),
142+ )
143+
144+ val showSseBanner = (sseState is SseState.Error ||
145+ (sseState == SseState.Disconnected && view.currentSessionId != null && !view.loading)) &&
146+ !sseBannerDismissed
147+
148+ // Reset the dismissed flag whenever a fresh SSE error arrives so the
149+ // banner reappears for a new disconnect.
150+ LaunchedEffect((sseState as? SseState.Error)?.message) {
151+ if (sseState is SseState.Error) sseBannerDismissed = false
152+ }
153+
154+ // Resolve nullable session fields into locals once per recomposition so we
155+ // never smart-cast a delegate-backed collected property across calls.
156+ val sessionTitle = view.currentSessionTitle
157+ val chatFallback = stringResource(R.string.top_bar_chat_fallback)
158+ val idShortTemplate = stringResource(R.string.chat_session_id_short)
159+ val title = remember(sessionId, sessionTitle) {
160+ when {
161+ sessionTitle.isNotEmpty() -> sessionTitle
162+ sessionId != null -> {
163+ val prefix = sessionId.take(8)
164+ if (sessionId.length > prefix.length) "$prefix…" else prefix
143165 }
166+ else -> chatFallback
144167 }
145168 }
169+ val subtitle = remember(sessionId, sessionTitle) {
170+ if (sessionTitle.isNotEmpty() && sessionId != null) {
171+ idShortTemplate.format(sessionId.take(8))
172+ } else null
173+ }
174+
175+ val refreshLabel = stringResource(R.string.chat_menu_refresh)
176+ val overflowLabel = stringResource(R.string.chat_menu_overflow)
177+ val chatEmptyHint = stringResource(R.string.chat_empty_hint)
178+ val copySessionIdCd = stringResource(R.string.chat_cd_copy_session_id)
179+ val sessionIdCopiedLabel = stringResource(R.string.chat_session_id_copied)
146180
147181 Scaffold(
148182 topBar = {
149183 TopAppBar(
150184 title = {
151− val title = state.currentSessionTitle.ifEmpty { "Chat" }
152− Text(
153− text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
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− ),
165− )
185+ Column(
186+ modifier = Modifier.fillMaxWidth(),
187+ ) {
188+ Text(
189+ text = title,
190+ maxLines = 1,
191+ overflow = TextOverflow.Ellipsis,
192+ )
193+ if (subtitle != null && sessionId != null) {
194+ Row(
195+ verticalAlignment = Alignment.CenterVertically,
196+ modifier = Modifier
197+ .fillMaxWidth()
198+ .combinedClickable(
199+ onClick = {
200+ clipboardManager.setText(AnnotatedString(sessionId))
201+ core.notifyCopied()
202+ },
203+ onLongClick = {
204+ clipboardManager.setText(AnnotatedString(sessionId))
205+ core.notifyCopied()
206+ },
207+ )
208+ .semantics {
209+ contentDescription = copySessionIdCd
210+ },
211+ ) {
212+ Text(
213+ text = subtitle,
214+ style = MaterialTheme.typography.labelSmall,
215+ color = MaterialTheme.colorScheme.onSurfaceVariant,
216+ maxLines = 1,
217+ )
218+ Icon(
219+ Icons.Default.ContentCopy,
220+ contentDescription = null,
221+ modifier = Modifier.size(Dimens.iconInlineSpinner),
222+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
223+ )
224+ }
225+ }
226+ }
166227 },
167228 navigationIcon = {
168− IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
169− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
229+ IconButton(onClick = { core.update(Event.NavigateToSessions) }) {
230+ Icon(
231+ Icons.AutoMirrored.Filled.ArrowBack,
232+ contentDescription = stringResource(R.string.chat_cd_back),
233+ )
170234 }
171235 },
172236 actions = {
173− if (state.generating) {
174− CircularProgressIndicator(
175− modifier = Modifier.padding(end = 16.dp).size(22.dp),
176− strokeWidth = 2.dp,
177− )
237+ // Overflow menu with a "Refresh messages" action. Gives
238+ // a discoverable manual reload path (e.g. when the SSE
239+ // banner has been dismissed) and a home for future items.
240+ Box {
241+ IconButton(onClick = { menuExpanded = true }) {
242+ Icon(
243+ Icons.Default.MoreVert,
244+ contentDescription = overflowLabel,
245+ )
246+ }
247+ DropdownMenu(
248+ expanded = menuExpanded,
249+ onDismissRequest = { menuExpanded = false },
250+ ) {
251+ DropdownMenuItem(
252+ text = { Text(refreshLabel) },
253+ leadingIcon = {
254+ Icon(Icons.Default.Refresh, contentDescription = null)
255+ },
256+ onClick = {
257+ menuExpanded = false
258+ sessionId?.let { core.update(Event.LoadMessages(it)) }
259+ },
260+ )
261+ }
178262 }
179263 },
180264 )
181265 },
182266 bottomBar = {
183267 ChatInputBar(
184− draft = state.draft,
185− generating = state.generating,
186− loading = state.loading && state.messages.isEmpty(),
187− onDraftChange = { dispatch(Event.DraftChanged(it)) },
188− onSend = { dispatch(Event.SendMessage(state.draft)) },
189− onStop = { dispatch(Event.CancelGeneration) },
268+ text = inputText,
269+ onTextChange = {
270+ inputText = it
271+ core.update(Event.DraftChanged(it))
272+ },
273+ onSend = {
274+ if (inputText.isNotBlank()) {
275+ core.update(Event.SendMessage(inputText))
276+ inputText = ""
277+ core.update(Event.DraftChanged(""))
278+ }
279+ },
280+ // Disable while loading OR while the assistant is generating,
281+ // so users can't fire a second message mid-reply.
282+ enabled = !view.loading && !view.generating,
283+ generating = view.generating,
284+ onStop = { core.update(Event.CancelGeneration) },
285+ stopContentDescription = stringResource(R.string.chat_cd_stop),
286+ placeholder = stringResource(R.string.chat_input_placeholder),
287+ sendContentDescription = stringResource(R.string.chat_cd_send),
288+ modifier = Modifier.focusRequester(inputFocusRequester),
190289 )
191290 },
291+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
192292 ) { padding ->
193− Column(Modifier.fillMaxSize().padding(padding)) {
194− if (!state.sseConnected && !state.generating) {
195− ReconnectingBanner()
196− }
197− if (state.loading && state.messages.isEmpty()) {
198− LinearProgressIndicator(Modifier.fillMaxWidth())
199− }
200− if (state.error != null && state.messages.isNotEmpty()) {
201− ErrorBanner(
202− message = state.error,
203− onDismiss = { dispatch(Event.DismissError) },
204− )
205− }
206− Box(Modifier.fillMaxWidth().weight(1f)) {
207− if (state.messages.isEmpty() && !state.loading) {
208− Column(
209− modifier = Modifier.align(Alignment.Center).padding(32.dp),
210− horizontalAlignment = Alignment.CenterHorizontally,
211− ) {
212− Icon(
213− Icons.Filled.ChatBubbleOutline,
214− contentDescription = null,
215− modifier = Modifier.size(48.dp),
216− tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
217− )
218− Spacer(Modifier.size(16.dp))
219− Text(
220− text = "Send a message to start the conversation.",
221− style = MaterialTheme.typography.bodyMedium,
222− color = MaterialTheme.colorScheme.onSurfaceVariant,
223− textAlign = TextAlign.Center,
224− )
225− }
293+ Box(
294+ modifier = Modifier
295+ .fillMaxSize()
296+ .padding(padding),
297+ ) {
298+ Column(modifier = Modifier.fillMaxSize()) {
299+ if (showSseBanner) {
300+ val bannerMessage = (sseState as? SseState.Error)?.message
301+ ?: stringResource(R.string.sse_disconnected)
302+ SseDisconnectedBanner(
303+ message = bannerMessage,
304+ onReconnect = {
305+ sseBannerDismissed = false
306+ core.reconnectSse()
307+ },
308+ onDismiss = { sseBannerDismissed = true },
309+ reconnectLabel = stringResource(R.string.sse_reconnect),
310+ dismissLabel = stringResource(R.string.sse_dismiss),
311+ )
312+ } else if (sseState == SseState.Connecting && sessionId != null && !view.loading) {
313+ // Brief "connecting" indicator while the SSE stream
314+ // establishes — not an error, just a transitional state.
315+ SseStatusBanner(
316+ message = stringResource(R.string.sse_connecting),
317+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
318+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
319+ )
226320 }
227− LazyColumn(
228− state = listState,
229− modifier = Modifier.fillMaxSize(),
230− contentPadding = PaddingValues(vertical = 8.dp),
231− ) {
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
240− MessageBubble(
241− message,
242− onRetry = { dispatch(Event.SendMessage(message.text)) },
243− compactSpacing = sameSenderAsPrevious,
244− )
321+
322+ when {
323+ view.loading && view.messages.isEmpty() -> {
324+ LoadingPlaceholder()
245325 }
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− }
326+ view.messages.isEmpty() -> {
327+ EmptyState(message = chatEmptyHint)
253328 }
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)
329+ else -> {
330+ // Pull-to-refresh lets the user manually reload
331+ // messages, e.g. when the SSE stream stalled and the
332+ // banner was dismissed. Mirrors the Sessions screen.
333+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
334+ isRefreshing = view.loading,
335+ onRefresh = { sessionId?.let { core.update(Event.LoadMessages(it)) } },
336+ modifier = Modifier.fillMaxSize(),
337+ ) {
338+ LazyColumn(
339+ state = listState,
340+ modifier = Modifier
341+ .fillMaxSize()
342+ .padding(horizontal = Dimens.listHorizontalPadding),
343+ verticalArrangement = Arrangement.spacedBy(Dimens.listItemSpacing),
344+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
345+ ) {
346+ val msgs = view.messages
347+ for (i in msgs.indices) {
348+ val msg = msgs[i]
349+ // Insert a date separator before a message
350+ // when its calendar day differs from the
351+ // previous message's day (and it has a
352+ // valid timestamp).
353+ if (msg.time > 0uL) {
354+ val prevDay = if (i > 0) dayOfEpoch(msgs[i - 1].time) else Int.MIN_VALUE
355+ val curDay = dayOfEpoch(msg.time)
356+ if (curDay != prevDay) {
357+ item(key = "date_${msg.id}") {
358+ DateSeparator(epochSeconds = msg.time)
359+ }
360+ }
361+ }
362+ item(key = msg.id) {
363+ MessageBubble(
364+ message = msg,
365+ onCopied = { core.notifyCopied() },
366+ )
367+ }
368+ }
369+ // "Thinking" indicator shown after the user sends, while
370+ // the assistant reply hasn't arrived yet. The indicator
371+ // itself animates (pulsing dots), so we don't wrap it in
372+ // AnimatedVisibility — an outer ColumnScope receiver from
373+ // the chat Column would shadow the LazyItemScope here and
374+ // break AnimatedVisibility overload resolution.
375+ if (view.generating) {
376+ item(key = "__generating__") {
377+ GeneratingIndicator()
378+ }
379+ }
268380 }
269381 }
270− },
271− )
272− ScrollToTopButton(
273− visible = showScrollTop,
274− onClick = {
275− coroutineScope.launch {
276− listState.animateScrollToItem(0)
277− }
278− },
279− )
382+ }
383+ }
280384 }
281− }
282− }
283−}
284385
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),
386+ // Scroll-to-bottom FAB: only when the user has scrolled up away
387+ // from the latest message and there's content to scroll to.
388+ // Animated so it scales in/out rather than popping.
389+ AnimatedVisibility(
390+ visible = view.messages.isNotEmpty() && !isAtBottom,
391+ enter = scaleIn(),
392+ exit = scaleOut(),
393+ modifier = Modifier.align(Alignment.BottomEnd),
300394 ) {
301− Icon(
302− Icons.Filled.KeyboardArrowDown,
303− contentDescription = "Scroll to latest message",
304− )
305− }
306− if (showBadge) {
307− Box(
395+ ExtendedFloatingActionButton(
396+ onClick = { scrollRequest++ },
397+ icon = {
398+ Icon(
399+ Icons.Default.KeyboardArrowDown,
400+ contentDescription = stringResource(R.string.chat_cd_scroll_to_bottom),
401+ )
402+ },
403+ text = { Text(stringResource(R.string.chat_scroll_to_latest)) },
404+ containerColor = MaterialTheme.colorScheme.primaryContainer,
405+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
308406 modifier = Modifier
309− .align(Alignment.TopEnd)
310− .size(8.dp)
311− .clip(CircleShape)
312− .background(MaterialTheme.colorScheme.error),
407+ .padding(
408+ end = Dimens.fabEndMargin,
409+ bottom = Dimens.fabBottomMargin,
410+ ),
313411 )
314412 }
315413 }
316414 }
317415}
318416
319−// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320−// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
417+@OptIn(ExperimentalMaterial3Api::class)
418+@Preview(showBackground = true)
419+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
321420@Composable
322−private fun BoxScope.ScrollToTopButton(visible: Boolean, onClick: () -> Unit) {
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)
421+private fun ChatScreenPreview() {
422+ OpencodeTheme {
423+ PreviewChatScreen(
424+ title = "Refactor the parser",
425+ sessionId = "abc12345",
426+ messages = listOf(
427+ MessageView("u1", "user", "Can you clean up parse_messages?", 1_700_000_000uL, MessageStatus.SENT),
428+ MessageView(
429+ "a1",
430+ "assistant",
431+ "Sure — here's a **plan**:\n\n1. Extract helpers\n2. Add tests",
432+ 1_700_000_010uL,
433+ MessageStatus.SENT,
434+ ),
435+ ),
436+ generating = false,
437+ )
374438 }
375439}
376440
441+@OptIn(ExperimentalMaterial3Api::class)
442+@Preview(showBackground = true)
377443@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")
395− Row(
396− modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
397− horizontalArrangement = Arrangement.Start,
398− ) {
399− Row(
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),
406− ) {
407− Text(
408− text = "Thinking",
409− style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
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)),
429− )
430− }
431− }
432− }
444+private fun ChatScreenGeneratingPreview() {
445+ OpencodeTheme {
446+ PreviewChatScreen(
447+ title = "New session",
448+ sessionId = "xyz12345",
449+ messages = listOf(MessageView("u1", "user", "Hello!", 1_700_000_000uL, MessageStatus.SENT)),
450+ generating = true,
451+ )
433452 }
434453}
435454
455+@OptIn(ExperimentalMaterial3Api::class)
456+@Preview(showBackground = true)
436457@Composable
437−private fun ReconnectingBanner() {
438− Surface(
439− modifier = Modifier.fillMaxWidth(),
440− color = MaterialTheme.colorScheme.tertiaryContainer,
441− ) {
442− Row(
443− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
444− verticalAlignment = Alignment.CenterVertically,
445− horizontalArrangement = Arrangement.spacedBy(8.dp),
446− ) {
447− CircularProgressIndicator(
448− modifier = Modifier.size(14.dp),
449− strokeWidth = 2.dp,
450− color = MaterialTheme.colorScheme.onTertiaryContainer,
451− )
452− Text(
453− text = "Reconnecting…",
454− style = MaterialTheme.typography.bodySmall,
455− color = MaterialTheme.colorScheme.onTertiaryContainer,
456− )
457− }
458+private fun ChatScreenEmptyPreview() {
459+ OpencodeTheme {
460+ PreviewChatScreen(title = "", sessionId = null, messages = emptyList(), generating = false)
458461 }
459462}
460463
464+@OptIn(ExperimentalMaterial3Api::class)
461465@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,
476− modifier = Modifier.weight(1f),
477− )
478− IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
479− Icon(
480− Icons.Filled.Close,
481− contentDescription = "Dismiss error",
482− tint = MaterialTheme.colorScheme.onErrorContainer,
483− modifier = Modifier.size(18.dp),
484− )
485− }
486− }
487− }
488−}
489−
490−@Composable
491−private fun ChatInputBar(
492− draft: String,
466+private fun PreviewChatScreen(
467+ title: String,
468+ sessionId: String?,
469+ messages: List<MessageView>,
493470 generating: Boolean,
494− loading: Boolean,
495− onDraftChange: (String) -> Unit,
496− onSend: () -> Unit,
497− onStop: () -> Unit,
498471) {
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
472+ val resolvedTitle = when {
473+ title.isNotEmpty() -> title
474+ sessionId != null -> {
475+ val prefix = sessionId.take(8)
476+ if (sessionId.length > prefix.length) "$prefix…" else prefix
477+ }
478+ else -> "Chat"
479+ }
480+ Scaffold(
481+ topBar = {
482+ TopAppBar(
483+ title = {
484+ Text(
485+ text = resolvedTitle,
486+ maxLines = 1,
487+ overflow = TextOverflow.Ellipsis,
488+ )
534489 },
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− )
490+ navigationIcon = {
491+ IconButton(onClick = {}) {
492+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
546493 }
547− } else {
548− null
549494 },
550− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551− keyboardActions = KeyboardActions(onSend = {
552− if (!generating && !loading && draft.isNotBlank()) onSend()
553− }),
554495 )
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")
496+ },
497+ bottomBar = {
498+ ChatInputBar(text = "", onTextChange = {}, onSend = {}, enabled = true)
499+ },
500+ ) { padding ->
501+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
502+ when {
503+ messages.isEmpty() -> EmptyState(message = "Send a message to start chatting")
504+ else -> {
505+ LazyColumn(
506+ modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
507+ verticalArrangement = Arrangement.spacedBy(8.dp),
508+ contentPadding = PaddingValues(vertical = 12.dp),
509+ ) {
510+ items(messages, key = { it.id }) { MessageBubble(it) }
511+ if (generating) {
512+ item(key = "__generating__") { GeneratingIndicator() }
513+ }
514+ }
574515 }
575516 }
576517 }
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− }
589518 }
590519}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+267−156
@@ -1,12 +1,13 @@
11package soy.iko.opencode.ui.screens
22
3+import androidx.compose.animation.AnimatedVisibility
34import androidx.compose.foundation.layout.Arrangement
45import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
57import androidx.compose.foundation.layout.Spacer
68import androidx.compose.foundation.layout.fillMaxSize
79import androidx.compose.foundation.layout.fillMaxWidth
810import androidx.compose.foundation.layout.height
9−import androidx.compose.foundation.layout.imePadding
1011import androidx.compose.foundation.layout.padding
1112import androidx.compose.foundation.layout.size
1213import androidx.compose.foundation.rememberScrollState
@@ -14,209 +15,319 @@ import androidx.compose.foundation.text.KeyboardActions
1415import androidx.compose.foundation.text.KeyboardOptions
1516import androidx.compose.foundation.verticalScroll
1617import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.Dns
1719import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material.icons.filled.Person
19−import androidx.compose.material.icons.filled.Terminal
2020import androidx.compose.material.icons.filled.Visibility
2121import androidx.compose.material.icons.filled.VisibilityOff
22+import androidx.compose.material.icons.filled.Warning
2223import androidx.compose.material3.Button
2324import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.ExperimentalMaterial3Api
2426import androidx.compose.material3.Icon
2527import androidx.compose.material3.IconButton
2628import androidx.compose.material3.MaterialTheme
2729import androidx.compose.material3.OutlinedTextField
30+import androidx.compose.material3.Scaffold
31+import androidx.compose.material3.SnackbarHostState
2832import androidx.compose.material3.Text
33+import androidx.compose.material3.TextButton
34+import androidx.compose.material3.TopAppBar
2935import androidx.compose.runtime.Composable
3036import androidx.compose.runtime.LaunchedEffect
37+import androidx.compose.runtime.collectAsState
3138import androidx.compose.runtime.getValue
3239import androidx.compose.runtime.mutableStateOf
3340import androidx.compose.runtime.remember
41+import androidx.compose.runtime.saveable.rememberSaveable
3442import androidx.compose.runtime.setValue
3543import androidx.compose.ui.Alignment
3644import androidx.compose.ui.Modifier
3745import androidx.compose.ui.focus.FocusRequester
3846import androidx.compose.ui.focus.focusRequester
47+import androidx.compose.ui.res.pluralStringResource
48+import androidx.compose.ui.res.stringResource
3949import androidx.compose.ui.text.input.ImeAction
4050import androidx.compose.ui.text.input.KeyboardType
4151import androidx.compose.ui.text.input.PasswordVisualTransformation
4252import androidx.compose.ui.text.input.VisualTransformation
43−import androidx.compose.ui.text.style.TextAlign
53+import androidx.compose.ui.tooling.preview.Preview
4454import androidx.compose.ui.unit.dp
55+import soy.iko.opencode.CrashLogger
56+import soy.iko.opencode.R
57+import soy.iko.opencode.core.Core
4558import soy.iko.opencode.core.Event
46−import soy.iko.opencode.core.UiState
59+import soy.iko.opencode.ui.components.CrashLogDialog
60+import soy.iko.opencode.ui.components.DualSnackbarHost
61+import soy.iko.opencode.ui.components.ErrorHost
62+import soy.iko.opencode.ui.components.ErrorSnackbarHost
63+import soy.iko.opencode.ui.components.InfoHost
64+import soy.iko.opencode.ui.components.InfoSnackbarHost
65+import soy.iko.opencode.ui.theme.Dimens
4766
67+@OptIn(ExperimentalMaterial3Api::class)
4868@Composable
49−fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50− val focusRequester = remember { FocusRequester() }
51− var passwordVisible by remember { mutableStateOf(false) }
69+fun ConnectScreen(core: Core) {
70+ val view by core.view.collectAsState()
71+ val crashCount by CrashLogger.reportCount.collectAsState()
72+ var url by remember(view.serverUrl) { mutableStateOf(view.serverUrl) }
73+ var username by remember(view.username) { mutableStateOf(view.username) }
74+ var password by remember(view.password) { mutableStateOf(view.password) }
75+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
76+ var showCrashLogs by rememberSaveable { mutableStateOf(false) }
77+ var urlError by rememberSaveable { mutableStateOf(false) }
78+ val errorHostState = ErrorHost(
79+ core = core,
80+ dismissLabel = stringResource(R.string.action_dismiss),
81+ retryLabel = stringResource(R.string.action_retry),
82+ )
83+ val infoHostState = InfoHost(
84+ core = core,
85+ successConnectedLabel = stringResource(R.string.connect_success),
86+ successSessionCreatedLabel = stringResource(R.string.session_created),
87+ )
5288
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.
55− LaunchedEffect(Unit) {
56− if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
89+ // Auto-focus the username field when the server reveals it requires
90+ // authentication, so the user doesn't have to tap into it manually.
91+ val usernameFocusRequester = remember { FocusRequester() }
92+ LaunchedEffect(view.authRequired) {
93+ if (view.authRequired) {
94+ usernameFocusRequester.requestFocus()
95+ }
5796 }
5897
59− Column(
60− modifier = Modifier
61− .fillMaxSize()
62− .verticalScroll(rememberScrollState())
63− .imePadding()
64− .padding(horizontal = 28.dp),
65− verticalArrangement = Arrangement.Center,
66− horizontalAlignment = Alignment.CenterHorizontally,
67− ) {
68− Icon(
69− Icons.Filled.Terminal,
70− contentDescription = null,
71− tint = MaterialTheme.colorScheme.primary,
72− modifier = Modifier.size(72.dp),
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− )
98+ fun isValidUrl(value: String): Boolean {
99+ val trimmed = value.trim()
100+ if (trimmed.isEmpty()) return false
101+ return trimmed.startsWith("http://", ignoreCase = true) ||
102+ trimmed.startsWith("https://", ignoreCase = true)
103+ }
103104
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,
113− )
105+ fun submit() {
106+ val trimmedUrl = url.trim()
107+ if (!isValidUrl(trimmedUrl)) {
108+ urlError = true
109+ return
110+ }
111+ urlError = false
112+ url = trimmedUrl
113+ core.update(Event.ServerUrlChanged(trimmedUrl))
114+ if (view.authRequired || username.isNotBlank() || password.isNotBlank()) {
115+ core.update(Event.UsernameChanged(username.trim()))
116+ core.update(Event.PasswordChanged(password))
114117 }
118+ core.update(Event.Connect)
119+ }
115120
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")) {
119− Spacer(Modifier.height(6.dp))
121+ Scaffold(
122+ topBar = {
123+ TopAppBar(title = { Text(stringResource(R.string.top_bar_connect)) })
124+ },
125+ snackbarHost = {
126+ DualSnackbarHost(errorHostState, infoHostState)
127+ },
128+ ) { padding ->
129+ Column(
130+ horizontalAlignment = Alignment.CenterHorizontally,
131+ verticalArrangement = Arrangement.Center,
132+ modifier = Modifier
133+ .fillMaxSize()
134+ .padding(padding)
135+ .padding(Dimens.screenPadding)
136+ .verticalScroll(rememberScrollState()),
137+ ) {
138+ Icon(
139+ imageVector = Icons.Default.Dns,
140+ contentDescription = stringResource(R.string.connect_cd_dns),
141+ modifier = Modifier.size(Dimens.iconHero),
142+ tint = MaterialTheme.colorScheme.primary,
143+ )
144+ Spacer(Modifier.height(Dimens.spaceLarge))
120145 Text(
121− text = "Tip: Add http:// if connecting to a local server",
122− style = MaterialTheme.typography.bodySmall,
123− color = MaterialTheme.colorScheme.tertiary,
124− textAlign = TextAlign.Center,
146+ text = stringResource(R.string.connect_heading),
147+ style = MaterialTheme.typography.headlineSmall,
125148 )
126− }
127−
128− if (state.authRequired) {
129− Spacer(Modifier.height(12.dp))
149+ Spacer(Modifier.height(Dimens.spaceXLarge))
130150 OutlinedTextField(
131− value = state.username,
132− onValueChange = { dispatch(Event.UsernameChanged(it)) },
133− label = { Text("Username") },
151+ value = url,
152+ onValueChange = {
153+ url = it
154+ if (urlError) urlError = false
155+ },
156+ label = { Text(stringResource(R.string.connect_field_server_url)) },
134157 singleLine = true,
135− leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
158+ isError = urlError,
159+ supportingText = if (urlError) {
160+ { Text(stringResource(R.string.connect_error_invalid_url)) }
161+ } else null,
162+ keyboardOptions = KeyboardOptions(
163+ keyboardType = KeyboardType.Uri,
164+ imeAction = ImeAction.Go,
165+ ),
166+ keyboardActions = KeyboardActions(onGo = { submit() }),
136167 modifier = Modifier.fillMaxWidth(),
137− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138− enabled = !state.loading,
139168 )
140− Spacer(Modifier.height(12.dp))
141− OutlinedTextField(
142− value = state.password,
143− onValueChange = { dispatch(Event.PasswordChanged(it)) },
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− )
169+
170+ AnimatedVisibility(visible = view.authRequired) {
171+ Column(
172+ horizontalAlignment = Alignment.CenterHorizontally,
173+ modifier = Modifier.fillMaxWidth().padding(top = Dimens.spaceSmall),
174+ ) {
175+ Text(
176+ text = stringResource(R.string.connect_auth_required),
177+ style = MaterialTheme.typography.bodyMedium,
178+ color = MaterialTheme.colorScheme.error,
179+ )
180+ Spacer(Modifier.height(Dimens.spaceSmall))
181+ OutlinedTextField(
182+ value = username,
183+ onValueChange = { username = it },
184+ label = { Text(stringResource(R.string.connect_field_username)) },
185+ singleLine = true,
186+ leadingIcon = {
187+ Icon(
188+ Icons.Default.Lock,
189+ contentDescription = stringResource(R.string.connect_cd_lock),
190+ )
191+ },
192+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
193+ modifier = Modifier
194+ .fillMaxWidth()
195+ .focusRequester(usernameFocusRequester),
196+ )
197+ Spacer(Modifier.height(Dimens.spaceSmall))
198+ OutlinedTextField(
199+ value = password,
200+ onValueChange = { password = it },
201+ label = { Text(stringResource(R.string.connect_field_password)) },
202+ singleLine = true,
203+ visualTransformation = if (passwordVisible) {
204+ VisualTransformation.None
205+ } else {
206+ PasswordVisualTransformation()
207+ },
208+ trailingIcon = {
209+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
210+ Icon(
211+ if (passwordVisible) {
212+ Icons.Default.VisibilityOff
213+ } else {
214+ Icons.Default.Visibility
215+ },
216+ contentDescription = if (passwordVisible) {
217+ stringResource(R.string.connect_password_hide)
218+ } else {
219+ stringResource(R.string.connect_password_show)
220+ },
221+ )
222+ }
223+ },
224+ keyboardOptions = KeyboardOptions(
225+ keyboardType = KeyboardType.Password,
226+ imeAction = ImeAction.Go,
227+ ),
228+ keyboardActions = KeyboardActions(onGo = { submit() }),
229+ modifier = Modifier.fillMaxWidth(),
230+ )
231+ Spacer(Modifier.height(Dimens.spaceTiny))
232+ TextButton(onClick = { core.update(Event.CancelAuth) }) {
233+ Text(stringResource(R.string.connect_button_cancel))
154234 }
155− },
235+ }
236+ }
237+
238+ Spacer(Modifier.height(Dimens.spaceLarge))
239+ Button(
240+ onClick = { submit() },
241+ enabled = !view.loading,
156242 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)
243+ ) {
244+ if (view.loading) {
245+ CircularProgressIndicator(
246+ modifier = Modifier.size(Dimens.iconButtonSpinner),
247+ strokeWidth = Dimens.strokeThin,
248+ color = MaterialTheme.colorScheme.onPrimary,
249+ )
250+ } else {
251+ Text(stringResource(R.string.connect_button_connect))
252+ }
253+ }
254+
255+ if (crashCount > 0) {
256+ Spacer(Modifier.height(Dimens.spaceLarge))
257+ Row(verticalAlignment = Alignment.CenterVertically) {
258+ Icon(
259+ Icons.Default.Warning,
260+ contentDescription = stringResource(R.string.connect_cd_warning),
261+ tint = MaterialTheme.colorScheme.error,
262+ modifier = Modifier.size(Dimens.iconInputLeading),
263+ )
264+ Spacer(Modifier.size(Dimens.gapTiny))
265+ TextButton(onClick = { showCrashLogs = true }) {
266+ Text(
267+ text = pluralStringResource(R.plurals.crash_reports_view, crashCount, crashCount),
268+ style = MaterialTheme.typography.labelSmall,
269+ )
165270 }
166− }),
167− enabled = !state.loading,
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− )
271+ }
272+ }
175273 }
274+ }
176275
177− Spacer(Modifier.height(24.dp))
276+ if (showCrashLogs) {
277+ CrashLogDialog(
278+ reports = CrashLogger.getReports(),
279+ onDismiss = { showCrashLogs = false },
280+ onClear = {
281+ CrashLogger.clearReports()
282+ showCrashLogs = false
283+ },
284+ title = stringResource(R.string.crash_reports_title),
285+ clearLabel = stringResource(R.string.crash_reports_clear),
286+ closeLabel = stringResource(R.string.crash_reports_close),
287+ cancelLabel = stringResource(R.string.crash_reports_cancel),
288+ clearConfirmLabel = stringResource(R.string.crash_reports_clear_confirm),
289+ clearConfirmYesLabel = stringResource(R.string.crash_reports_clear_confirm_yes),
290+ )
291+ }
292+}
178293
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− ) {
187− if (state.loading) {
188− CircularProgressIndicator(
189− modifier = Modifier.height(20.dp),
190− strokeWidth = 2.dp,
191− color = MaterialTheme.colorScheme.onPrimary,
294+@OptIn(ExperimentalMaterial3Api::class)
295+@Preview(showBackground = true)
296+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
297+@Composable
298+private fun ConnectScreenPreview() {
299+ soy.iko.opencode.ui.theme.OpencodeTheme {
300+ Scaffold(topBar = { TopAppBar(title = { Text("Opencode") }) }) { padding ->
301+ Column(
302+ horizontalAlignment = Alignment.CenterHorizontally,
303+ verticalArrangement = Arrangement.Center,
304+ modifier = Modifier
305+ .fillMaxSize()
306+ .padding(padding)
307+ .padding(24.dp)
308+ .verticalScroll(rememberScrollState()),
309+ ) {
310+ Icon(
311+ Icons.Default.Dns,
312+ contentDescription = null,
313+ modifier = Modifier.size(72.dp),
314+ tint = MaterialTheme.colorScheme.primary,
315+ )
316+ Spacer(Modifier.height(16.dp))
317+ Text("Connect to Opencode Server", style = MaterialTheme.typography.headlineSmall)
318+ Spacer(Modifier.height(24.dp))
319+ OutlinedTextField(
320+ value = "http://localhost:4096",
321+ onValueChange = {},
322+ label = { Text("Server URL") },
323+ singleLine = true,
324+ modifier = Modifier.fillMaxWidth(),
192325 )
193− } else {
194− Text(if (state.authRequired) "Sign in" else "Connect")
326+ Spacer(Modifier.height(16.dp))
327+ Button(onClick = {}, modifier = Modifier.fillMaxWidth()) {
328+ Text("Connect")
329+ }
195330 }
196331 }
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− }
221332 }
222333}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+218−314
@@ -1,234 +1,139 @@
11package soy.iko.opencode.ui.screens
22
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
73import androidx.compose.foundation.ExperimentalFoundationApi
8−import androidx.compose.foundation.background
94import androidx.compose.foundation.clickable
105import androidx.compose.foundation.combinedClickable
11−import androidx.compose.foundation.layout.Arrangement
126import androidx.compose.foundation.layout.Box
13−import androidx.compose.foundation.layout.Column
147import androidx.compose.foundation.layout.PaddingValues
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.Spacer
178import androidx.compose.foundation.layout.fillMaxSize
18−import androidx.compose.foundation.layout.fillMaxWidth
19−import androidx.compose.foundation.layout.height
209import androidx.compose.foundation.layout.padding
21−import androidx.compose.foundation.layout.size
2210import androidx.compose.foundation.lazy.LazyColumn
2311import androidx.compose.foundation.lazy.items
24−import androidx.compose.foundation.lazy.rememberLazyListState
25−import androidx.compose.foundation.shape.CircleShape
2612import androidx.compose.material.icons.Icons
2713import androidx.compose.material.icons.automirrored.filled.ArrowBack
14+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
2815import androidx.compose.material.icons.filled.Add
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
16+import androidx.compose.material.icons.filled.ChatBubble
17+import androidx.compose.material.icons.filled.Delete
3318import androidx.compose.material3.AlertDialog
34−import androidx.compose.material3.CircularProgressIndicator
3519import androidx.compose.material3.ExperimentalMaterial3Api
36−import androidx.compose.material3.ExtendedFloatingActionButton
37−import androidx.compose.material3.HorizontalDivider
3820import androidx.compose.material3.Icon
3921import androidx.compose.material3.IconButton
40−import androidx.compose.material3.LinearProgressIndicator
22+import androidx.compose.material3.ListItem
4123import androidx.compose.material3.MaterialTheme
42−import androidx.compose.material3.OutlinedTextField
4324import androidx.compose.material3.Scaffold
44−import androidx.compose.material3.Surface
45−import androidx.compose.material3.SwipeToDismissBox
46−import androidx.compose.material3.SwipeToDismissBoxValue
4725import androidx.compose.material3.Text
4826import androidx.compose.material3.TextButton
4927import androidx.compose.material3.TopAppBar
50−import androidx.compose.material3.rememberSwipeToDismissBoxState
5128import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52−import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
5329import androidx.compose.runtime.Composable
54−import androidx.compose.runtime.LaunchedEffect
30+import androidx.compose.runtime.collectAsState
5531import androidx.compose.runtime.getValue
5632import androidx.compose.runtime.mutableStateOf
5733import androidx.compose.runtime.remember
5834import androidx.compose.runtime.setValue
59−import androidx.compose.ui.Alignment
6035import 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
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.semantics
6741import androidx.compose.ui.text.style.TextOverflow
42+import androidx.compose.ui.tooling.preview.Preview
6843import androidx.compose.ui.unit.dp
44+import soy.iko.opencode.R
45+import soy.iko.opencode.core.Core
6946import soy.iko.opencode.core.Event
7047import soy.iko.opencode.core.SessionView
71−import soy.iko.opencode.core.UiState
48+import soy.iko.opencode.ui.components.DualSnackbarHost
49+import soy.iko.opencode.ui.components.EmptyState
50+import soy.iko.opencode.ui.components.ErrorHost
51+import soy.iko.opencode.ui.components.InfoHost
52+import soy.iko.opencode.ui.components.LoadingPlaceholder
53+import soy.iko.opencode.ui.theme.Dimens
54+import soy.iko.opencode.ui.theme.OpencodeTheme
7255
7356@OptIn(ExperimentalMaterial3Api::class)
7457@Composable
75−fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
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()
58+fun SessionsScreen(core: Core) {
59+ val view by core.view.collectAsState()
60+ val errorHostState = ErrorHost(
61+ core = core,
62+ dismissLabel = stringResource(R.string.action_dismiss),
63+ retryLabel = stringResource(R.string.action_retry),
64+ )
65+ val infoHostState = InfoHost(
66+ core = core,
67+ successConnectedLabel = stringResource(R.string.connect_success),
68+ successSessionCreatedLabel = stringResource(R.string.session_created),
69+ copiedLabel = stringResource(R.string.chat_copied),
70+ sessionDeletedLabel = stringResource(R.string.session_deleted),
71+ )
72+ val untitledLabel = stringResource(R.string.sessions_untitled)
73+ val idShortTemplate = stringResource(R.string.sessions_id_short)
74+ val deleteCdLabel = stringResource(R.string.sessions_cd_delete)
75+ val disconnectConfirmLabel = stringResource(R.string.sessions_disconnect_confirm)
76+ val disconnectYesLabel = stringResource(R.string.sessions_disconnect_confirm_yes)
8177
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− }
78+ // Track the session pending deletion (for confirmation dialog) and
79+ // whether the disconnect-from-server confirmation should show.
80+ var pendingDelete by remember { mutableStateOf<SessionView?>(null) }
81+ var showDisconnectConfirm by remember { mutableStateOf(false) }
11882
11983 Scaffold(
12084 topBar = {
12185 TopAppBar(
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− },
86+ title = { Text(stringResource(R.string.top_bar_sessions)) },
13987 navigationIcon = {
140− IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
88+ IconButton(onClick = { showDisconnectConfirm = true }) {
89+ Icon(
90+ Icons.AutoMirrored.Filled.ArrowBack,
91+ contentDescription = stringResource(R.string.sessions_cd_back),
92+ )
14293 }
14394 },
14495 actions = {
145− IconButton(onClick = { dispatch(Event.LoadSessions) }) {
146− Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
96+ IconButton(onClick = { core.update(Event.CreateSession) }) {
97+ Icon(
98+ Icons.Default.Add,
99+ contentDescription = stringResource(R.string.sessions_cd_new),
100+ )
147101 }
148102 },
149103 )
150104 },
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(
156− onClick = { dispatch(Event.CreateSession) },
157− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
158− text = { Text("New session") },
159− )
160− }
161− },
105+ snackbarHost = { DualSnackbarHost(errorHostState, infoHostState) },
162106 ) { padding ->
163− Column(Modifier.fillMaxSize().padding(padding)) {
164− if (!state.sseConnected) {
165− ReconnectingBanner()
166− }
167− if (state.loading && !isPullRefreshing) {
168− LinearProgressIndicator(Modifier.fillMaxWidth())
169− }
170− if (state.sessions.isNotEmpty()) {
171− OutlinedTextField(
172− value = searchQuery,
173− onValueChange = { searchQuery = it },
174− placeholder = { Text("Search sessions") },
175− singleLine = true,
176− modifier = Modifier
177− .fillMaxWidth()
178− .padding(horizontal = 16.dp, vertical = 8.dp),
179− )
180− }
181− val filteredSessions = if (searchQuery.isEmpty()) {
182− state.sessions
183− } else {
184− state.sessions.filter {
185− it.title.contains(searchQuery, ignoreCase = true) ||
186− it.id.contains(searchQuery, ignoreCase = true)
187− }
188− }
107+ Box(
108+ modifier = Modifier
109+ .fillMaxSize()
110+ .padding(padding),
111+ ) {
189112 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− ) {
201− LazyColumn(
202− state = sessionsListState,
113+ view.loading && view.sessions.isEmpty() -> {
114+ LoadingPlaceholder()
115+ }
116+ view.sessions.isEmpty() -> {
117+ EmptyState(message = stringResource(R.string.sessions_empty_hint))
118+ }
119+ else -> {
120+ PullToRefreshBox(
121+ isRefreshing = view.loading,
122+ onRefresh = { core.update(Event.LoadSessions) },
203123 modifier = Modifier.fillMaxSize(),
204− contentPadding = PaddingValues(bottom = 88.dp),
205124 ) {
206− items(filteredSessions, key = { it.id }) { session ->
207− // animateItem() slides the remaining rows into place when one is
208− // deleted (and fades new rows in) instead of snapping instantly.
209− Column(modifier = Modifier.animateItem()) {
210− val dismissState = rememberSwipeToDismissBoxState(
211− // Only a swipe from the end (right-to-left) triggers deletion;
212− // return false so the row snaps back and the confirm dialog decides.
213− confirmValueChange = { value ->
214− if (value == SwipeToDismissBoxValue.EndToStart) {
215− pendingDeleteId = session.id
216− }
217− false
218− },
125+ LazyColumn(
126+ contentPadding = PaddingValues(vertical = Dimens.listVerticalPadding),
127+ ) {
128+ items(view.sessions, key = { it.id }) { session ->
129+ SessionRow(
130+ session = session,
131+ untitledLabel = untitledLabel,
132+ idShortLabel = idShortTemplate.format(session.id.take(8)),
133+ deleteCdLabel = deleteCdLabel,
134+ onClick = { core.update(Event.SelectSession(session.id)) },
135+ onDelete = { pendingDelete = session },
219136 )
220− SwipeToDismissBox(
221− state = dismissState,
222− enableDismissFromStartToEnd = false,
223− backgroundContent = { SwipeDeleteBackground() },
224− ) {
225− SessionRow(
226− session = session,
227− onOpen = { dispatch(Event.SelectSession(session.id)) },
228− onDelete = { pendingDeleteId = session.id },
229− )
230− }
231− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
232137 }
233138 }
234139 }
@@ -236,167 +141,166 @@ fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
236141 }
237142 }
238143 }
144+
145+ // Delete confirmation dialog
146+ pendingDelete?.let { session ->
147+ AlertDialog(
148+ onDismissRequest = { pendingDelete = null },
149+ title = { Text(session.title.ifEmpty { untitledLabel }) },
150+ text = { Text(stringResource(R.string.sessions_delete_confirm)) },
151+ confirmButton = {
152+ TextButton(onClick = {
153+ core.update(Event.DeleteSession(session.id))
154+ pendingDelete = null
155+ }) {
156+ Text(
157+ text = stringResource(R.string.sessions_delete_confirm_yes),
158+ color = MaterialTheme.colorScheme.error,
159+ )
160+ }
161+ },
162+ dismissButton = {
163+ TextButton(onClick = { pendingDelete = null }) {
164+ Text(stringResource(R.string.crash_reports_cancel))
165+ }
166+ },
167+ )
168+ }
169+
170+ // Disconnect confirmation dialog — going back to Connect is destructive
171+ // (drops the session list and requires a full reconnect), so confirm.
172+ if (showDisconnectConfirm) {
173+ AlertDialog(
174+ onDismissRequest = { showDisconnectConfirm = false },
175+ text = { Text(disconnectConfirmLabel) },
176+ confirmButton = {
177+ TextButton(onClick = {
178+ core.update(Event.NavigateToConnect)
179+ showDisconnectConfirm = false
180+ }) {
181+ Text(disconnectYesLabel)
182+ }
183+ },
184+ dismissButton = {
185+ TextButton(onClick = { showDisconnectConfirm = false }) {
186+ Text(stringResource(R.string.crash_reports_cancel))
187+ }
188+ },
189+ )
190+ }
239191}
240192
241193@OptIn(ExperimentalFoundationApi::class)
242194@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)) {
195+private fun SessionRow(
196+ session: SessionView,
197+ untitledLabel: String,
198+ idShortLabel: String,
199+ deleteCdLabel: String,
200+ onClick: () -> Unit,
201+ onDelete: () -> Unit,
202+) {
203+ val title = session.title.ifEmpty { untitledLabel }
204+ ListItem(
205+ headlineContent = {
266206 Text(
267− text = session.title,
268− style = MaterialTheme.typography.bodyLarge,
207+ text = title,
269208 maxLines = 1,
270209 overflow = TextOverflow.Ellipsis,
271210 )
211+ },
212+ supportingContent = {
272213 Text(
273− text = shortSessionId(session.id),
274− style = MaterialTheme.typography.bodySmall,
275− color = MaterialTheme.colorScheme.onSurfaceVariant,
214+ text = idShortLabel,
276215 maxLines = 1,
277− overflow = TextOverflow.Ellipsis,
278216 )
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) {
217+ },
218+ leadingContent = {
307219 Icon(
308− Icons.Filled.DeleteOutline,
220+ Icons.Default.ChatBubble,
309221 contentDescription = null,
310− tint = MaterialTheme.colorScheme.onError,
222+ tint = MaterialTheme.colorScheme.primary,
311223 )
312− Text(
313− text = "Delete",
314− style = MaterialTheme.typography.labelLarge,
315− color = MaterialTheme.colorScheme.onError,
316− modifier = Modifier.padding(start = 4.dp),
224+ },
225+ trailingContent = {
226+ IconButton(onClick = onDelete) {
227+ Icon(
228+ Icons.Default.Delete,
229+ contentDescription = deleteCdLabel,
230+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
231+ )
232+ }
233+ },
234+ modifier = Modifier
235+ .combinedClickable(
236+ onClick = onClick,
237+ onLongClick = onDelete,
317238 )
318− }
319− }
239+ .semantics(mergeDescendants = true) {
240+ // Merge the row into a single TalkBack utterance and announce
241+ // it as a button so users know it's actionable.
242+ role = Role.Button
243+ contentDescription = title
244+ },
245+ )
320246}
321247
322−/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
248+@OptIn(ExperimentalMaterial3Api::class)
249+@Preview(showBackground = true)
250+@Preview(showBackground = true, uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES)
323251@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,
252+private fun SessionsScreenPreview() {
253+ OpencodeTheme {
254+ Scaffold(topBar = {
255+ TopAppBar(
256+ title = { Text("Sessions") },
257+ navigationIcon = {
258+ IconButton(onClick = {}) {
259+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
260+ }
261+ },
262+ actions = {
263+ IconButton(onClick = {}) {
264+ Icon(Icons.Default.Add, contentDescription = "New session")
265+ }
266+ },
343267 )
268+ }) { padding ->
269+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
270+ LazyColumn(
271+ contentPadding = PaddingValues(vertical = 12.dp),
272+ ) {
273+ items(
274+ listOf(
275+ SessionView("abc12345def", "Refactor the parser"),
276+ SessionView("xyz12345abc", ""),
277+ ),
278+ key = { it.id },
279+ ) { session ->
280+ SessionRow(
281+ session,
282+ untitledLabel = "Untitled",
283+ idShortLabel = "Session ${session.id.take(8)}",
284+ deleteCdLabel = "Delete session",
285+ onClick = {},
286+ onDelete = {},
287+ )
288+ }
289+ }
290+ }
344291 }
345292 }
346293}
347294
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−
295+@OptIn(ExperimentalMaterial3Api::class)
296+@Preview(showBackground = true)
371297@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− )
298+private fun SessionsScreenEmptyPreview() {
299+ OpencodeTheme {
300+ Scaffold(topBar = { TopAppBar(title = { Text("Sessions") }) }) { padding ->
301+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
302+ EmptyState(message = "No sessions yet")
303+ }
304+ }
401305 }
402306}
app/src/main/java/soy/iko/opencode/ui/theme/Color.ktdeleted−19
@@ -1,19 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.ui.graphics.Color
4−
5−// A calm, terminal-adjacent palette. Used when dynamic color isn't available.
6−val Emerald = Color(0xFF3DDC84)
7−val EmeraldDark = Color(0xFF1B8F52)
8−
9−val DarkBackground = Color(0xFF0F1115)
10−val DarkSurface = Color(0xFF171A21)
11−val DarkSurfaceVariant = Color(0xFF232833)
12−val DarkOnSurface = Color(0xFFE6E9EF)
13−val DarkOutline = Color(0xFF3A4150)
14−
15−val LightBackground = Color(0xFFF7F8FA)
16−val LightSurface = Color(0xFFFFFFFF)
17−val LightSurfaceVariant = Color(0xFFEBEDF2)
18−val LightOnSurface = Color(0xFF1A1C1F)
19−val LightOutline = Color(0xFFC4C9D4)
app/src/main/java/soy/iko/opencode/ui/theme/Dimens.ktnew file+53
@@ -0,0 +1,53 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.unit.dp
4+
5+/**
6+ * Centralized spacing/dimension tokens. Replaces the ~14 inline magic `dp`
7+ * values that were scattered across the screen files. New UI should pull from
8+ * here so spacing stays consistent and drift becomes visible.
9+ */
10+object Dimens {
11+ // Screen-edge padding
12+ val screenPadding = 24.dp
13+
14+ // List content padding (horizontal gutters + vertical breathing room)
15+ val listHorizontalPadding = 12.dp
16+ val listVerticalPadding = 12.dp
17+ val listItemSpacing = 8.dp
18+
19+ // Generic spacers
20+ val spaceTiny = 4.dp
21+ val spaceSmall = 8.dp
22+ val spaceMedium = 12.dp
23+ val spaceLarge = 16.dp
24+ val spaceXLarge = 24.dp
25+
26+ // Inline gaps (not large enough for a named "space" token but still shared)
27+ val gapTiny = 6.dp
28+
29+ // Bubble internals
30+ val bubbleHorizontalPadding = 14.dp
31+ val bubbleVerticalPadding = 10.dp
32+ val bubbleTailCorner = 4.dp
33+ val bubbleMaxWidthFraction = 0.82f
34+
35+ // Input bar
36+ val inputBarHorizontalPadding = 12.dp
37+ val inputBarVerticalPadding = 8.dp
38+ val inputBarTonalElevation = 3.dp
39+
40+ // Icons
41+ val iconHero = 72.dp
42+ val iconInputLeading = 16.dp
43+ val iconInlineSpinner = 14.dp
44+ val iconButtonSpinner = 20.dp
45+
46+ // Strokes
47+ val strokeThin = 2.dp
48+
49+ // Floating action button
50+ val fabSize = 40.dp
51+ val fabEndMargin = 16.dp
52+ val fabBottomMargin = 12.dp
53+}
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+33
@@ -0,0 +1,33 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.foundation.shape.RoundedCornerShape
4+import androidx.compose.material3.Shapes
5+import androidx.compose.ui.unit.dp
6+
7+/**
8+ * Centralized shape tokens. The chat-bubble shapes are defined here so the
9+ * user/assistant bubbles and the "generating" indicator all share one source
10+ * of truth instead of being hand-duplicated at each call site.
11+ */
12+val OpencodeShapes = Shapes(
13+ extraSmall = RoundedCornerShape(4.dp),
14+ small = RoundedCornerShape(8.dp),
15+ medium = RoundedCornerShape(12.dp),
16+ large = RoundedCornerShape(16.dp),
17+ extraLarge = RoundedCornerShape(28.dp),
18+)
19+
20+/** Tail-at-corner shapes for chat bubbles. */
21+val UserBubbleShape = RoundedCornerShape(
22+ topStart = 16.dp,
23+ topEnd = 16.dp,
24+ bottomStart = 16.dp,
25+ bottomEnd = 4.dp,
26+)
27+
28+val AssistantBubbleShape = RoundedCornerShape(
29+ topStart = 16.dp,
30+ topEnd = 16.dp,
31+ bottomStart = 4.dp,
32+ bottomEnd = 16.dp,
33+)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+30−46
@@ -11,66 +11,50 @@ import androidx.compose.runtime.Composable
1111import androidx.compose.ui.graphics.Color
1212import androidx.compose.ui.platform.LocalContext
1313
14−private val DarkColors = darkColorScheme(
15− primary = Emerald,
16− onPrimary = Color(0xFF0F1115),
17− primaryContainer = Color(0xFF1A3D2A),
18− onPrimaryContainer = Color(0xFFA8E6C1),
19− secondary = EmeraldDark,
20− onSecondary = Color(0xFFE6E9EF),
21− secondaryContainer = Color(0xFF1A2A3D),
22− onSecondaryContainer = Color(0xFFA8C5E6),
23− tertiary = Color(0xFF6B7280),
24− onTertiary = Color(0xFFE6E9EF),
25− tertiaryContainer = Color(0xFF2A2A3D),
26− onTertiaryContainer = Color(0xFFC5C5E6),
27− errorContainer = Color(0xFF4D2626),
28− onErrorContainer = Color(0xFFFFB4B4),
29− background = DarkBackground,
30− surface = DarkSurface,
31− surfaceVariant = DarkSurfaceVariant,
32− onSurface = DarkOnSurface,
33− outline = DarkOutline,
14+private val DarkColorScheme = darkColorScheme(
15+ primary = Color(0xFF82B1FF),
16+ secondary = Color(0xFF80CBC4),
17+ tertiary = Color(0xFFFFB74D),
18+ background = Color(0xFF121212),
19+ surface = Color(0xFF1E1E1E),
20+ onPrimary = Color.Black,
21+ onSecondary = Color.Black,
22+ onTertiary = Color(0xFF1A1A1A),
23+ onBackground = Color.White,
24+ onSurface = Color.White,
3425)
3526
36−private val LightColors = lightColorScheme(
37− primary = EmeraldDark,
38− primaryContainer = Color(0xFFD7F5E3),
39− onPrimaryContainer = Color(0xFF0A3D22),
40− secondary = Emerald,
41− onSecondary = Color(0xFF1A1C1F),
42− secondaryContainer = Color(0xFFD7E8F5),
43− onSecondaryContainer = Color(0xFF0A2A4D),
44− tertiary = Color(0xFF6B7280),
45− onTertiary = Color(0xFFFFFFFF),
46− tertiaryContainer = Color(0xFFE8E8F5),
47− onTertiaryContainer = Color(0xFF2A2A4D),
48− errorContainer = Color(0xFFFFE0E0),
49− onErrorContainer = Color(0xFF660000),
50− background = LightBackground,
51− surface = LightSurface,
52− surfaceVariant = LightSurfaceVariant,
53− onSurface = LightOnSurface,
54− outline = LightOutline,
27+private val LightColorScheme = lightColorScheme(
28+ primary = Color(0xFF1565C0),
29+ secondary = Color(0xFF00897B),
30+ tertiary = Color(0xFFE65100),
31+ background = Color(0xFFF5F5F5),
32+ surface = Color.White,
33+ onPrimary = Color.White,
34+ onSecondary = Color.White,
35+ onTertiary = Color.White,
36+ onBackground = Color.Black,
37+ onSurface = Color.Black,
5538)
5639
5740@Composable
5841fun OpencodeTheme(
5942 darkTheme: Boolean = isSystemInDarkTheme(),
60− dynamicColor: Boolean = false,
43+ dynamicColor: Boolean = true,
6144 content: @Composable () -> Unit,
6245) {
63− val context = LocalContext.current
6446 val colorScheme = when {
65− dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
47+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
48+ val context = LocalContext.current
6649 if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
67− darkTheme -> DarkColors
68− else -> LightColors
50+ }
51+ darkTheme -> DarkColorScheme
52+ else -> LightColorScheme
6953 }
70−
7154 MaterialTheme(
7255 colorScheme = colorScheme,
73− typography = AppTypography,
56+ typography = OpencodeTypography,
57+ shapes = OpencodeShapes,
7458 content = content,
7559 )
7660}
app/src/main/java/soy/iko/opencode/ui/theme/Type.kt+62−7
@@ -3,13 +3,68 @@ package soy.iko.opencode.ui.theme
33import androidx.compose.material3.Typography
44import androidx.compose.ui.text.TextStyle
55import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.text.font.FontWeight
67import androidx.compose.ui.unit.sp
78
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.
9+/**
10+ * Typography scale. Stays close to the Material 3 baseline but pins the values
11+ * so that font sizes, weights, and letter spacing don't silently shift when
12+ * the Compose BOM updates. The screens reference these via
13+ * [androidx.compose.material3.MaterialTheme.typography].
14+ */
15+val OpencodeTypography = Typography(
16+ headlineSmall = TextStyle(
17+ fontFamily = FontFamily.Default,
18+ fontWeight = FontWeight.SemiBold,
19+ fontSize = 24.sp,
20+ lineHeight = 32.sp,
21+ letterSpacing = 0.sp,
22+ ),
23+ titleLarge = TextStyle(
24+ fontFamily = FontFamily.Default,
25+ fontWeight = FontWeight.SemiBold,
26+ fontSize = 22.sp,
27+ lineHeight = 28.sp,
28+ ),
29+ bodyLarge = TextStyle(
30+ fontFamily = FontFamily.Default,
31+ fontWeight = FontWeight.Normal,
32+ fontSize = 16.sp,
33+ lineHeight = 24.sp,
34+ letterSpacing = 0.5.sp,
35+ ),
36+ bodyMedium = TextStyle(
37+ fontFamily = FontFamily.Default,
38+ fontWeight = FontWeight.Normal,
39+ fontSize = 14.sp,
40+ lineHeight = 20.sp,
41+ letterSpacing = 0.25.sp,
42+ ),
43+ bodySmall = TextStyle(
44+ fontFamily = FontFamily.Default,
45+ fontWeight = FontWeight.Normal,
46+ fontSize = 12.sp,
47+ lineHeight = 16.sp,
48+ ),
49+ labelLarge = TextStyle(
50+ fontFamily = FontFamily.Default,
51+ fontWeight = FontWeight.Medium,
52+ fontSize = 14.sp,
53+ lineHeight = 20.sp,
54+ letterSpacing = 0.1.sp,
55+ ),
56+ labelMedium = TextStyle(
57+ fontFamily = FontFamily.Default,
58+ fontWeight = FontWeight.Medium,
59+ fontSize = 12.sp,
60+ lineHeight = 16.sp,
61+ letterSpacing = 0.5.sp,
62+ ),
63+ labelSmall = TextStyle(
64+ fontFamily = FontFamily.Default,
65+ fontWeight = FontWeight.Medium,
66+ fontSize = 11.sp,
67+ lineHeight = 16.sp,
68+ letterSpacing = 0.5.sp,
69+ ),
1370)
14−
15−val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/res/values-night-v31/themes.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
5+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
6+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
7+ </style>
8+</resources>
app/src/main/res/values-night/colors.xml+1−1
@@ -1,4 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <color name="window_background">#0F1115</color>
3+ <color name="splash_background">#FF1E1E1E</color>
44</resources>
app/src/main/res/values-night/themes.xml+2−4
@@ -1,8 +1,6 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
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>
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
75 </style>
86</resources>
app/src/main/res/values-v31/themes.xmlnew file+12
@@ -0,0 +1,12 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
4+ <item name="android:windowBackground">@color/splash_background</item>
5+ <!-- Android 12+ system splash screen: use the window background color
6+ so the post-splash crossfade into the Compose UI is seamless. The
7+ Compose UI paints its own background, so we only set the poster
8+ color and a system icon here. -->
9+ <item name="android:windowSplashScreenBackground">@color/splash_background</item>
10+ <item name="android:windowSplashScreenAnimatedIcon">@android:drawable/ic_dialog_info</item>
11+ </style>
12+</resources>
app/src/main/res/values/colors.xml+1−1
@@ -1,4 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <color name="window_background">#F7F8FA</color>
3+ <color name="splash_background">#FFF5F5F5</color>
44</resources>
app/src/main/res/values/strings.xml+101−2
@@ -1,4 +1,103 @@
1−<?xml version="1.0" encoding="utf-8"?>
21<resources>
3− <string name="app_name">opencode</string>
2+ <string name="app_name">Opencode</string>
3+
4+ <!-- Top bar titles -->
5+ <string name="top_bar_connect">Opencode</string>
6+ <string name="top_bar_sessions">Sessions</string>
7+ <string name="top_bar_chat_fallback">Chat</string>
8+
9+ <!-- Connect screen -->
10+ <string name="connect_heading">Connect to Opencode Server</string>
11+ <string name="connect_field_server_url">Server URL</string>
12+ <string name="connect_field_username">Username</string>
13+ <string name="connect_field_password">Password</string>
14+ <string name="connect_auth_required">Server requires authentication</string>
15+ <string name="connect_password_hide">Hide password</string>
16+ <string name="connect_password_show">Show password</string>
17+ <string name="connect_button_cancel">Cancel</string>
18+ <string name="connect_button_connect">Connect</string>
19+ <string name="connect_success">Connected</string>
20+ <string name="connect_cd_dns">Connection icon</string>
21+ <string name="connect_cd_lock">Credentials icon</string>
22+ <string name="connect_cd_warning">Warning icon</string>
23+ <string name="connect_error_invalid_url">Enter a valid URL (http:// or https://)</string>
24+
25+ <!-- Crash log -->
26+ <string name="crash_reports_title">Crash Reports</string>
27+ <plurals name="crash_reports_view">
28+ <item quantity="one">%d crash report — view</item>
29+ <item quantity="other">%d crash reports — view</item>
30+ </plurals>
31+ <string name="crash_reports_clear">Clear all</string>
32+ <string name="crash_reports_close">Close</string>
33+ <string name="crash_reports_cancel">Cancel</string>
34+ <string name="crash_reports_clear_confirm">Clear all crash reports?</string>
35+ <string name="crash_reports_clear_confirm_yes">Clear</string>
36+
37+ <!-- Sessions screen -->
38+ <string name="sessions_empty_hint">No sessions yet. Tap + to create one.</string>
39+ <string name="sessions_cd_back">Change server</string>
40+ <string name="sessions_cd_new">New session</string>
41+ <string name="sessions_untitled">Untitled</string>
42+ <string name="sessions_id_short">Session %s</string>
43+ <string name="session_created">Session created</string>
44+
45+ <!-- Chat screen -->
46+ <string name="chat_empty_hint">Send a message below to start the conversation.</string>
47+ <string name="chat_no_content">(no content)</string>
48+ <string name="chat_input_placeholder">Message…</string>
49+ <string name="chat_cd_back">Back</string>
50+ <string name="chat_cd_send">Send</string>
51+ <string name="chat_role_user">User</string>
52+ <string name="chat_role_assistant">Assistant</string>
53+ <string name="chat_cd_generating">Generating reply</string>
54+ <string name="chat_generating_label">Thinking…</string>
55+ <string name="chat_cd_scroll_to_bottom">Scroll to latest message</string>
56+ <string name="chat_scroll_to_latest">Latest</string>
57+ <string name="chat_copied">Copied</string>
58+ <string name="chat_cd_stop">Stop generating</string>
59+ <string name="chat_session_id_short">Session %s</string>
60+ <string name="chat_menu_overflow">More options</string>
61+ <string name="chat_menu_refresh">Refresh messages</string>
62+
63+ <!-- Error / snackbar -->
64+ <string name="action_dismiss">Dismiss</string>
65+ <string name="action_retry">Retry</string>
66+
67+ <!-- SSE / connectivity -->
68+ <string name="sse_disconnected">Live stream disconnected</string>
69+ <string name="sse_reconnect">Reconnect</string>
70+ <string name="sse_dismiss">Dismiss</string>
71+ <string name="sse_connecting">Connecting live stream…</string>
72+ <string name="sse_live">Live</string>
73+ <string name="press_back_again_to_exit">Press back again to exit</string>
74+
75+ <!-- Loading / common -->
76+ <string name="loading">Loading</string>
77+ <string name="date_today">Today</string>
78+ <string name="date_yesterday">Yesterday</string>
79+ <string name="session_deleted">Session deleted</string>
80+
81+ <!-- Sessions screen: delete / disconnect -->
82+ <string name="sessions_cd_delete">Delete session</string>
83+ <string name="sessions_delete_confirm">Delete this session? This cannot be undone.</string>
84+ <string name="sessions_delete_confirm_yes">Delete</string>
85+ <string name="sessions_disconnect_confirm">Disconnect from the server?</string>
86+ <string name="sessions_disconnect_confirm_yes">Disconnect</string>
87+ <string name="sessions_cd_disconnect">Disconnect from server</string>
88+
89+ <!-- Chat: session ID copy / SSE status -->
90+ <string name="chat_cd_copy_session_id">Copy session ID</string>
91+ <string name="chat_session_id_copied">Session ID copied</string>
92+
93+ <!-- Chat: message status -->
94+ <string name="chat_message_status_pending">Sending</string>
95+ <string name="chat_message_status_failed">Failed to send</string>
96+ <string name="chat_cd_message_status_failed">Failed to send. Long-press to retry.</string>
97+
98+ <!-- Crash log dialog -->
99+ <string name="crash_reports_copy">Copy</string>
100+ <string name="crash_reports_share">Share</string>
101+ <string name="crash_reports_copied">Crash report copied</string>
102+ <string name="crash_reports_empty">No crash reports</string>
4103</resources>
app/src/main/res/values/themes.xml+7−9
@@ -1,13 +1,11 @@
11<?xml version="1.0" encoding="utf-8"?>
22<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− -->
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>
3+ <!-- Base theme for the Compose app. Uses a neutral NoActionBar parent so
4+ the legacy Material (Material1) widget styling doesn't clash with the
5+ Compose Material3 UI that paints on top. The window background matches
6+ the Compose surface color so the pre-Compose splash crossfade is
7+ seamless. -->
8+ <style name="Theme.Opencode" parent="android:Theme.DeviceDefault.Light.NoActionBar">
9+ <item name="android:windowBackground">@color/splash_background</item>
1210 </style>
1311</resources>
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktdeleted−69
@@ -1,69 +0,0 @@
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.ktnew file+284
@@ -0,0 +1,284 @@
1+package soy.iko.opencode.core
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertNotNull
6+import org.junit.Assert.assertNull
7+import org.junit.Assert.assertTrue
8+import org.junit.Test
9+class PureCoreFfiTest {
10+
11+ private fun createCore(): PureCoreFfi = PureCoreFfi()
12+
13+ private fun PureCoreFfi.currentView(): ViewModel =
14+ ViewModel.bincodeDeserialize(view())
15+
16+ private fun PureCoreFfi.processEvent(event: Event): Requests {
17+ val effects = update(event.bincodeSerialize())
18+ return Requests.bincodeDeserialize(effects)
19+ }
20+
21+ private fun PureCoreFfi.resolveHttp(
22+ requestId: UInt,
23+ status: Int = 200,
24+ body: String = "{}",
25+ ): Requests {
26+ val response = HttpResponse(
27+ status = status.toUShort(),
28+ headers = emptyList(),
29+ body = com.novi.serde.Bytes(body.toByteArray()),
30+ )
31+ val result = HttpResult.Ok(response)
32+ val effects = resolve(requestId, result.bincodeSerialize())
33+ return Requests.bincodeDeserialize(effects)
34+ }
35+
36+ @Test
37+ fun `start event sets default server URL`() {
38+ val core = createCore()
39+ core.processEvent(Event.Start)
40+ val view = core.currentView()
41+ assertEquals("http://localhost:4096", view.serverUrl)
42+ assertEquals(Screen.CONNECT, view.screen)
43+ }
44+
45+ @Test
46+ fun `server URL change updates view`() {
47+ val core = createCore()
48+ core.processEvent(Event.Start)
49+ core.processEvent(Event.ServerUrlChanged("http://10.0.0.1:4096"))
50+ val view = core.currentView()
51+ assertEquals("http://10.0.0.1:4096", view.serverUrl)
52+ }
53+
54+ @Test
55+ fun `connect emits HTTP effect`() {
56+ val core = createCore()
57+ core.processEvent(Event.Start)
58+ val requests = core.processEvent(Event.Connect)
59+ assertEquals(1, requests.value.size)
60+ assertTrue(requests.value[0].effect is Effect.Http)
61+ val view = core.currentView()
62+ assertTrue(view.loading)
63+ }
64+
65+ @Test
66+ fun `successful connect transitions to sessions screen`() {
67+ val core = createCore()
68+ core.processEvent(Event.Start)
69+ val requests = core.processEvent(Event.Connect)
70+ val httpReq = requests.value[0]
71+ assertEquals(Effect.Http::class, httpReq.effect::class)
72+
73+ // Simulate health check success — triggers LoadSessions
74+ val followUpRequests = core.resolveHttp(httpReq.id, body = """{"healthy":true}""")
75+ assertEquals(1, followUpRequests.value.size)
76+
77+ // Simulate sessions loaded
78+ val sessionsBody = """[{"id":"s1","title":"My Session"}]"""
79+ val finalRequests = core.resolveHttp(followUpRequests.value[0].id, body = sessionsBody)
80+ assertEquals(0, finalRequests.value.size)
81+
82+ val view = core.currentView()
83+ assertTrue(view.connected)
84+ assertEquals(Screen.SESSIONS, view.screen)
85+ assertEquals(1, view.sessions.size)
86+ assertEquals("s1", view.sessions[0].id)
87+ assertEquals("My Session", view.sessions[0].title)
88+ }
89+
90+ @Test
91+ fun `failed connect shows error`() {
92+ val core = createCore()
93+ core.processEvent(Event.Start)
94+ val requests = core.processEvent(Event.Connect)
95+
96+ val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
97+ val effects = core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
98+ Requests.bincodeDeserialize(effects)
99+
100+ val view = core.currentView()
101+ assertNotNull(view.error)
102+ assertFalse(view.loading)
103+ }
104+
105+ @Test
106+ fun `dismiss error clears error`() {
107+ val core = createCore()
108+ core.processEvent(Event.Start)
109+ val requests = core.processEvent(Event.Connect)
110+
111+ val errorResult = HttpResult.Err(HttpError.Io("Connection refused"))
112+ core.resolve(requests.value[0].id, errorResult.bincodeSerialize())
113+
114+ core.processEvent(Event.DismissError)
115+ val view = core.currentView()
116+ assertNull(view.error)
117+ }
118+
119+ @Test
120+ fun `select session loads messages and transitions to chat`() {
121+ val core = createCore()
122+ core.processEvent(Event.Start)
123+ val connectReqs = core.processEvent(Event.Connect)
124+ core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
125+ core.resolveHttp(0u, body = "[]")
126+
127+ val selectReqs = core.processEvent(Event.SelectSession("session-123"))
128+ assertTrue(selectReqs.value.isNotEmpty())
129+
130+ val view = core.currentView()
131+ assertEquals("session-123", view.currentSessionId)
132+ assertEquals(Screen.CHAT, view.screen)
133+ }
134+
135+ @Test
136+ fun `401 response sets auth_required in view`() {
137+ val core = createCore()
138+ core.processEvent(Event.Start)
139+ val requests = core.processEvent(Event.Connect)
140+
141+ // Simulate 401 — server behind basic auth
142+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
143+
144+ val view = core.currentView()
145+ assertTrue(view.authRequired)
146+ assertFalse(view.loading)
147+ assertEquals(Screen.CONNECT, view.screen)
148+ }
149+
150+ @Test
151+ fun `401 with credentials shows invalid credentials error`() {
152+ val core = createCore()
153+ core.processEvent(Event.Start)
154+ core.processEvent(Event.UsernameChanged("opencode"))
155+ core.processEvent(Event.PasswordChanged("wrong"))
156+ val requests = core.processEvent(Event.Connect)
157+
158+ // Simulate 401 despite credentials — invalid credentials
159+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
160+
161+ val view = core.currentView()
162+ assertNotNull(view.error)
163+ assertTrue(view.error!!.contains("Invalid credentials"))
164+ assertFalse(view.authRequired)
165+ }
166+
167+ @Test
168+ fun `cancel auth clears auth_required and credentials`() {
169+ val core = createCore()
170+ core.processEvent(Event.Start)
171+ val requests = core.processEvent(Event.Connect)
172+ core.resolveHttp(requests.value[0].id, status = 401, body = "Unauthorized")
173+
174+ core.processEvent(Event.CancelAuth)
175+ val view = core.currentView()
176+ assertFalse(view.authRequired)
177+ assertEquals("", view.username)
178+ assertEquals("", view.password)
179+ }
180+
181+ @Test
182+ fun `successful connect with credentials marks authed`() {
183+ val core = createCore()
184+ core.processEvent(Event.Start)
185+ core.processEvent(Event.UsernameChanged("opencode"))
186+ core.processEvent(Event.PasswordChanged("secret"))
187+ val requests = core.processEvent(Event.Connect)
188+
189+ // Health check with credentials succeeds
190+ val followUp = core.resolveHttp(requests.value[0].id, body = """{"healthy":true}""")
191+ assertEquals(1, followUp.value.size)
192+
193+ val sessionsBody = """[{"id":"s1","title":"Authed Session"}]"""
194+ core.resolveHttp(followUp.value[0].id, body = sessionsBody)
195+
196+ val view = core.currentView()
197+ assertTrue(view.connected)
198+ assertEquals(Screen.SESSIONS, view.screen)
199+ }
200+
201+ @Test
202+ fun `parseMessages extracts text from parts array`() {
203+ val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello world"}]}]"""
204+ val messages = parseMessages(json)
205+ assertEquals(1, messages.size)
206+ assertEquals("m1", messages[0].id)
207+ assertEquals("user", messages[0].role)
208+ assertEquals("Hello world", messages[0].text)
209+ assertEquals(100uL, messages[0].time)
210+ }
211+
212+ @Test
213+ fun `parseMessages falls back to top-level text field`() {
214+ val json = """[{"id":"m2","role":"assistant","created":200,"text":"Fallback"}]"""
215+ val messages = parseMessages(json)
216+ assertEquals(1, messages.size)
217+ assertEquals("Fallback", messages[0].text)
218+ }
219+
220+ @Test
221+ fun `extractJsonString returns null for missing key`() {
222+ assertNull(extractJsonString("""{"a":"b"}""", "id"))
223+ }
224+
225+ @Test
226+ fun `extractJsonNumber returns null for missing key`() {
227+ assertNull(extractJsonNumber("""{"a":"b"}""", "created"))
228+ }
229+
230+ @Test
231+ fun `draft changed updates view`() {
232+ val core = createCore()
233+ core.processEvent(Event.Start)
234+ core.processEvent(Event.DraftChanged("in progress"))
235+ val view = core.currentView()
236+ assertEquals("in progress", view.draftMessage)
237+ }
238+
239+ @Test
240+ fun `cancel generation clears generating flag`() {
241+ val core = createCore()
242+ core.processEvent(Event.Start)
243+ // Simulate connected + session selected
244+ val connectReqs = core.processEvent(Event.Connect)
245+ val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
246+ core.resolveHttp(loadSessionsReqs.value[0].id, body = "[]")
247+ val selectReqs = core.processEvent(Event.SelectSession("session-123"))
248+ core.resolveHttp(selectReqs.value[0].id, body = "[]")
249+
250+ // CancelGeneration is a no-op when not generating, but shouldn't crash
251+ core.processEvent(Event.CancelGeneration)
252+ val view = core.currentView()
253+ assertFalse(view.generating)
254+ }
255+
256+ @Test
257+ fun `delete session removes from list and emits http effect`() {
258+ val core = createCore()
259+ core.processEvent(Event.Start)
260+ val connectReqs = core.processEvent(Event.Connect)
261+ // Health check success → triggers LoadSessions
262+ val loadSessionsReqs = core.resolveHttp(connectReqs.value[0].id, body = """{"healthy":true}""")
263+ // Sessions loaded
264+ val sessionsBody = """[{"id":"s1","title":"First"},{"id":"s2","title":"Second"}]"""
265+ core.resolveHttp(loadSessionsReqs.value[0].id, body = sessionsBody)
266+
267+ val viewBefore = core.currentView()
268+ assertEquals(2, viewBefore.sessions.size)
269+
270+ val deleteReqs = core.processEvent(Event.DeleteSession("s1"))
271+ assertTrue(deleteReqs.value.isNotEmpty())
272+ val viewAfter = core.currentView()
273+ assertEquals(1, viewAfter.sessions.size)
274+ assertEquals("s2", viewAfter.sessions[0].id)
275+ }
276+
277+ @Test
278+ fun `parseMessages sets status to sent`() {
279+ val json = """[{"id":"m1","role":"user","created":100,"parts":[{"type":"text","text":"Hello"}]}]"""
280+ val messages = parseMessages(json)
281+ assertEquals(1, messages.size)
282+ assertEquals(MessageStatus.SENT, messages[0].status)
283+ }
284+}
build.gradle.kts+1
@@ -2,5 +2,6 @@ plugins {
22 alias(libs.plugins.android.application) apply false
33 alias(libs.plugins.kotlin.android) apply false
44 alias(libs.plugins.kotlin.compose) apply false
5+ alias(libs.plugins.android.library) apply false
56 alias(libs.plugins.kotlin.serialization) apply false
67}
flake.nix+17−28
@@ -1,5 +1,5 @@
11{
2− description = "Dev environment for opencode-android — a Crux (Rust) core + Kotlin / Jetpack Compose shell";
2+ description = "Dev environment for opencode-android (Crux Rust core + Kotlin / Jetpack Compose shell)";
33
44 inputs = {
55 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -9,17 +9,14 @@
99 outputs = { self, nixpkgs, flake-utils }:
1010 flake-utils.lib.eachDefaultSystem (system:
1111 let
12− # Kept in sync with the Gradle project (see gradle/libs.versions.toml and
13− # app/build.gradle.kts). These are the versions actually provisioned below,
14− # so `compileSdk` / `buildToolsVersion` must match or Gradle will try to
15− # download SDK components at build time.
16− platformVersion = "35";
17− buildToolsVersion = "35.0.0";
12+ # Versions kept in sync with the Gradle project (compileSdk / build-tools).
13+ platformVersion = "36";
14+ buildToolsVersion = "36.0.0";
1815
1916 pkgs = import nixpkgs {
2017 inherit system;
2118 config = {
22− allowUnfree = true; # the Android SDK is unfree
19+ allowUnfree = true; # Android SDK is unfree
2320 android_sdk.accept_license = true;
2421 };
2522 };
@@ -31,50 +28,42 @@
3128
3229 includeEmulator = false;
3330 includeSystemImages = false;
31+ systemImageTypes = [ "google_apis" ];
32+ abiVersions = [ "x86_64" ];
3433
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.
4034 includeNDK = false;
4135 };
4236
43− sdk = android.androidsdk;
44− sdkRoot = "${sdk}/libexec/android-sdk";
37+ sdk = android.androidsdk;
38+ sdkRoot = "${sdk}/libexec/android-sdk";
39+
4540 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
4641 in {
4742 devShells.default = pkgs.mkShell {
4843 buildInputs = [
49− # JVM / Android toolchain
5044 pkgs.jdk17
5145 pkgs.gradle
52− sdk
53−
54− # Rust toolchain for the Crux core (build, test, typegen codegen)
5546 pkgs.rustc
5647 pkgs.cargo
48+ sdk
5749 ];
5850
5951 ANDROID_HOME = sdkRoot;
6052 ANDROID_SDK_ROOT = sdkRoot;
6153 JAVA_HOME = "${pkgs.jdk17}";
6254
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.
6555 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
6656
6757 shellHook = ''
6858 echo "opencode-android dev shell"
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}')"
59+ echo " JDK: $(java -version 2>&1 | head -1)"
7260 echo " ANDROID_HOME: $ANDROID_HOME"
61+ echo " aapt2: ${aapt2Path}"
62+ echo " Rust: $(rustc --version)"
7363 echo ""
74− echo " Core: (cd shared && cargo test) # Crux core unit tests"
75− echo " Types: (cd shared && cargo run --bin codegen)"
76− echo " App: ./gradlew assembleDebug # build debug APK"
77− echo " Unit: ./gradlew testDebugUnitTest"
64+ echo "Build: ./gradlew assembleDebug"
65+ echo "Core: cd shared && cargo test"
66+ echo "Device: adb devices"
7867 '';
7968 };
8069 });
generated/build.gradle.ktsnew file+21
@@ -0,0 +1,21 @@
1+plugins {
2+ kotlin("jvm") version "2.2.0"
3+ kotlin("plugin.serialization") version "2.2.0"
4+ `java-library`
5+}
6+
7+group = "soy.iko.opencode.core"
8+version = "1.0.0"
9+
10+repositories {
11+ mavenCentral()
12+}
13+
14+dependencies {}
15+
16+tasks.withType<Jar> {
17+ manifest {
18+ attributes["Implementation-Title"] = "soy.iko.opencode.core"
19+ attributes["Implementation-Version"] = "1.0.0"
20+ }
21+}
generated/com/novi/bincode/BincodeDeserializer.javadeleted−38
@@ -1,38 +0,0 @@
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.ktnew file+39
@@ -0,0 +1,39 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode
5+
6+import com.novi.serde.BinaryDeserializer
7+import com.novi.serde.DeserializationError
8+import com.novi.serde.Slice
9+
10+class BincodeDeserializer(input: ByteArray) : BinaryDeserializer(input, Long.MAX_VALUE) {
11+ @Throws(DeserializationError::class)
12+ override fun deserialize_f32(): Float {
13+ return Float.fromBits(getInt())
14+ }
15+
16+ @Throws(DeserializationError::class)
17+ override fun deserialize_f64(): Double {
18+ return Double.fromBits(getLong())
19+ }
20+
21+ @Throws(DeserializationError::class)
22+ override fun deserialize_len(): Long {
23+ val value = getLong()
24+ if (value < 0 || value > Int.MAX_VALUE.toLong()) {
25+ throw DeserializationError("Incorrect length value")
26+ }
27+ return value
28+ }
29+
30+ @Throws(DeserializationError::class)
31+ override fun deserialize_variant_index(): Int {
32+ return getInt()
33+ }
34+
35+ @Throws(DeserializationError::class)
36+ override fun check_that_key_slices_are_increasing(key1: Slice, key2: Slice) {
37+ // Not required by the format.
38+ }
39+}
generated/com/novi/bincode/BincodeSerializer.javadeleted−33
@@ -1,33 +0,0 @@
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.ktnew file+33
@@ -0,0 +1,33 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode
5+
6+import com.novi.serde.BinarySerializer
7+import com.novi.serde.SerializationError
8+
9+class BincodeSerializer : BinarySerializer(Long.MAX_VALUE) {
10+ @Throws(SerializationError::class)
11+ override fun serialize_f32(value: Float) {
12+ serialize_i32(value.toRawBits())
13+ }
14+
15+ @Throws(SerializationError::class)
16+ override fun serialize_f64(value: Double) {
17+ serialize_i64(value.toRawBits())
18+ }
19+
20+ @Throws(SerializationError::class)
21+ override fun serialize_len(value: Long) {
22+ serialize_u64(value.toULong())
23+ }
24+
25+ @Throws(SerializationError::class)
26+ override fun serialize_variant_index(value: Int) {
27+ serialize_u32(value.toUInt())
28+ }
29+
30+ override fun sort_map_entries(offsets: IntArray) {
31+ // Not required by the format.
32+ }
33+}
generated/com/novi/serde/ArrayLen.javadeleted−12
@@ -1,12 +0,0 @@
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.javadeleted−195
@@ -1,195 +0,0 @@
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.ktnew file+182
@@ -0,0 +1,182 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+abstract class BinaryDeserializer(
7+ protected val input: ByteArray,
8+ maxContainerDepth: Long
9+) : Deserializer {
10+ private var position: Int = 0
11+ private var containerDepthBudget: Long = maxContainerDepth
12+
13+ @Throws(DeserializationError::class)
14+ override fun increase_container_depth() {
15+ if (containerDepthBudget == 0L) {
16+ throw DeserializationError("Exceeded maximum container depth")
17+ }
18+ containerDepthBudget -= 1
19+ }
20+
21+ override fun decrease_container_depth() {
22+ containerDepthBudget += 1
23+ }
24+
25+ @Throws(DeserializationError::class)
26+ override fun deserialize_str(): String {
27+ val len = deserialize_len()
28+ if (len < 0 || len > Int.MAX_VALUE.toLong()) {
29+ throw DeserializationError("Incorrect length value for Kotlin string")
30+ }
31+ val content = readBytes(len.toInt())
32+ return try {
33+ content.decodeToString(throwOnInvalidSequence = true)
34+ } catch (e: Throwable) {
35+ throw DeserializationError("Incorrect UTF8 string")
36+ }
37+ }
38+
39+ @Throws(DeserializationError::class)
40+ override fun deserialize_bytes(): Bytes {
41+ val len = deserialize_len()
42+ if (len < 0 || len > Int.MAX_VALUE.toLong()) {
43+ throw DeserializationError("Incorrect length value for Kotlin array")
44+ }
45+ return Bytes(readBytes(len.toInt()))
46+ }
47+
48+ @Throws(DeserializationError::class)
49+ override fun deserialize_bool(): Boolean {
50+ val value = getByte()
51+ return when (value.toInt()) {
52+ 0 -> false
53+ 1 -> true
54+ else -> throw DeserializationError("Incorrect boolean value")
55+ }
56+ }
57+
58+ @Throws(DeserializationError::class)
59+ override fun deserialize_unit(): Unit {
60+ return Unit
61+ }
62+
63+ @Throws(DeserializationError::class)
64+ override fun deserialize_char(): Char {
65+ throw DeserializationError("Not implemented: deserialize_char")
66+ }
67+
68+ @Throws(DeserializationError::class)
69+ override fun deserialize_u8(): UByte {
70+ return getByte().toUByte()
71+ }
72+
73+ @Throws(DeserializationError::class)
74+ override fun deserialize_u16(): UShort {
75+ val b0 = getByte().toInt() and 0xff
76+ val b1 = getByte().toInt() and 0xff
77+ return (b0 or (b1 shl 8)).toUShort()
78+ }
79+
80+ @Throws(DeserializationError::class)
81+ override fun deserialize_u32(): UInt {
82+ val b0 = getByte().toInt() and 0xff
83+ val b1 = getByte().toInt() and 0xff
84+ val b2 = getByte().toInt() and 0xff
85+ val b3 = getByte().toInt() and 0xff
86+ return (b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)).toUInt()
87+ }
88+
89+ @Throws(DeserializationError::class)
90+ override fun deserialize_u64(): ULong {
91+ var value = 0uL
92+ for (shift in 0 until 64 step 8) {
93+ val byteValue = getByte().toULong() and 0xffuL
94+ value = value or (byteValue shl shift)
95+ }
96+ return value
97+ }
98+
99+ @Throws(DeserializationError::class)
100+ override fun deserialize_u128(): UInt128 {
101+ val low = deserialize_u64()
102+ val high = deserialize_u64()
103+ return UInt128(high = high, low = low)
104+ }
105+
106+ @Throws(DeserializationError::class)
107+ override fun deserialize_i8(): Byte {
108+ return getByte()
109+ }
110+
111+ @Throws(DeserializationError::class)
112+ override fun deserialize_i16(): Short {
113+ return deserialize_u16().toShort()
114+ }
115+
116+ @Throws(DeserializationError::class)
117+ override fun deserialize_i32(): Int {
118+ return deserialize_u32().toInt()
119+ }
120+
121+ @Throws(DeserializationError::class)
122+ override fun deserialize_i64(): Long {
123+ return deserialize_u64().toLong()
124+ }
125+
126+ @Throws(DeserializationError::class)
127+ override fun deserialize_i128(): Int128 {
128+ val low = deserialize_u64()
129+ val high = deserialize_i64()
130+ return Int128(high = high, low = low)
131+ }
132+
133+ @Throws(DeserializationError::class)
134+ override fun deserialize_option_tag(): Boolean {
135+ return deserialize_bool()
136+ }
137+
138+ override fun get_buffer_offset(): Int {
139+ return position
140+ }
141+
142+ protected fun getInt(): Int {
143+ val b0 = getByte().toInt() and 0xff
144+ val b1 = getByte().toInt() and 0xff
145+ val b2 = getByte().toInt() and 0xff
146+ val b3 = getByte().toInt() and 0xff
147+ return b0 or (b1 shl 8) or (b2 shl 16) or (b3 shl 24)
148+ }
149+
150+ protected fun getLong(): Long {
151+ var value = 0L
152+ for (shift in 0 until 64 step 8) {
153+ val byteValue = getByte().toLong() and 0xffL
154+ value = value or (byteValue shl shift)
155+ }
156+ return value
157+ }
158+
159+ protected fun getByte(): Byte {
160+ requireAvailable(1)
161+ val value = input[position]
162+ position += 1
163+ return value
164+ }
165+
166+ private fun readBytes(count: Int): ByteArray {
167+ requireAvailable(count)
168+ val slice = input.copyOfRange(position, position + count)
169+ position += count
170+ return slice
171+ }
172+
173+ private fun requireAvailable(count: Int) {
174+ if (position + count > input.size) {
175+ throw DeserializationError(INPUT_NOT_LARGE_ENOUGH)
176+ }
177+ }
178+
179+ companion object {
180+ private const val INPUT_NOT_LARGE_ENOUGH = "Input is not large enough"
181+ }
182+}
generated/com/novi/serde/BinarySerializer.javadeleted−146
@@ -1,146 +0,0 @@
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.ktnew file+122
@@ -0,0 +1,122 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+abstract class BinarySerializer(maxContainerDepth: Long) : Serializer {
7+ protected val output: SerdeByteArrayOutput = SerdeByteArrayOutput()
8+ private var containerDepthBudget: Long = maxContainerDepth
9+
10+ @Throws(SerializationError::class)
11+ override fun increase_container_depth() {
12+ if (containerDepthBudget == 0L) {
13+ throw SerializationError("Exceeded maximum container depth")
14+ }
15+ containerDepthBudget -= 1
16+ }
17+
18+ override fun decrease_container_depth() {
19+ containerDepthBudget += 1
20+ }
21+
22+ @Throws(SerializationError::class)
23+ override fun serialize_str(value: String) {
24+ serialize_bytes(Bytes(value.encodeToByteArray()))
25+ }
26+
27+ @Throws(SerializationError::class)
28+ override fun serialize_bytes(value: Bytes) {
29+ serialize_len(value.content.size.toLong())
30+ output.writeBytes(value.content, 0, value.content.size)
31+ }
32+
33+ @Throws(SerializationError::class)
34+ override fun serialize_bool(value: Boolean) {
35+ output.writeByte(if (value) 1.toByte() else 0.toByte())
36+ }
37+
38+ @Throws(SerializationError::class)
39+ override fun serialize_unit(value: Unit) {
40+ // Nothing to serialize.
41+ }
42+
43+ @Throws(SerializationError::class)
44+ override fun serialize_char(value: Char) {
45+ throw SerializationError("Not implemented: serialize_char")
46+ }
47+
48+ @Throws(SerializationError::class)
49+ override fun serialize_u8(value: UByte) {
50+ output.writeByte(value.toByte())
51+ }
52+
53+ @Throws(SerializationError::class)
54+ override fun serialize_u16(value: UShort) {
55+ val v = value.toInt()
56+ output.writeByte((v and 0xff).toByte())
57+ output.writeByte(((v ushr 8) and 0xff).toByte())
58+ }
59+
60+ @Throws(SerializationError::class)
61+ override fun serialize_u32(value: UInt) {
62+ val v = value.toInt()
63+ output.writeByte((v and 0xff).toByte())
64+ output.writeByte(((v ushr 8) and 0xff).toByte())
65+ output.writeByte(((v ushr 16) and 0xff).toByte())
66+ output.writeByte(((v ushr 24) and 0xff).toByte())
67+ }
68+
69+ @Throws(SerializationError::class)
70+ override fun serialize_u64(value: ULong) {
71+ var v = value
72+ for (i in 0 until 8) {
73+ output.writeByte((v and 0xffuL).toByte())
74+ v = v shr 8
75+ }
76+ }
77+
78+ @Throws(SerializationError::class)
79+ override fun serialize_u128(value: UInt128) {
80+ serialize_u64(value.low)
81+ serialize_u64(value.high)
82+ }
83+
84+ @Throws(SerializationError::class)
85+ override fun serialize_i8(value: Byte) {
86+ serialize_u8(value.toUByte())
87+ }
88+
89+ @Throws(SerializationError::class)
90+ override fun serialize_i16(value: Short) {
91+ serialize_u16(value.toUShort())
92+ }
93+
94+ @Throws(SerializationError::class)
95+ override fun serialize_i32(value: Int) {
96+ serialize_u32(value.toUInt())
97+ }
98+
99+ @Throws(SerializationError::class)
100+ override fun serialize_i64(value: Long) {
101+ serialize_u64(value.toULong())
102+ }
103+
104+ @Throws(SerializationError::class)
105+ override fun serialize_i128(value: Int128) {
106+ serialize_u64(value.low)
107+ serialize_i64(value.high)
108+ }
109+
110+ @Throws(SerializationError::class)
111+ override fun serialize_option_tag(value: Boolean) {
112+ output.writeByte(if (value) 1.toByte() else 0.toByte())
113+ }
114+
115+ override fun get_buffer_offset(): Int {
116+ return output.size()
117+ }
118+
119+ override fun get_bytes(): ByteArray {
120+ return output.toByteArray()
121+ }
122+}
generated/com/novi/serde/Bytes.javadeleted−54
@@ -1,54 +0,0 @@
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.ktnew file+39
@@ -0,0 +1,39 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+/**
7+ * Inline value class wrapper around ByteArray.
8+ *
9+ * Provides proper value semantics for `equals` and `hashCode` using
10+ * structural equality (contentEquals/contentHashCode) instead of
11+ * referential equality.
12+ *
13+ * As a value class, this wrapper has zero runtime overhead - it's
14+ * inlined at compile time and compiles down to ByteArray at runtime.
15+ */
16+@JvmInline
17+value class Bytes(val content: ByteArray) {
18+ override fun toString(): String = content.contentToString()
19+
20+ companion object {
21+ fun empty(): Bytes = Bytes(ByteArray(0))
22+
23+ fun valueOf(content: ByteArray): Bytes = Bytes(content)
24+ }
25+}
26+
27+/**
28+ * Extension function for structural equality comparison.
29+ * Required because value classes don't support overriding equals.
30+ */
31+fun Bytes.contentEquals(other: Bytes): Boolean =
32+ this.content.contentEquals(other.content)
33+
34+/**
35+ * Extension function for structural hash code.
36+ * Required because value classes don't support overriding hashCode.
37+ */
38+fun Bytes.contentHashCode(): Int =
39+ content.contentHashCode()
generated/com/novi/serde/DeserializationError.javadeleted−11
@@ -1,11 +0,0 @@
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.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class DeserializationError(message: String) : Exception(message)
generated/com/novi/serde/Deserializer.javadeleted−56
@@ -1,56 +0,0 @@
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.ktnew file+76
@@ -0,0 +1,76 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+interface Deserializer {
7+ @Throws(DeserializationError::class)
8+ fun deserialize_str(): String
9+
10+ @Throws(DeserializationError::class)
11+ fun deserialize_bytes(): Bytes
12+
13+ @Throws(DeserializationError::class)
14+ fun deserialize_bool(): Boolean
15+
16+ @Throws(DeserializationError::class)
17+ fun deserialize_unit(): Unit
18+
19+ @Throws(DeserializationError::class)
20+ fun deserialize_char(): Char
21+
22+ @Throws(DeserializationError::class)
23+ fun deserialize_f32(): Float
24+
25+ @Throws(DeserializationError::class)
26+ fun deserialize_f64(): Double
27+
28+ @Throws(DeserializationError::class)
29+ fun deserialize_u8(): UByte
30+
31+ @Throws(DeserializationError::class)
32+ fun deserialize_u16(): UShort
33+
34+ @Throws(DeserializationError::class)
35+ fun deserialize_u32(): UInt
36+
37+ @Throws(DeserializationError::class)
38+ fun deserialize_u64(): ULong
39+
40+ @Throws(DeserializationError::class)
41+ fun deserialize_u128(): UInt128
42+
43+ @Throws(DeserializationError::class)
44+ fun deserialize_i8(): Byte
45+
46+ @Throws(DeserializationError::class)
47+ fun deserialize_i16(): Short
48+
49+ @Throws(DeserializationError::class)
50+ fun deserialize_i32(): Int
51+
52+ @Throws(DeserializationError::class)
53+ fun deserialize_i64(): Long
54+
55+ @Throws(DeserializationError::class)
56+ fun deserialize_i128(): Int128
57+
58+ @Throws(DeserializationError::class)
59+ fun deserialize_len(): Long
60+
61+ @Throws(DeserializationError::class)
62+ fun deserialize_variant_index(): Int
63+
64+ @Throws(DeserializationError::class)
65+ fun deserialize_option_tag(): Boolean
66+
67+ @Throws(DeserializationError::class)
68+ fun increase_container_depth()
69+
70+ fun decrease_container_depth()
71+
72+ fun get_buffer_offset(): Int
73+
74+ @Throws(DeserializationError::class)
75+ fun check_that_key_slices_are_increasing(key1: Slice, key2: Slice)
76+}
generated/com/novi/serde/Int128.javadeleted−11
@@ -1,11 +0,0 @@
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.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Int128(val high: Long, val low: ULong)
generated/com/novi/serde/SerdeByteArrayOutput.ktnew file+48
@@ -0,0 +1,48 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class SerdeByteArrayOutput(initialCapacity: Int = 32) {
7+ private var buffer: ByteArray = ByteArray(initialCapacity)
8+ private var length: Int = 0
9+
10+ fun writeByte(value: Byte) {
11+ ensureCapacity(1)
12+ buffer[length] = value
13+ length += 1
14+ }
15+
16+ fun writeBytes(value: ByteArray, offset: Int, count: Int) {
17+ if (count == 0) {
18+ return
19+ }
20+ ensureCapacity(count)
21+ value.copyInto(buffer, destinationOffset = length, startIndex = offset, endIndex = offset + count)
22+ length += count
23+ }
24+
25+ fun size(): Int {
26+ return length
27+ }
28+
29+ fun toByteArray(): ByteArray {
30+ return buffer.copyOf(length)
31+ }
32+
33+ fun getBuffer(): ByteArray {
34+ return buffer
35+ }
36+
37+ private fun ensureCapacity(extra: Int) {
38+ val required = length + extra
39+ if (required <= buffer.size) {
40+ return
41+ }
42+ var newSize = buffer.size
43+ while (newSize < required) {
44+ newSize = newSize * 2
45+ }
46+ buffer = buffer.copyOf(newSize)
47+ }
48+}
generated/com/novi/serde/SerializationError.javadeleted−11
@@ -1,11 +0,0 @@
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.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+class SerializationError(message: String) : Exception(message)
generated/com/novi/serde/Serializer.javadeleted−58
@@ -1,58 +0,0 @@
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.ktnew file+77
@@ -0,0 +1,77 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+interface Serializer {
7+ @Throws(SerializationError::class)
8+ fun serialize_str(value: String)
9+
10+ @Throws(SerializationError::class)
11+ fun serialize_bytes(value: Bytes)
12+
13+ @Throws(SerializationError::class)
14+ fun serialize_bool(value: Boolean)
15+
16+ @Throws(SerializationError::class)
17+ fun serialize_unit(value: Unit)
18+
19+ @Throws(SerializationError::class)
20+ fun serialize_char(value: Char)
21+
22+ @Throws(SerializationError::class)
23+ fun serialize_f32(value: Float)
24+
25+ @Throws(SerializationError::class)
26+ fun serialize_f64(value: Double)
27+
28+ @Throws(SerializationError::class)
29+ fun serialize_u8(value: UByte)
30+
31+ @Throws(SerializationError::class)
32+ fun serialize_u16(value: UShort)
33+
34+ @Throws(SerializationError::class)
35+ fun serialize_u32(value: UInt)
36+
37+ @Throws(SerializationError::class)
38+ fun serialize_u64(value: ULong)
39+
40+ @Throws(SerializationError::class)
41+ fun serialize_u128(value: UInt128)
42+
43+ @Throws(SerializationError::class)
44+ fun serialize_i8(value: Byte)
45+
46+ @Throws(SerializationError::class)
47+ fun serialize_i16(value: Short)
48+
49+ @Throws(SerializationError::class)
50+ fun serialize_i32(value: Int)
51+
52+ @Throws(SerializationError::class)
53+ fun serialize_i64(value: Long)
54+
55+ @Throws(SerializationError::class)
56+ fun serialize_i128(value: Int128)
57+
58+ @Throws(SerializationError::class)
59+ fun serialize_len(value: Long)
60+
61+ @Throws(SerializationError::class)
62+ fun serialize_variant_index(value: Int)
63+
64+ @Throws(SerializationError::class)
65+ fun serialize_option_tag(value: Boolean)
66+
67+ @Throws(SerializationError::class)
68+ fun increase_container_depth()
69+
70+ fun decrease_container_depth()
71+
72+ fun get_buffer_offset(): Int
73+
74+ fun sort_map_entries(offsets: IntArray)
75+
76+ fun get_bytes(): ByteArray
77+}
generated/com/novi/serde/Slice.javadeleted−40
@@ -1,40 +0,0 @@
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.ktnew file+36
@@ -0,0 +1,36 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Slice(val start: Int, val end: Int) {
7+ companion object {
8+ fun compare_bytes(content: ByteArray, slice1: Slice, slice2: Slice): Int {
9+ val start1 = slice1.start
10+ val end1 = slice1.end
11+ val start2 = slice2.start
12+ val end2 = slice2.end
13+ var i = 0
14+ while (i < end1 - start1) {
15+ val index1 = start1 + i
16+ val index2 = start2 + i
17+ val byte1 = content[index1].toInt() and 0xff
18+ if (index2 >= end2) {
19+ return 1
20+ }
21+ val byte2 = content[index2].toInt() and 0xff
22+ if (byte1 > byte2) {
23+ return 1
24+ }
25+ if (byte1 < byte2) {
26+ return -1
27+ }
28+ i += 1
29+ }
30+ if (end2 - start2 > end1 - start1) {
31+ return -1
32+ }
33+ return 0
34+ }
35+ }
36+}
generated/com/novi/serde/Tuple2.javadeleted−40
@@ -1,40 +0,0 @@
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.javadeleted−46
@@ -1,46 +0,0 @@
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.javadeleted−54
@@ -1,54 +0,0 @@
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.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple4<T0, T1, T2, T3>(val field0: T0, val field1: T1, val field2: T2, val field3: T3)
generated/com/novi/serde/Tuple5.javadeleted−61
@@ -1,61 +0,0 @@
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.ktnew file+12
@@ -0,0 +1,12 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple5<T0, T1, T2, T3, T4>(
7+ val field0: T0,
8+ val field1: T1,
9+ val field2: T2,
10+ val field3: T3,
11+ val field4: T4
12+)
generated/com/novi/serde/Tuple6.javadeleted−67
@@ -1,67 +0,0 @@
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.ktnew file+13
@@ -0,0 +1,13 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class Tuple6<T0, T1, T2, T3, T4, T5>(
7+ val field0: T0,
8+ val field1: T1,
9+ val field2: T2,
10+ val field3: T3,
11+ val field4: T4,
12+ val field5: T5
13+)
generated/com/novi/serde/UInt128.ktnew file+6
@@ -0,0 +1,6 @@
1+// Copyright (c) Zefchain Labs, Inc.
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde
5+
6+data class UInt128(val high: ULong, val low: ULong)
generated/com/novi/serde/Unit.javadeleted−20
@@ -1,20 +0,0 @@
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.javadeleted−11
@@ -1,11 +0,0 @@
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.ktnew file+1291
@@ -0,0 +1,1291 @@
1+package soy.iko.opencode.core
2+
3+import com.novi.bincode.BincodeDeserializer
4+import com.novi.bincode.BincodeSerializer
5+import com.novi.serde.Bytes
6+import com.novi.serde.DeserializationError
7+import com.novi.serde.Deserializer
8+import com.novi.serde.Serializer
9+
10+fun <T> List<T>.serialize(
11+ serializer: Serializer,
12+ serializeElement: Serializer.(T) -> Unit,
13+) {
14+ serializer.serialize_len(size.toLong())
15+ forEach { element ->
16+ serializer.serializeElement(element)
17+ }
18+}
19+
20+fun <T> Deserializer.deserializeListOf(deserializeElement: (Deserializer) -> T): List<T> {
21+ val length = deserialize_len()
22+ val list = mutableListOf<T>()
23+ repeat(length.toInt()) {
24+ list.add(deserializeElement(this))
25+ }
26+ return list
27+}
28+
29+fun <T> T?.serializeOptionOf(
30+ serializer: Serializer,
31+ serializeElement: Serializer.(T) -> Unit,
32+) {
33+ if (this != null) {
34+ serializer.serialize_option_tag(true)
35+ serializer.serializeElement(this)
36+ } else {
37+ serializer.serialize_option_tag(false)
38+ }
39+}
40+
41+fun <T> Deserializer.deserializeOptionOf(deserializeElement: (Deserializer) -> T): T? {
42+ val tag = deserialize_option_tag()
43+ return if (tag) {
44+ deserializeElement(this)
45+ } else {
46+ null
47+ }
48+}
49+
50+sealed interface Effect {
51+ fun serialize(serializer: Serializer)
52+
53+ fun bincodeSerialize(): ByteArray {
54+ val serializer = BincodeSerializer()
55+ serialize(serializer)
56+ return serializer.get_bytes()
57+ }
58+
59+ data class Render(
60+ val value: soy.iko.opencode.core.RenderOperation,
61+ ) : Effect {
62+ override fun serialize(serializer: Serializer) {
63+ serializer.increase_container_depth()
64+ serializer.serialize_variant_index(0)
65+ value.serialize(serializer)
66+ serializer.decrease_container_depth()
67+ }
68+
69+ companion object {
70+ fun deserialize(deserializer: Deserializer): Render {
71+ deserializer.increase_container_depth()
72+ val value = soy.iko.opencode.core.RenderOperation.deserialize(deserializer)
73+ deserializer.decrease_container_depth()
74+ return Render(value)
75+ }
76+ }
77+ }
78+
79+ data class Http(
80+ val value: soy.iko.opencode.core.HttpRequest,
81+ ) : Effect {
82+ override fun serialize(serializer: Serializer) {
83+ serializer.increase_container_depth()
84+ serializer.serialize_variant_index(1)
85+ value.serialize(serializer)
86+ serializer.decrease_container_depth()
87+ }
88+
89+ companion object {
90+ fun deserialize(deserializer: Deserializer): Http {
91+ deserializer.increase_container_depth()
92+ val value = soy.iko.opencode.core.HttpRequest.deserialize(deserializer)
93+ deserializer.decrease_container_depth()
94+ return Http(value)
95+ }
96+ }
97+ }
98+
99+ companion object {
100+ @Throws(DeserializationError::class)
101+ fun deserialize(deserializer: Deserializer): Effect {
102+ val index = deserializer.deserialize_variant_index()
103+ return when (index) {
104+ 0 -> Render.deserialize(deserializer)
105+ 1 -> Http.deserialize(deserializer)
106+ else -> throw DeserializationError("Unknown variant index for Effect: $index")
107+ }
108+ }
109+
110+ @Throws(DeserializationError::class)
111+ fun bincodeDeserialize(input: ByteArray?): Effect {
112+ if (input == null) {
113+ throw DeserializationError("Cannot deserialize null array")
114+ }
115+ val deserializer = BincodeDeserializer(input)
116+ val value = deserialize(deserializer)
117+ if (deserializer.get_buffer_offset() < input.size) {
118+ throw DeserializationError("Some input bytes were not read")
119+ }
120+ return value
121+ }
122+ }
123+}
124+
125+sealed interface Event {
126+ fun serialize(serializer: Serializer)
127+
128+ fun bincodeSerialize(): ByteArray {
129+ val serializer = BincodeSerializer()
130+ serialize(serializer)
131+ return serializer.get_bytes()
132+ }
133+
134+ data object Start: Event {
135+ override fun serialize(serializer: Serializer) {
136+ serializer.increase_container_depth()
137+ serializer.serialize_variant_index(0)
138+ serializer.decrease_container_depth()
139+ }
140+
141+ fun deserialize(deserializer: Deserializer): Start {
142+ return Start
143+ }
144+ }
145+
146+ data class ServerUrlChanged(
147+ val value: String,
148+ ) : Event {
149+ override fun serialize(serializer: Serializer) {
150+ serializer.increase_container_depth()
151+ serializer.serialize_variant_index(1)
152+ serializer.serialize_str(value)
153+ serializer.decrease_container_depth()
154+ }
155+
156+ companion object {
157+ fun deserialize(deserializer: Deserializer): ServerUrlChanged {
158+ deserializer.increase_container_depth()
159+ val value = deserializer.deserialize_str()
160+ deserializer.decrease_container_depth()
161+ return ServerUrlChanged(value)
162+ }
163+ }
164+ }
165+
166+ data class UsernameChanged(
167+ val value: String,
168+ ) : Event {
169+ override fun serialize(serializer: Serializer) {
170+ serializer.increase_container_depth()
171+ serializer.serialize_variant_index(2)
172+ serializer.serialize_str(value)
173+ serializer.decrease_container_depth()
174+ }
175+
176+ companion object {
177+ fun deserialize(deserializer: Deserializer): UsernameChanged {
178+ deserializer.increase_container_depth()
179+ val value = deserializer.deserialize_str()
180+ deserializer.decrease_container_depth()
181+ return UsernameChanged(value)
182+ }
183+ }
184+ }
185+
186+ data class PasswordChanged(
187+ val value: String,
188+ ) : Event {
189+ override fun serialize(serializer: Serializer) {
190+ serializer.increase_container_depth()
191+ serializer.serialize_variant_index(3)
192+ serializer.serialize_str(value)
193+ serializer.decrease_container_depth()
194+ }
195+
196+ companion object {
197+ fun deserialize(deserializer: Deserializer): PasswordChanged {
198+ deserializer.increase_container_depth()
199+ val value = deserializer.deserialize_str()
200+ deserializer.decrease_container_depth()
201+ return PasswordChanged(value)
202+ }
203+ }
204+ }
205+
206+ data object Connect: Event {
207+ override fun serialize(serializer: Serializer) {
208+ serializer.increase_container_depth()
209+ serializer.serialize_variant_index(4)
210+ serializer.decrease_container_depth()
211+ }
212+
213+ fun deserialize(deserializer: Deserializer): Connect {
214+ return Connect
215+ }
216+ }
217+
218+ data object CancelAuth: Event {
219+ override fun serialize(serializer: Serializer) {
220+ serializer.increase_container_depth()
221+ serializer.serialize_variant_index(5)
222+ serializer.decrease_container_depth()
223+ }
224+
225+ fun deserialize(deserializer: Deserializer): CancelAuth {
226+ return CancelAuth
227+ }
228+ }
229+
230+ data object LoadSessions: Event {
231+ override fun serialize(serializer: Serializer) {
232+ serializer.increase_container_depth()
233+ serializer.serialize_variant_index(6)
234+ serializer.decrease_container_depth()
235+ }
236+
237+ fun deserialize(deserializer: Deserializer): LoadSessions {
238+ return LoadSessions
239+ }
240+ }
241+
242+ data class SelectSession(
243+ val value: String,
244+ ) : Event {
245+ override fun serialize(serializer: Serializer) {
246+ serializer.increase_container_depth()
247+ serializer.serialize_variant_index(7)
248+ serializer.serialize_str(value)
249+ serializer.decrease_container_depth()
250+ }
251+
252+ companion object {
253+ fun deserialize(deserializer: Deserializer): SelectSession {
254+ deserializer.increase_container_depth()
255+ val value = deserializer.deserialize_str()
256+ deserializer.decrease_container_depth()
257+ return SelectSession(value)
258+ }
259+ }
260+ }
261+
262+ data object CreateSession: Event {
263+ override fun serialize(serializer: Serializer) {
264+ serializer.increase_container_depth()
265+ serializer.serialize_variant_index(8)
266+ serializer.decrease_container_depth()
267+ }
268+
269+ fun deserialize(deserializer: Deserializer): CreateSession {
270+ return CreateSession
271+ }
272+ }
273+
274+ data class LoadMessages(
275+ val value: String,
276+ ) : Event {
277+ override fun serialize(serializer: Serializer) {
278+ serializer.increase_container_depth()
279+ serializer.serialize_variant_index(9)
280+ serializer.serialize_str(value)
281+ serializer.decrease_container_depth()
282+ }
283+
284+ companion object {
285+ fun deserialize(deserializer: Deserializer): LoadMessages {
286+ deserializer.increase_container_depth()
287+ val value = deserializer.deserialize_str()
288+ deserializer.decrease_container_depth()
289+ return LoadMessages(value)
290+ }
291+ }
292+ }
293+
294+ data class SendMessage(
295+ val value: String,
296+ ) : Event {
297+ override fun serialize(serializer: Serializer) {
298+ serializer.increase_container_depth()
299+ serializer.serialize_variant_index(10)
300+ serializer.serialize_str(value)
301+ serializer.decrease_container_depth()
302+ }
303+
304+ companion object {
305+ fun deserialize(deserializer: Deserializer): SendMessage {
306+ deserializer.increase_container_depth()
307+ val value = deserializer.deserialize_str()
308+ deserializer.decrease_container_depth()
309+ return SendMessage(value)
310+ }
311+ }
312+ }
313+
314+ data class EventReceived(
315+ val value: String,
316+ ) : Event {
317+ override fun serialize(serializer: Serializer) {
318+ serializer.increase_container_depth()
319+ serializer.serialize_variant_index(11)
320+ serializer.serialize_str(value)
321+ serializer.decrease_container_depth()
322+ }
323+
324+ companion object {
325+ fun deserialize(deserializer: Deserializer): EventReceived {
326+ deserializer.increase_container_depth()
327+ val value = deserializer.deserialize_str()
328+ deserializer.decrease_container_depth()
329+ return EventReceived(value)
330+ }
331+ }
332+ }
333+
334+ data class NavigateToChat(
335+ val value: String,
336+ ) : Event {
337+ override fun serialize(serializer: Serializer) {
338+ serializer.increase_container_depth()
339+ serializer.serialize_variant_index(12)
340+ serializer.serialize_str(value)
341+ serializer.decrease_container_depth()
342+ }
343+
344+ companion object {
345+ fun deserialize(deserializer: Deserializer): NavigateToChat {
346+ deserializer.increase_container_depth()
347+ val value = deserializer.deserialize_str()
348+ deserializer.decrease_container_depth()
349+ return NavigateToChat(value)
350+ }
351+ }
352+ }
353+
354+ data object NavigateToSessions: Event {
355+ override fun serialize(serializer: Serializer) {
356+ serializer.increase_container_depth()
357+ serializer.serialize_variant_index(13)
358+ serializer.decrease_container_depth()
359+ }
360+
361+ fun deserialize(deserializer: Deserializer): NavigateToSessions {
362+ return NavigateToSessions
363+ }
364+ }
365+
366+ data object NavigateToConnect: Event {
367+ override fun serialize(serializer: Serializer) {
368+ serializer.increase_container_depth()
369+ serializer.serialize_variant_index(14)
370+ serializer.decrease_container_depth()
371+ }
372+
373+ fun deserialize(deserializer: Deserializer): NavigateToConnect {
374+ return NavigateToConnect
375+ }
376+ }
377+
378+ data object DismissError: Event {
379+ override fun serialize(serializer: Serializer) {
380+ serializer.increase_container_depth()
381+ serializer.serialize_variant_index(15)
382+ serializer.decrease_container_depth()
383+ }
384+
385+ fun deserialize(deserializer: Deserializer): DismissError {
386+ return DismissError
387+ }
388+ }
389+
390+ data class DraftChanged(
391+ val value: String,
392+ ) : Event {
393+ override fun serialize(serializer: Serializer) {
394+ serializer.increase_container_depth()
395+ serializer.serialize_variant_index(16)
396+ serializer.serialize_str(value)
397+ serializer.decrease_container_depth()
398+ }
399+
400+ companion object {
401+ fun deserialize(deserializer: Deserializer): DraftChanged {
402+ deserializer.increase_container_depth()
403+ val value = deserializer.deserialize_str()
404+ deserializer.decrease_container_depth()
405+ return DraftChanged(value)
406+ }
407+ }
408+ }
409+
410+ data object CancelGeneration: Event {
411+ override fun serialize(serializer: Serializer) {
412+ serializer.increase_container_depth()
413+ serializer.serialize_variant_index(17)
414+ serializer.decrease_container_depth()
415+ }
416+
417+ fun deserialize(deserializer: Deserializer): CancelGeneration {
418+ return CancelGeneration
419+ }
420+ }
421+
422+ data class DeleteSession(
423+ val value: String,
424+ ) : Event {
425+ override fun serialize(serializer: Serializer) {
426+ serializer.increase_container_depth()
427+ serializer.serialize_variant_index(18)
428+ serializer.serialize_str(value)
429+ serializer.decrease_container_depth()
430+ }
431+
432+ companion object {
433+ fun deserialize(deserializer: Deserializer): DeleteSession {
434+ deserializer.increase_container_depth()
435+ val value = deserializer.deserialize_str()
436+ deserializer.decrease_container_depth()
437+ return DeleteSession(value)
438+ }
439+ }
440+ }
441+
442+ data class RetryMessage(
443+ val value: String,
444+ ) : Event {
445+ override fun serialize(serializer: Serializer) {
446+ serializer.increase_container_depth()
447+ serializer.serialize_variant_index(19)
448+ serializer.serialize_str(value)
449+ serializer.decrease_container_depth()
450+ }
451+
452+ companion object {
453+ fun deserialize(deserializer: Deserializer): RetryMessage {
454+ deserializer.increase_container_depth()
455+ val value = deserializer.deserialize_str()
456+ deserializer.decrease_container_depth()
457+ return RetryMessage(value)
458+ }
459+ }
460+ }
461+
462+ companion object {
463+ @Throws(DeserializationError::class)
464+ fun deserialize(deserializer: Deserializer): Event {
465+ val index = deserializer.deserialize_variant_index()
466+ return when (index) {
467+ 0 -> Start.deserialize(deserializer)
468+ 1 -> ServerUrlChanged.deserialize(deserializer)
469+ 2 -> UsernameChanged.deserialize(deserializer)
470+ 3 -> PasswordChanged.deserialize(deserializer)
471+ 4 -> Connect.deserialize(deserializer)
472+ 5 -> CancelAuth.deserialize(deserializer)
473+ 6 -> LoadSessions.deserialize(deserializer)
474+ 7 -> SelectSession.deserialize(deserializer)
475+ 8 -> CreateSession.deserialize(deserializer)
476+ 9 -> LoadMessages.deserialize(deserializer)
477+ 10 -> SendMessage.deserialize(deserializer)
478+ 11 -> EventReceived.deserialize(deserializer)
479+ 12 -> NavigateToChat.deserialize(deserializer)
480+ 13 -> NavigateToSessions.deserialize(deserializer)
481+ 14 -> NavigateToConnect.deserialize(deserializer)
482+ 15 -> DismissError.deserialize(deserializer)
483+ 16 -> DraftChanged.deserialize(deserializer)
484+ 17 -> CancelGeneration.deserialize(deserializer)
485+ 18 -> DeleteSession.deserialize(deserializer)
486+ 19 -> RetryMessage.deserialize(deserializer)
487+ else -> throw DeserializationError("Unknown variant index for Event: $index")
488+ }
489+ }
490+
491+ @Throws(DeserializationError::class)
492+ fun bincodeDeserialize(input: ByteArray?): Event {
493+ if (input == null) {
494+ throw DeserializationError("Cannot deserialize null array")
495+ }
496+ val deserializer = BincodeDeserializer(input)
497+ val value = deserialize(deserializer)
498+ if (deserializer.get_buffer_offset() < input.size) {
499+ throw DeserializationError("Some input bytes were not read")
500+ }
501+ return value
502+ }
503+ }
504+}
505+
506+/// An error produced when an HTTP request fails.
507+///
508+/// Variants fall into two groups:
509+///
510+/// **Transport errors** — generated by the shell when it cannot complete the HTTP
511+/// exchange. These cross the FFI boundary and are serialized in the protocol:
512+/// [`Url`](HttpError::Url), [`Io`](HttpError::Io), [`Timeout`](HttpError::Timeout).
513+///
514+/// **Processing errors** — generated on the Rust side after a response arrives.
515+/// These are never serialized or visible to shells:
516+///
517+/// - [`Http`](HttpError::Http) — produced by `Response::new()` when the server returns
518+/// a 4xx or 5xx status. At the *protocol* level these arrive as
519+/// [`HttpResult::Ok`](crate::protocol::HttpResult::Ok); `Response::new()` converts
520+/// them here, so app code using `crux_http::Result<Response<T>>` will see them as
521+/// `Err(HttpError::Http { code, .. })`.
522+/// - [`Json`](HttpError::Json) — produced when response body deserialisation fails.
523+sealed interface HttpError {
524+ fun serialize(serializer: Serializer)
525+
526+ fun bincodeSerialize(): ByteArray {
527+ val serializer = BincodeSerializer()
528+ serialize(serializer)
529+ return serializer.get_bytes()
530+ }
531+
532+ /// The request URL could not be parsed.
533+ data class Url(
534+ val value: String,
535+ ) : HttpError {
536+ override fun serialize(serializer: Serializer) {
537+ serializer.increase_container_depth()
538+ serializer.serialize_variant_index(0)
539+ serializer.serialize_str(value)
540+ serializer.decrease_container_depth()
541+ }
542+
543+ companion object {
544+ fun deserialize(deserializer: Deserializer): Url {
545+ deserializer.increase_container_depth()
546+ val value = deserializer.deserialize_str()
547+ deserializer.decrease_container_depth()
548+ return Url(value)
549+ }
550+ }
551+ }
552+
553+ /// An IO error prevented the request from completing.
554+ data class Io(
555+ val value: String,
556+ ) : HttpError {
557+ override fun serialize(serializer: Serializer) {
558+ serializer.increase_container_depth()
559+ serializer.serialize_variant_index(1)
560+ serializer.serialize_str(value)
561+ serializer.decrease_container_depth()
562+ }
563+
564+ companion object {
565+ fun deserialize(deserializer: Deserializer): Io {
566+ deserializer.increase_container_depth()
567+ val value = deserializer.deserialize_str()
568+ deserializer.decrease_container_depth()
569+ return Io(value)
570+ }
571+ }
572+ }
573+
574+ /// The request timed out before a response was received.
575+ data object Timeout: HttpError {
576+ override fun serialize(serializer: Serializer) {
577+ serializer.increase_container_depth()
578+ serializer.serialize_variant_index(2)
579+ serializer.decrease_container_depth()
580+ }
581+
582+ fun deserialize(deserializer: Deserializer): Timeout {
583+ return Timeout
584+ }
585+ }
586+
587+ companion object {
588+ @Throws(DeserializationError::class)
589+ fun deserialize(deserializer: Deserializer): HttpError {
590+ val index = deserializer.deserialize_variant_index()
591+ return when (index) {
592+ 0 -> Url.deserialize(deserializer)
593+ 1 -> Io.deserialize(deserializer)
594+ 2 -> Timeout.deserialize(deserializer)
595+ else -> throw DeserializationError("Unknown variant index for HttpError: $index")
596+ }
597+ }
598+
599+ @Throws(DeserializationError::class)
600+ fun bincodeDeserialize(input: ByteArray?): HttpError {
601+ if (input == null) {
602+ throw DeserializationError("Cannot deserialize null array")
603+ }
604+ val deserializer = BincodeDeserializer(input)
605+ val value = deserialize(deserializer)
606+ if (deserializer.get_buffer_offset() < input.size) {
607+ throw DeserializationError("Some input bytes were not read")
608+ }
609+ return value
610+ }
611+ }
612+}
613+
614+data class HttpHeader(
615+ val name: String,
616+ val value: String,
617+) {
618+ fun serialize(serializer: Serializer) {
619+ serializer.increase_container_depth()
620+ serializer.serialize_str(name)
621+ serializer.serialize_str(value)
622+ serializer.decrease_container_depth()
623+ }
624+
625+ fun bincodeSerialize(): ByteArray {
626+ val serializer = BincodeSerializer()
627+ serialize(serializer)
628+ return serializer.get_bytes()
629+ }
630+
631+ companion object {
632+ fun deserialize(deserializer: Deserializer): HttpHeader {
633+ deserializer.increase_container_depth()
634+ val name = deserializer.deserialize_str()
635+ val value = deserializer.deserialize_str()
636+ deserializer.decrease_container_depth()
637+ return HttpHeader(name, value)
638+ }
639+
640+ @Throws(DeserializationError::class)
641+ fun bincodeDeserialize(input: ByteArray?): HttpHeader {
642+ if (input == null) {
643+ throw DeserializationError("Cannot deserialize null array")
644+ }
645+ val deserializer = BincodeDeserializer(input)
646+ val value = deserialize(deserializer)
647+ if (deserializer.get_buffer_offset() < input.size) {
648+ throw DeserializationError("Some input bytes were not read")
649+ }
650+ return value
651+ }
652+ }
653+}
654+
655+/// A raw HTTP request, as sent to the shell over the protocol boundary.
656+///
657+/// # No header validation
658+///
659+/// All fields are plain strings. Header names and values are carried as-is with no
660+/// validation against the HTTP specification. This is intentional: `HttpRequest` is a
661+/// cross-language data carrier deserialised by Swift, Kotlin, and TypeScript shells;
662+/// Rust's `http`-crate validation rules cannot be enforced on the other side of that
663+/// boundary.
664+///
665+/// **Shell authors must not assume that header names or values are well-formed.**
666+/// Pass them to your underlying HTTP client as-is — it will apply its own rules.
667+///
668+/// For the ergonomic Rust-side builder that *does* validate header values (and panics
669+/// on invalid input), see [`crate::command::RequestBuilder`].
670+data class HttpRequest(
671+ val method: String,
672+ val url: String,
673+ val headers: List<soy.iko.opencode.core.HttpHeader>,
674+ val body: Bytes,
675+) {
676+ fun serialize(serializer: Serializer) {
677+ serializer.increase_container_depth()
678+ serializer.serialize_str(method)
679+ serializer.serialize_str(url)
680+ headers.serialize(serializer) {
681+ it.serialize(serializer)
682+ }
683+ serializer.serialize_bytes(body)
684+ serializer.decrease_container_depth()
685+ }
686+
687+ fun bincodeSerialize(): ByteArray {
688+ val serializer = BincodeSerializer()
689+ serialize(serializer)
690+ return serializer.get_bytes()
691+ }
692+
693+ companion object {
694+ fun deserialize(deserializer: Deserializer): HttpRequest {
695+ deserializer.increase_container_depth()
696+ val method = deserializer.deserialize_str()
697+ val url = deserializer.deserialize_str()
698+ val headers =
699+ deserializer.deserializeListOf {
700+ soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
701+ }
702+ val body = deserializer.deserialize_bytes()
703+ deserializer.decrease_container_depth()
704+ return HttpRequest(method, url, headers, body)
705+ }
706+
707+ @Throws(DeserializationError::class)
708+ fun bincodeDeserialize(input: ByteArray?): HttpRequest {
709+ if (input == null) {
710+ throw DeserializationError("Cannot deserialize null array")
711+ }
712+ val deserializer = BincodeDeserializer(input)
713+ val value = deserialize(deserializer)
714+ if (deserializer.get_buffer_offset() < input.size) {
715+ throw DeserializationError("Some input bytes were not read")
716+ }
717+ return value
718+ }
719+ }
720+}
721+
722+data class HttpResponse(
723+ val status: UShort,
724+ val headers: List<soy.iko.opencode.core.HttpHeader>,
725+ val body: Bytes,
726+) {
727+ fun serialize(serializer: Serializer) {
728+ serializer.increase_container_depth()
729+ serializer.serialize_u16(status)
730+ headers.serialize(serializer) {
731+ it.serialize(serializer)
732+ }
733+ serializer.serialize_bytes(body)
734+ serializer.decrease_container_depth()
735+ }
736+
737+ fun bincodeSerialize(): ByteArray {
738+ val serializer = BincodeSerializer()
739+ serialize(serializer)
740+ return serializer.get_bytes()
741+ }
742+
743+ companion object {
744+ fun deserialize(deserializer: Deserializer): HttpResponse {
745+ deserializer.increase_container_depth()
746+ val status = deserializer.deserialize_u16()
747+ val headers =
748+ deserializer.deserializeListOf {
749+ soy.iko.opencode.core.HttpHeader.deserialize(deserializer)
750+ }
751+ val body = deserializer.deserialize_bytes()
752+ deserializer.decrease_container_depth()
753+ return HttpResponse(status, headers, body)
754+ }
755+
756+ @Throws(DeserializationError::class)
757+ fun bincodeDeserialize(input: ByteArray?): HttpResponse {
758+ if (input == null) {
759+ throw DeserializationError("Cannot deserialize null array")
760+ }
761+ val deserializer = BincodeDeserializer(input)
762+ val value = deserialize(deserializer)
763+ if (deserializer.get_buffer_offset() < input.size) {
764+ throw DeserializationError("Some input bytes were not read")
765+ }
766+ return value
767+ }
768+ }
769+}
770+
771+/// The result of an HTTP request, as returned by the shell over the protocol boundary.
772+///
773+/// # Status codes are not errors
774+///
775+/// Any completed HTTP exchange — including responses with 4xx or 5xx status codes — is
776+/// returned as [`HttpResult::Ok`]. Only *transport-level* failures (the shell could not
777+/// reach the server at all) produce [`HttpResult::Err`].
778+///
779+/// To act on an error status, inspect [`HttpResponse::status`]:
780+///
781+/// ```
782+/// # use crux_http::protocol::{HttpResult, HttpResponse};
783+/// # use crux_http::HttpError;
784+/// # fn handle(result: HttpResult) {
785+/// match result {
786+/// HttpResult::Ok(response) if response.status == 200 => { /* success */ }
787+/// HttpResult::Ok(response) if response.status == 404 => { /* not found */ }
788+/// HttpResult::Ok(response) if response.status >= 500 => { /* server error */ }
789+/// HttpResult::Ok(_) => { /* other status */ }
790+/// HttpResult::Err(e) => { /* transport failure: bad URL, IO error, or timeout */ }
791+/// }
792+/// # }
793+/// ```
794+sealed interface HttpResult {
795+ fun serialize(serializer: Serializer)
796+
797+ fun bincodeSerialize(): ByteArray {
798+ val serializer = BincodeSerializer()
799+ serialize(serializer)
800+ return serializer.get_bytes()
801+ }
802+
803+ /// The shell completed the HTTP exchange. The response may carry any status code,
804+ /// including 4xx and 5xx — inspect [`HttpResponse::status`] to distinguish them.
805+ data class Ok(
806+ val value: soy.iko.opencode.core.HttpResponse,
807+ ) : HttpResult {
808+ override fun serialize(serializer: Serializer) {
809+ serializer.increase_container_depth()
810+ serializer.serialize_variant_index(0)
811+ value.serialize(serializer)
812+ serializer.decrease_container_depth()
813+ }
814+
815+ companion object {
816+ fun deserialize(deserializer: Deserializer): Ok {
817+ deserializer.increase_container_depth()
818+ val value = soy.iko.opencode.core.HttpResponse.deserialize(deserializer)
819+ deserializer.decrease_container_depth()
820+ return Ok(value)
821+ }
822+ }
823+ }
824+
825+ /// The shell could not complete the HTTP exchange due to a transport-level failure.
826+ /// See [`HttpError`] for the possible causes.
827+ data class Err(
828+ val value: soy.iko.opencode.core.HttpError,
829+ ) : HttpResult {
830+ override fun serialize(serializer: Serializer) {
831+ serializer.increase_container_depth()
832+ serializer.serialize_variant_index(1)
833+ value.serialize(serializer)
834+ serializer.decrease_container_depth()
835+ }
836+
837+ companion object {
838+ fun deserialize(deserializer: Deserializer): Err {
839+ deserializer.increase_container_depth()
840+ val value = soy.iko.opencode.core.HttpError.deserialize(deserializer)
841+ deserializer.decrease_container_depth()
842+ return Err(value)
843+ }
844+ }
845+ }
846+
847+ companion object {
848+ @Throws(DeserializationError::class)
849+ fun deserialize(deserializer: Deserializer): HttpResult {
850+ val index = deserializer.deserialize_variant_index()
851+ return when (index) {
852+ 0 -> Ok.deserialize(deserializer)
853+ 1 -> Err.deserialize(deserializer)
854+ else -> throw DeserializationError("Unknown variant index for HttpResult: $index")
855+ }
856+ }
857+
858+ @Throws(DeserializationError::class)
859+ fun bincodeDeserialize(input: ByteArray?): HttpResult {
860+ if (input == null) {
861+ throw DeserializationError("Cannot deserialize null array")
862+ }
863+ val deserializer = BincodeDeserializer(input)
864+ val value = deserialize(deserializer)
865+ if (deserializer.get_buffer_offset() < input.size) {
866+ throw DeserializationError("Some input bytes were not read")
867+ }
868+ return value
869+ }
870+ }
871+}
872+
873+enum class MessageStatus {
874+ SENT,
875+ PENDING,
876+ FAILED;
877+
878+ fun serialize(serializer: Serializer) {
879+ serializer.increase_container_depth()
880+ serializer.serialize_variant_index(ordinal)
881+ serializer.decrease_container_depth()
882+ }
883+
884+ fun bincodeSerialize(): ByteArray {
885+ val serializer = BincodeSerializer()
886+ serialize(serializer)
887+ return serializer.get_bytes()
888+ }
889+
890+ companion object {
891+ @Throws(DeserializationError::class)
892+ fun deserialize(deserializer: Deserializer): MessageStatus {
893+ deserializer.increase_container_depth()
894+ val index = deserializer.deserialize_variant_index()
895+ deserializer.decrease_container_depth()
896+ return when (index) {
897+ 0 -> SENT
898+ 1 -> PENDING
899+ 2 -> FAILED
900+ else -> throw DeserializationError("Unknown variant index for MessageStatus: $index")
901+ }
902+ }
903+
904+ @Throws(DeserializationError::class)
905+ fun bincodeDeserialize(input: ByteArray?): MessageStatus {
906+ if (input == null) {
907+ throw DeserializationError("Cannot deserialize null array")
908+ }
909+ val deserializer = BincodeDeserializer(input)
910+ val value = deserialize(deserializer)
911+ if (deserializer.get_buffer_offset() < input.size) {
912+ throw DeserializationError("Some input bytes were not read")
913+ }
914+ return value
915+ }
916+ }
917+}
918+
919+data class MessageView(
920+ val id: String,
921+ val role: String,
922+ val text: String,
923+ val time: ULong,
924+ val status: soy.iko.opencode.core.MessageStatus,
925+) {
926+ fun serialize(serializer: Serializer) {
927+ serializer.increase_container_depth()
928+ serializer.serialize_str(id)
929+ serializer.serialize_str(role)
930+ serializer.serialize_str(text)
931+ serializer.serialize_u64(time)
932+ status.serialize(serializer)
933+ serializer.decrease_container_depth()
934+ }
935+
936+ fun bincodeSerialize(): ByteArray {
937+ val serializer = BincodeSerializer()
938+ serialize(serializer)
939+ return serializer.get_bytes()
940+ }
941+
942+ companion object {
943+ fun deserialize(deserializer: Deserializer): MessageView {
944+ deserializer.increase_container_depth()
945+ val id = deserializer.deserialize_str()
946+ val role = deserializer.deserialize_str()
947+ val text = deserializer.deserialize_str()
948+ val time = deserializer.deserialize_u64()
949+ val status = soy.iko.opencode.core.MessageStatus.deserialize(deserializer)
950+ deserializer.decrease_container_depth()
951+ return MessageView(id, role, text, time, status)
952+ }
953+
954+ @Throws(DeserializationError::class)
955+ fun bincodeDeserialize(input: ByteArray?): MessageView {
956+ if (input == null) {
957+ throw DeserializationError("Cannot deserialize null array")
958+ }
959+ val deserializer = BincodeDeserializer(input)
960+ val value = deserialize(deserializer)
961+ if (deserializer.get_buffer_offset() < input.size) {
962+ throw DeserializationError("Some input bytes were not read")
963+ }
964+ return value
965+ }
966+ }
967+}
968+
969+/// The single operation `Render` implements.
970+data object RenderOperation {
971+ fun serialize(serializer: Serializer) {}
972+
973+ fun bincodeSerialize(): ByteArray {
974+ val serializer = BincodeSerializer()
975+ serialize(serializer)
976+ return serializer.get_bytes()
977+ }
978+
979+ fun deserialize(deserializer: Deserializer): RenderOperation {
980+ return RenderOperation
981+ }
982+
983+ @Throws(DeserializationError::class)
984+ fun bincodeDeserialize(input: ByteArray?): RenderOperation {
985+ if (input == null) {
986+ throw DeserializationError("Cannot deserialize null array")
987+ }
988+ val deserializer = BincodeDeserializer(input)
989+ val value = deserialize(deserializer)
990+ if (deserializer.get_buffer_offset() < input.size) {
991+ throw DeserializationError("Some input bytes were not read")
992+ }
993+ return value
994+ }
995+}
996+
997+/// Request for a side-effect passed from the Core to the Shell.
998+///
999+/// The `EffectId` links the `Request` with the corresponding call to [`Core::resolve`] to pass the data back
1000+/// to the [`App::update`] function (wrapped in the event provided to the capability originating the effect).
1001+data class Request(
1002+ val id: UInt,
1003+ val effect: soy.iko.opencode.core.Effect,
1004+) {
1005+ fun serialize(serializer: Serializer) {
1006+ serializer.increase_container_depth()
1007+ serializer.serialize_u32(id)
1008+ effect.serialize(serializer)
1009+ serializer.decrease_container_depth()
1010+ }
1011+
1012+ fun bincodeSerialize(): ByteArray {
1013+ val serializer = BincodeSerializer()
1014+ serialize(serializer)
1015+ return serializer.get_bytes()
1016+ }
1017+
1018+ companion object {
1019+ fun deserialize(deserializer: Deserializer): Request {
1020+ deserializer.increase_container_depth()
1021+ val id = deserializer.deserialize_u32()
1022+ val effect = soy.iko.opencode.core.Effect.deserialize(deserializer)
1023+ deserializer.decrease_container_depth()
1024+ return Request(id, effect)
1025+ }
1026+
1027+ @Throws(DeserializationError::class)
1028+ fun bincodeDeserialize(input: ByteArray?): Request {
1029+ if (input == null) {
1030+ throw DeserializationError("Cannot deserialize null array")
1031+ }
1032+ val deserializer = BincodeDeserializer(input)
1033+ val value = deserialize(deserializer)
1034+ if (deserializer.get_buffer_offset() < input.size) {
1035+ throw DeserializationError("Some input bytes were not read")
1036+ }
1037+ return value
1038+ }
1039+ }
1040+}
1041+
1042+/// A batch of effect requests from the Core to the Shell, as serialised by
1043+/// [`Bridge::update`] and [`Bridge::resolve`].
1044+///
1045+/// The wire format is identical to `Vec<Request<Eff>>` (the newtype is
1046+/// `serde(transparent)`), so existing shell code that already deserialises
1047+/// a `Vec<Request>` remains binary-compatible.
1048+///
1049+/// Registering this type with the type-generation system causes the code
1050+/// generators to emit a `Requests` type (with a `value` field containing the
1051+/// list) together with a top-level `bincodeDeserialize` / `BincodeDeserialize`
1052+/// helper, replacing the hand-written extension files that were previously
1053+/// appended by `add_extensions()`.
1054+data class Requests(
1055+ val value: List<soy.iko.opencode.core.Request>,
1056+) {
1057+ fun serialize(serializer: Serializer) {
1058+ serializer.increase_container_depth()
1059+ value.serialize(serializer) {
1060+ it.serialize(serializer)
1061+ }
1062+ serializer.decrease_container_depth()
1063+ }
1064+
1065+ fun bincodeSerialize(): ByteArray {
1066+ val serializer = BincodeSerializer()
1067+ serialize(serializer)
1068+ return serializer.get_bytes()
1069+ }
1070+
1071+ companion object {
1072+ fun deserialize(deserializer: Deserializer): Requests {
1073+ deserializer.increase_container_depth()
1074+ val value =
1075+ deserializer.deserializeListOf {
1076+ soy.iko.opencode.core.Request.deserialize(deserializer)
1077+ }
1078+ deserializer.decrease_container_depth()
1079+ return Requests(value)
1080+ }
1081+
1082+ @Throws(DeserializationError::class)
1083+ fun bincodeDeserialize(input: ByteArray?): Requests {
1084+ if (input == null) {
1085+ throw DeserializationError("Cannot deserialize null array")
1086+ }
1087+ val deserializer = BincodeDeserializer(input)
1088+ val value = deserialize(deserializer)
1089+ if (deserializer.get_buffer_offset() < input.size) {
1090+ throw DeserializationError("Some input bytes were not read")
1091+ }
1092+ return value
1093+ }
1094+ }
1095+}
1096+
1097+enum class Screen {
1098+ CONNECT,
1099+ SESSIONS,
1100+ CHAT;
1101+
1102+ fun serialize(serializer: Serializer) {
1103+ serializer.increase_container_depth()
1104+ serializer.serialize_variant_index(ordinal)
1105+ serializer.decrease_container_depth()
1106+ }
1107+
1108+ fun bincodeSerialize(): ByteArray {
1109+ val serializer = BincodeSerializer()
1110+ serialize(serializer)
1111+ return serializer.get_bytes()
1112+ }
1113+
1114+ companion object {
1115+ @Throws(DeserializationError::class)
1116+ fun deserialize(deserializer: Deserializer): Screen {
1117+ deserializer.increase_container_depth()
1118+ val index = deserializer.deserialize_variant_index()
1119+ deserializer.decrease_container_depth()
1120+ return when (index) {
1121+ 0 -> CONNECT
1122+ 1 -> SESSIONS
1123+ 2 -> CHAT
1124+ else -> throw DeserializationError("Unknown variant index for Screen: $index")
1125+ }
1126+ }
1127+
1128+ @Throws(DeserializationError::class)
1129+ fun bincodeDeserialize(input: ByteArray?): Screen {
1130+ if (input == null) {
1131+ throw DeserializationError("Cannot deserialize null array")
1132+ }
1133+ val deserializer = BincodeDeserializer(input)
1134+ val value = deserialize(deserializer)
1135+ if (deserializer.get_buffer_offset() < input.size) {
1136+ throw DeserializationError("Some input bytes were not read")
1137+ }
1138+ return value
1139+ }
1140+ }
1141+}
1142+
1143+data class SessionView(
1144+ val id: String,
1145+ val title: String,
1146+) {
1147+ fun serialize(serializer: Serializer) {
1148+ serializer.increase_container_depth()
1149+ serializer.serialize_str(id)
1150+ serializer.serialize_str(title)
1151+ serializer.decrease_container_depth()
1152+ }
1153+
1154+ fun bincodeSerialize(): ByteArray {
1155+ val serializer = BincodeSerializer()
1156+ serialize(serializer)
1157+ return serializer.get_bytes()
1158+ }
1159+
1160+ companion object {
1161+ fun deserialize(deserializer: Deserializer): SessionView {
1162+ deserializer.increase_container_depth()
1163+ val id = deserializer.deserialize_str()
1164+ val title = deserializer.deserialize_str()
1165+ deserializer.decrease_container_depth()
1166+ return SessionView(id, title)
1167+ }
1168+
1169+ @Throws(DeserializationError::class)
1170+ fun bincodeDeserialize(input: ByteArray?): SessionView {
1171+ if (input == null) {
1172+ throw DeserializationError("Cannot deserialize null array")
1173+ }
1174+ val deserializer = BincodeDeserializer(input)
1175+ val value = deserialize(deserializer)
1176+ if (deserializer.get_buffer_offset() < input.size) {
1177+ throw DeserializationError("Some input bytes were not read")
1178+ }
1179+ return value
1180+ }
1181+ }
1182+}
1183+
1184+data class ViewModel(
1185+ val screen: soy.iko.opencode.core.Screen,
1186+ val serverUrl: String,
1187+ val username: String,
1188+ val password: String,
1189+ val authRequired: Boolean,
1190+ val connected: Boolean,
1191+ val loading: Boolean,
1192+ val error: String? = null,
1193+ val sessions: List<soy.iko.opencode.core.SessionView>,
1194+ val currentSessionId: String? = null,
1195+ val currentSessionTitle: String,
1196+ val messages: List<soy.iko.opencode.core.MessageView>,
1197+ val draftMessage: String,
1198+ /// True while waiting for the assistant to reply after the user sends.
1199+ val generating: Boolean,
1200+ val crashLogCount: UInt,
1201+ val latestCrashLog: String? = null,
1202+) {
1203+ fun serialize(serializer: Serializer) {
1204+ serializer.increase_container_depth()
1205+ screen.serialize(serializer)
1206+ serializer.serialize_str(serverUrl)
1207+ serializer.serialize_str(username)
1208+ serializer.serialize_str(password)
1209+ serializer.serialize_bool(authRequired)
1210+ serializer.serialize_bool(connected)
1211+ serializer.serialize_bool(loading)
1212+ error.serializeOptionOf(serializer) {
1213+ serializer.serialize_str(it)
1214+ }
1215+ sessions.serialize(serializer) {
1216+ it.serialize(serializer)
1217+ }
1218+ currentSessionId.serializeOptionOf(serializer) {
1219+ serializer.serialize_str(it)
1220+ }
1221+ serializer.serialize_str(currentSessionTitle)
1222+ messages.serialize(serializer) {
1223+ it.serialize(serializer)
1224+ }
1225+ serializer.serialize_str(draftMessage)
1226+ serializer.serialize_bool(generating)
1227+ serializer.serialize_u32(crashLogCount)
1228+ latestCrashLog.serializeOptionOf(serializer) {
1229+ serializer.serialize_str(it)
1230+ }
1231+ serializer.decrease_container_depth()
1232+ }
1233+
1234+ fun bincodeSerialize(): ByteArray {
1235+ val serializer = BincodeSerializer()
1236+ serialize(serializer)
1237+ return serializer.get_bytes()
1238+ }
1239+
1240+ companion object {
1241+ fun deserialize(deserializer: Deserializer): ViewModel {
1242+ deserializer.increase_container_depth()
1243+ val screen = soy.iko.opencode.core.Screen.deserialize(deserializer)
1244+ val serverUrl = deserializer.deserialize_str()
1245+ val username = deserializer.deserialize_str()
1246+ val password = deserializer.deserialize_str()
1247+ val authRequired = deserializer.deserialize_bool()
1248+ val connected = deserializer.deserialize_bool()
1249+ val loading = deserializer.deserialize_bool()
1250+ val error =
1251+ deserializer.deserializeOptionOf {
1252+ deserializer.deserialize_str()
1253+ }
1254+ val sessions =
1255+ deserializer.deserializeListOf {
1256+ soy.iko.opencode.core.SessionView.deserialize(deserializer)
1257+ }
1258+ val currentSessionId =
1259+ deserializer.deserializeOptionOf {
1260+ deserializer.deserialize_str()
1261+ }
1262+ val currentSessionTitle = deserializer.deserialize_str()
1263+ val messages =
1264+ deserializer.deserializeListOf {
1265+ soy.iko.opencode.core.MessageView.deserialize(deserializer)
1266+ }
1267+ val draftMessage = deserializer.deserialize_str()
1268+ val generating = deserializer.deserialize_bool()
1269+ val crashLogCount = deserializer.deserialize_u32()
1270+ val latestCrashLog =
1271+ deserializer.deserializeOptionOf {
1272+ deserializer.deserialize_str()
1273+ }
1274+ deserializer.decrease_container_depth()
1275+ return ViewModel(screen, serverUrl, username, password, authRequired, connected, loading, error, sessions, currentSessionId, currentSessionTitle, messages, draftMessage, generating, crashLogCount, latestCrashLog)
1276+ }
1277+
1278+ @Throws(DeserializationError::class)
1279+ fun bincodeDeserialize(input: ByteArray?): ViewModel {
1280+ if (input == null) {
1281+ throw DeserializationError("Cannot deserialize null array")
1282+ }
1283+ val deserializer = BincodeDeserializer(input)
1284+ val value = deserialize(deserializer)
1285+ if (deserializer.get_buffer_offset() < input.size) {
1286+ throw DeserializationError("Some input bytes were not read")
1287+ }
1288+ return value
1289+ }
1290+ }
1291+}
generated/soy/iko/opencode/shared/MessageStatus.javadeleted−150
@@ -1,150 +0,0 @@
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.javadeleted−131
@@ -1,131 +0,0 @@
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.javadeleted−27
@@ -1,27 +0,0 @@
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.javadeleted−150
@@ -1,150 +0,0 @@
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.javadeleted−77
@@ -1,77 +0,0 @@
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.javadeleted−86
@@ -1,86 +0,0 @@
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.javadeleted−71
@@ -1,71 +0,0 @@
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.javadeleted−185
@@ -1,185 +0,0 @@
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+2−7
@@ -1,10 +1,5 @@
11org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2−org.gradle.caching=true
3−org.gradle.configuration-cache=true
4−
5−# AndroidX
62android.useAndroidX=true
7−android.nonTransitiveRClass=true
8−
9−# Kotlin
103kotlin.code.style=official
4+android.nonTransitiveRClass=true
5+android.suppressUnsupportedCompileSdk=37.0
gradle/libs.versions.toml+30−23
@@ -1,44 +1,51 @@
11[versions]
2−agp = "8.7.3"
3−kotlin = "2.0.21"
4−coreKtx = "1.15.0"
5−lifecycle = "2.8.7"
6−activityCompose = "1.9.3"
7−composeBom = "2024.10.01"
8−coroutines = "1.9.0"
9−serialization = "1.7.3"
10−okhttp = "4.12.0"
2+agp = "8.13.2"
3+kotlin = "2.3.0"
4+coreKtx = "1.17.0"
115junit = "4.13.2"
12−androidxJunit = "1.2.1"
13−
6+junitVersion = "1.3.0"
7+espressoCore = "3.7.0"
8+lifecycleRuntimeKtx = "2.10.0"
9+activityCompose = "1.12.3"
10+composeBom = "2026.01.01"
11+lifecycle = "2.10.0"
12+ktor = "3.1.3"
13+kotlinxCoroutines = "1.10.2"
14+markdownRenderer = "0.43.0"
1415[libraries]
1516androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
16−androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
17−androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
18−androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
19−androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
17+junit = { group = "junit", name = "junit", version.ref = "junit" }
18+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
19+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
20+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
2021androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21−
2222androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
2323androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
2424androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
2525androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
2626androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
27+androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
28+androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
2729androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
2830androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
31+lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
2932
30−kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
31−kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
33+markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3-android", version.ref = "markdownRenderer" }
34+markdown-renderer-core = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-android", version.ref = "markdownRenderer" }
3235
33−okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
34−okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
36+ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
37+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
38+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
39+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
40+ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
41+ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
3542
36−junit = { group = "junit", name = "junit", version.ref = "junit" }
37−kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
38−androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
43+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
44+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
3945
4046[plugins]
4147android-application = { id = "com.android.application", version.ref = "agp" }
48+android-library = { id = "com.android.library", version.ref = "agp" }
4249kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
4350kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
4451kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
settings.gradle.kts+1−3
@@ -21,7 +21,5 @@ dependencyResolutionManagement {
2121}
2222
2323rootProject.name = "opencode-android"
24−
25−// The Crux core lives in `shared/` and is a pure Rust crate (built and tested with
26−// cargo, not Gradle), so the only Gradle module is the Android app shell.
2724include(":app")
25+include(":shared")
shared/Cargo.lock+362−4
@@ -2,12 +2,71 @@
22# It is not intended for manual editing.
33version = 4
44
5+[[package]]
6+name = "aho-corasick"
7+version = "1.1.4"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10+dependencies = [
11+ "memchr",
12+]
13+
514[[package]]
615name = "allocator-api2"
716version = "0.2.21"
817source = "registry+https://github.com/rust-lang/crates.io-index"
918checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
1019
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+
1170[[package]]
1271name = "anyhow"
1372version = "1.0.103"
@@ -46,6 +105,89 @@ dependencies = [
46105 "serde",
47106]
48107
108+[[package]]
109+name = "boltffi"
110+version = "0.27.3"
111+source = "registry+https://github.com/rust-lang/crates.io-index"
112+checksum = "a52185e3efe73ae1e37d4a12ecfc51b03392b3bd6fa677b49bf4e6f4eb30d033"
113+dependencies = [
114+ "boltffi_core",
115+ "boltffi_macros",
116+]
117+
118+[[package]]
119+name = "boltffi_ast"
120+version = "0.27.3"
121+source = "registry+https://github.com/rust-lang/crates.io-index"
122+checksum = "447bff7f815366882b6c7152284cf60b35bda4ee4920c20f9ca702b3de5d517e"
123+dependencies = [
124+ "serde",
125+]
126+
127+[[package]]
128+name = "boltffi_binding"
129+version = "0.27.3"
130+source = "registry+https://github.com/rust-lang/crates.io-index"
131+checksum = "f162e2813a4168530ee144a411921c89b2148e2aedda2327b7f9c46cd57e50d0"
132+dependencies = [
133+ "boltffi_ast",
134+ "serde",
135+ "serde_json",
136+]
137+
138+[[package]]
139+name = "boltffi_core"
140+version = "0.27.3"
141+source = "registry+https://github.com/rust-lang/crates.io-index"
142+checksum = "f6df0e010773b88cf3888511de299425dce2ed0458c9f9d02f454e23590ac1e8"
143+dependencies = [
144+ "boltffi_macros",
145+ "url",
146+ "uuid",
147+]
148+
149+[[package]]
150+name = "boltffi_ffi_rules"
151+version = "0.27.3"
152+source = "registry+https://github.com/rust-lang/crates.io-index"
153+checksum = "8d72f6392c860c87c069c1b11c890d208cae4db143f72230d73ce53155ab2370"
154+dependencies = [
155+ "serde",
156+ "serde_json",
157+ "syn 2.0.118",
158+ "walkdir",
159+]
160+
161+[[package]]
162+name = "boltffi_macros"
163+version = "0.27.3"
164+source = "registry+https://github.com/rust-lang/crates.io-index"
165+checksum = "8b2f35b6e1b94218d5178916bdd59f0c358214c146f3ce189fc96ddaa019d435"
166+dependencies = [
167+ "boltffi_ast",
168+ "boltffi_binding",
169+ "boltffi_ffi_rules",
170+ "boltffi_scan",
171+ "proc-macro2",
172+ "quote",
173+ "syn 2.0.118",
174+]
175+
176+[[package]]
177+name = "boltffi_scan"
178+version = "0.27.3"
179+source = "registry+https://github.com/rust-lang/crates.io-index"
180+checksum = "4476cba632b69b8736ea499a463a8eddc6fd311f320f98601e338ddc0c9476a0"
181+dependencies = [
182+ "boltffi_ast",
183+ "proc-macro2",
184+ "quote",
185+ "serde",
186+ "serde_json",
187+ "syn 2.0.118",
188+ "walkdir",
189+]
190+
49191[[package]]
50192name = "bumpalo"
51193version = "3.20.3"
@@ -54,9 +196,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
54196
55197[[package]]
56198name = "bytes"
57−version = "1.12.1"
199+version = "1.12.0"
58200source = "registry+https://github.com/rust-lang/crates.io-index"
59−checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
201+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
60202
61203[[package]]
62204name = "cfg-if"
@@ -64,6 +206,52 @@ version = "1.0.4"
64206source = "registry+https://github.com/rust-lang/crates.io-index"
65207checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
66208
209+[[package]]
210+name = "clap"
211+version = "4.6.1"
212+source = "registry+https://github.com/rust-lang/crates.io-index"
213+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
214+dependencies = [
215+ "clap_builder",
216+ "clap_derive",
217+]
218+
219+[[package]]
220+name = "clap_builder"
221+version = "4.6.0"
222+source = "registry+https://github.com/rust-lang/crates.io-index"
223+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
224+dependencies = [
225+ "anstream",
226+ "anstyle",
227+ "clap_lex",
228+ "strsim",
229+]
230+
231+[[package]]
232+name = "clap_derive"
233+version = "4.6.1"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
236+dependencies = [
237+ "heck 0.5.0",
238+ "proc-macro2",
239+ "quote",
240+ "syn 2.0.118",
241+]
242+
243+[[package]]
244+name = "clap_lex"
245+version = "1.1.0"
246+source = "registry+https://github.com/rust-lang/crates.io-index"
247+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
248+
249+[[package]]
250+name = "colorchoice"
251+version = "1.0.5"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
254+
67255[[package]]
68256name = "const-fnv1a-hash"
69257version = "1.1.0"
@@ -96,8 +284,10 @@ dependencies = [
96284 "crossbeam-channel",
97285 "crux_macros",
98286 "facet",
287+ "facet_generate",
99288 "futures",
100289 "include_dir 0.7.4",
290+ "log",
101291 "serde",
102292 "serde-generate",
103293 "serde-reflection",
@@ -349,6 +539,26 @@ dependencies = [
349539 "unsynn",
350540]
351541
542+[[package]]
543+name = "facet_generate"
544+version = "0.17.2"
545+source = "registry+https://github.com/rust-lang/crates.io-index"
546+checksum = "4bab57b923bae6e204f5e47643054a9c5ad9c3bd9aadec66df741790de8a859f"
547+dependencies = [
548+ "derive_builder",
549+ "facet",
550+ "facet-generate-attrs",
551+ "heck 0.5.0",
552+ "include_dir 0.7.4",
553+ "indent",
554+ "indoc",
555+ "regex",
556+ "serde",
557+ "serde_json",
558+ "textwrap 0.16.2",
559+ "thiserror 2.0.18",
560+]
561+
352562[[package]]
353563name = "fnv"
354564version = "1.0.7"
@@ -680,6 +890,27 @@ dependencies = [
680890 "quote",
681891]
682892
893+[[package]]
894+name = "indent"
895+version = "0.1.1"
896+source = "registry+https://github.com/rust-lang/crates.io-index"
897+checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
898+
899+[[package]]
900+name = "indoc"
901+version = "2.0.7"
902+source = "registry+https://github.com/rust-lang/crates.io-index"
903+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
904+dependencies = [
905+ "rustversion",
906+]
907+
908+[[package]]
909+name = "is_terminal_polyfill"
910+version = "1.70.2"
911+source = "registry+https://github.com/rust-lang/crates.io-index"
912+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
913+
683914[[package]]
684915name = "itoa"
685916version = "1.0.18"
@@ -709,6 +940,12 @@ version = "0.8.2"
709940source = "registry+https://github.com/rust-lang/crates.io-index"
710941checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
711942
943+[[package]]
944+name = "log"
945+version = "0.4.33"
946+source = "registry+https://github.com/rust-lang/crates.io-index"
947+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
948+
712949[[package]]
713950name = "memchr"
714951version = "2.8.3"
@@ -733,6 +970,12 @@ version = "1.21.4"
733970source = "registry+https://github.com/rust-lang/crates.io-index"
734971checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
735972
973+[[package]]
974+name = "once_cell_polyfill"
975+version = "1.70.2"
976+source = "registry+https://github.com/rust-lang/crates.io-index"
977+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
978+
736979[[package]]
737980name = "percent-encoding"
738981version = "2.3.2"
@@ -885,6 +1128,35 @@ dependencies = [
8851128 "getrandom",
8861129]
8871130
1131+[[package]]
1132+name = "regex"
1133+version = "1.12.4"
1134+source = "registry+https://github.com/rust-lang/crates.io-index"
1135+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1136+dependencies = [
1137+ "aho-corasick",
1138+ "memchr",
1139+ "regex-automata",
1140+ "regex-syntax",
1141+]
1142+
1143+[[package]]
1144+name = "regex-automata"
1145+version = "0.4.14"
1146+source = "registry+https://github.com/rust-lang/crates.io-index"
1147+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1148+dependencies = [
1149+ "aho-corasick",
1150+ "memchr",
1151+ "regex-syntax",
1152+]
1153+
1154+[[package]]
1155+name = "regex-syntax"
1156+version = "0.8.11"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1159+
8881160[[package]]
8891161name = "rustc-hash"
8901162version = "2.1.3"
@@ -903,6 +1175,15 @@ version = "1.0.23"
9031175source = "registry+https://github.com/rust-lang/crates.io-index"
9041176checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
9051177
1178+[[package]]
1179+name = "same-file"
1180+version = "1.0.6"
1181+source = "registry+https://github.com/rust-lang/crates.io-index"
1182+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1183+dependencies = [
1184+ "winapi-util",
1185+]
1186+
9061187[[package]]
9071188name = "serde"
9081189version = "1.0.228"
@@ -924,7 +1205,7 @@ dependencies = [
9241205 "phf",
9251206 "serde",
9261207 "serde-reflection",
927− "textwrap",
1208+ "textwrap 0.13.4",
9281209]
9291210
9301211[[package]]
@@ -999,8 +1280,12 @@ version = "0.1.0"
9991280dependencies = [
10001281 "anyhow",
10011282 "base64",
1283+ "bincode",
1284+ "boltffi",
1285+ "clap",
10021286 "crux_core",
10031287 "crux_http",
1288+ "facet",
10041289 "serde",
10051290 "serde_json",
10061291]
@@ -1081,7 +1366,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
10811366checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
10821367dependencies = [
10831368 "smawk",
1084− "unicode-width",
1369+ "unicode-width 0.1.14",
1370+]
1371+
1372+[[package]]
1373+name = "textwrap"
1374+version = "0.16.2"
1375+source = "registry+https://github.com/rust-lang/crates.io-index"
1376+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1377+dependencies = [
1378+ "smawk",
1379+ "unicode-linebreak",
1380+ "unicode-width 0.2.2",
10851381]
10861382
10871383[[package]]
@@ -1140,6 +1436,12 @@ version = "1.0.24"
11401436source = "registry+https://github.com/rust-lang/crates.io-index"
11411437checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
11421438
1439+[[package]]
1440+name = "unicode-linebreak"
1441+version = "0.1.5"
1442+source = "registry+https://github.com/rust-lang/crates.io-index"
1443+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1444+
11431445[[package]]
11441446name = "unicode-segmentation"
11451447version = "1.13.3"
@@ -1152,6 +1454,12 @@ version = "0.1.14"
11521454source = "registry+https://github.com/rust-lang/crates.io-index"
11531455checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
11541456
1457+[[package]]
1458+name = "unicode-width"
1459+version = "0.2.2"
1460+source = "registry+https://github.com/rust-lang/crates.io-index"
1461+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1462+
11551463[[package]]
11561464name = "unsynn"
11571465version = "0.3.0"
@@ -1181,12 +1489,38 @@ version = "1.0.4"
11811489source = "registry+https://github.com/rust-lang/crates.io-index"
11821490checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
11831491
1492+[[package]]
1493+name = "utf8parse"
1494+version = "0.2.2"
1495+source = "registry+https://github.com/rust-lang/crates.io-index"
1496+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1497+
1498+[[package]]
1499+name = "uuid"
1500+version = "1.23.4"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
1503+dependencies = [
1504+ "js-sys",
1505+ "wasm-bindgen",
1506+]
1507+
11841508[[package]]
11851509name = "version_check"
11861510version = "0.9.5"
11871511source = "registry+https://github.com/rust-lang/crates.io-index"
11881512checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
11891513
1514+[[package]]
1515+name = "walkdir"
1516+version = "2.5.0"
1517+source = "registry+https://github.com/rust-lang/crates.io-index"
1518+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1519+dependencies = [
1520+ "same-file",
1521+ "winapi-util",
1522+]
1523+
11901524[[package]]
11911525name = "wasi"
11921526version = "0.11.1+wasi-snapshot-preview1"
@@ -1248,6 +1582,30 @@ dependencies = [
12481582 "wasm-bindgen",
12491583]
12501584
1585+[[package]]
1586+name = "winapi-util"
1587+version = "0.1.11"
1588+source = "registry+https://github.com/rust-lang/crates.io-index"
1589+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
1590+dependencies = [
1591+ "windows-sys",
1592+]
1593+
1594+[[package]]
1595+name = "windows-link"
1596+version = "0.2.1"
1597+source = "registry+https://github.com/rust-lang/crates.io-index"
1598+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
1599+
1600+[[package]]
1601+name = "windows-sys"
1602+version = "0.61.2"
1603+source = "registry+https://github.com/rust-lang/crates.io-index"
1604+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
1605+dependencies = [
1606+ "windows-link",
1607+]
1608+
12511609[[package]]
12521610name = "writeable"
12531611version = "0.6.3"
shared/Cargo.toml+11−8
@@ -1,31 +1,34 @@
11[package]
22name = "shared"
33version = "0.1.0"
4−edition = "2021"
5−rust-version = "1.85"
4+edition = "2024"
5+rust-version = "1.90"
66license = "Apache-2.0"
7−description = "Crux core for the opencode Android client"
87
98[lib]
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"]
9+crate-type = ["cdylib", "lib", "staticlib"]
1310
1411[[bin]]
1512name = "codegen"
16−required-features = ["typegen"]
13+required-features = ["codegen"]
1714
1815[features]
19−typegen = ["crux_core/typegen", "dep:anyhow"]
16+facet_typegen = ["crux_core/facet_typegen"]
17+codegen = ["facet_typegen", "dep:clap", "dep:anyhow", "crux_core/typegen"]
2018
2119[dependencies]
20+facet = "=0.44"
21+boltffi = "0.27"
2222crux_core = "0.19"
2323crux_http = "0.19"
2424serde = { version = "1", features = ["derive"] }
2525serde_json = "1"
2626base64 = "0.22"
2727
28+# Optional codegen deps
2829anyhow = { version = "1", optional = true }
30+clap = { version = "4", optional = true, features = ["derive"] }
2931
3032[dev-dependencies]
33+bincode = "1"
3134crux_core = { version = "0.19", features = ["testing"] }
shared/build.gradle.ktsnew file+39
@@ -0,0 +1,39 @@
1+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2+
3+plugins {
4+ alias(libs.plugins.android.library)
5+ alias(libs.plugins.kotlin.android)
6+}
7+
8+android {
9+ namespace = "soy.iko.opencode.core"
10+
11+ compileSdk = 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+688−736
@@ -1,203 +1,137 @@
1−//! The Crux `App`: model, events, effects, update logic and view.
2−//!
3−//! Streaming works like this: after a successful connect the shell opens the
4−//! `/event` SSE stream and forwards every decoded event as
5−//! [`Event::ServerEvent`]. The core parses those and mutates the chat
6−//! incrementally — creating the assistant message on `message.updated`, growing
7−//! its text on `message.part.updated`, and clearing the "generating" flag on
8−//! `session.idle`. User messages are shown optimistically the moment they're
9−//! sent, so the stream only ever *adds* assistant content.
10−
111use base64::Engine;
122use crux_core::{
13− macros::effect,
14− render::{render, RenderOperation},
153 App, Command,
4+ macros::effect,
5+ render::{RenderOperation, render},
166};
177use crux_http::command::{Http, RequestBuilder};
188use crux_http::protocol::HttpRequest;
9+use facet::Facet;
1910use serde::{Deserialize, Serialize};
2011
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−
26−// ─── Events ──────────────────────────────────────────────────────────────────
12+// ─── Events ────────────────────────────────────────────────────────────────
2713
14+#[derive(Facet, Serialize, Deserialize, Clone, Debug)]
15+#[repr(C)]
2816pub enum Event {
29− // Lifecycle / connect
17+ // ── Type-generated events (sent from Shell → Core).
18+ // These MUST be contiguous starting at index 0 to match the
19+ // generated Kotlin/Swift/TS enum indices. Do not interleave
20+ // skipped variants here. ──────────────────────────────────────
21+
22+ // Lifecycle
3023 Start,
3124 ServerUrlChanged(String),
3225 UsernameChanged(String),
3326 PasswordChanged(String),
3427 Connect,
28+ CancelAuth,
3529
36− // Sessions
30+ // Session list
3731 LoadSessions,
3832 SelectSession(String),
3933 CreateSession,
40− DeleteSession(String),
4134
4235 // Chat
4336 LoadMessages(String),
44− DraftChanged(String),
4537 SendMessage(String),
46− CancelGeneration,
4738
48− // A raw event line from the shell's `/event` SSE subscription.
49− ServerEvent(String),
39+ // SSE event received
40+ EventReceived(String),
5041
51− // Navigation / misc
42+ // Navigation
43+ NavigateToChat(String),
5244 NavigateToSessions,
5345 NavigateToConnect,
46+
47+ // Errors
5448 DismissError,
5549
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),
50+ // Draft / composer
51+ DraftChanged(String),
52+ // Cancel waiting for the assistant reply (client-side stop).
53+ CancelGeneration,
54+
55+ // Session management
56+ DeleteSession(String),
57+ // Retry sending a failed message by its (local) message id.
58+ RetryMessage(String),
59+
60+ // ── Internal events (skipped from typegen, sent only via then_send).
61+ // These occupy higher discriminant indices that are never
62+ // produced by the shell, so the index gap is harmless. ─────────
63+
64+ #[serde(skip)]
65+ #[facet(skip)]
66+ SessionsLoaded(#[facet(opaque)] HttpResult<String>),
67+ #[serde(skip)]
68+ #[facet(skip)]
69+ SessionCreated(#[facet(opaque)] HttpResult<String>),
70+ #[serde(skip)]
71+ #[facet(skip)]
72+ MessagesLoaded(#[facet(opaque)] HttpResult<String>),
73+ #[serde(skip)]
74+ #[facet(skip)]
75+ MessageSent(#[facet(opaque)] HttpResult<String>),
76+ #[serde(skip)]
77+ #[facet(skip)]
78+ CrashLog(String),
79+ #[serde(skip)]
80+ #[facet(skip)]
81+ HealthProbed(#[facet(opaque)] HttpResult<String>),
82+ #[serde(skip)]
83+ #[facet(skip)]
84+ SessionDeleted(#[facet(opaque)] HttpResult<String>),
6485}
6586
66−// ─── Effects ─────────────────────────────────────────────────────────────────
87+// ─── Effects ───────────────────────────────────────────────────────────────
6788
68−#[effect]
89+#[effect(facet_typegen)]
6990#[derive(Debug)]
7091pub enum Effect {
7192 Render(RenderOperation),
7293 Http(HttpRequest),
7394}
7495
75−// ─── Model ───────────────────────────────────────────────────────────────────
96+// ─── HTTP result wrapper (opaque, not type-generated) ──────────────────────
97+
98+pub type HttpResult<T> = Result<crux_http::Response<T>, crux_http::HttpError>;
99+
100+// ─── Model ─────────────────────────────────────────────────────────────────
76101
77102#[derive(Default)]
78103pub struct Model {
79104 server_url: String,
80105 username: String,
81106 password: String,
82− /// The server answered 401 to an unauthenticated probe — show credentials.
107+ /// Set once the server replies 401 to an unauthenticated probe.
108+ /// The view surfaces this so the shell can show credential fields.
83109 auth_required: bool,
84− /// Credentials were accepted; attach the auth header to every request.
110+ /// Set once a request with credentials succeeds. All subsequent
111+ /// requests carry the Authorization header.
85112 authed: bool,
86113 connected: bool,
87114 loading: bool,
88115 error: Option<String>,
89− screen: Screen,
90−
91− sessions: Vec<SessionView>,
116+ sessions: Vec<SessionSummary>,
92117 current_session_id: Option<String>,
93118 current_session_title: String,
94−
95− messages: Vec<MsgState>,
96− draft: String,
119+ messages: Vec<MessageView>,
120+ draft_message: String,
121+ /// True between sending a message and the assistant reply arriving.
97122 generating: bool,
98−
99− /// Monotonic counter for optimistic (client-side) user-message ids.
100− local_seq: u64,
101−}
102−
103−impl Model {
104− fn message_mut(&mut self, id: &str) -> Option<&mut MsgState> {
105− self.messages.iter_mut().find(|m| m.id == id)
106− }
107−
108− fn has_credentials(&self) -> bool {
109− !self.username.is_empty() || !self.password.is_empty()
110− }
111−
112− fn is_current(&self, session_id: &str) -> bool {
113− self.current_session_id.as_deref() == Some(session_id)
114− }
115−}
116−
117−/// Internal per-message state. Parts are tracked by id so streaming updates can
118−/// upsert them; the [`MessageView`] handed to the shell is derived from these.
119−struct MsgState {
120− id: String,
121− role: String,
122− time: u64,
123− status: MessageStatus,
124− streaming: bool,
125− text_parts: Vec<(String, String)>,
126− reasoning_parts: Vec<(String, String)>,
127− tool_parts: Vec<(String, ToolView)>,
128−}
129−
130−impl MsgState {
131− fn new(id: String, role: String, time: u64) -> Self {
132− Self {
133− id,
134− role,
135− time,
136− status: MessageStatus::Sent,
137− streaming: false,
138− text_parts: Vec::new(),
139− reasoning_parts: Vec::new(),
140− tool_parts: Vec::new(),
141− }
142− }
143−
144− fn upsert_part(parts: &mut Vec<(String, String)>, id: &str, text: String) {
145− if let Some(slot) = parts.iter_mut().find(|(pid, _)| pid == id) {
146− slot.1 = text;
147− } else {
148− parts.push((id.to_string(), text));
149− }
150− }
151−
152− fn upsert_tool(&mut self, id: &str, view: ToolView) {
153− if let Some(slot) = self.tool_parts.iter_mut().find(|(pid, _)| pid == id) {
154− slot.1 = view;
155− } else {
156− self.tool_parts.push((id.to_string(), view));
157− }
158− }
159−
160− fn remove_part(&mut self, part_id: &str) {
161− self.text_parts.retain(|(pid, _)| pid != part_id);
162− self.reasoning_parts.retain(|(pid, _)| pid != part_id);
163− self.tool_parts.retain(|(pid, _)| pid != part_id);
164− }
165−
166− fn to_view(&self) -> MessageView {
167− let join = |parts: &[(String, String)]| {
168− parts.iter().map(|(_, t)| t.as_str()).collect::<String>()
169− };
170− let reasoning = join(&self.reasoning_parts);
171− MessageView {
172− id: self.id.clone(),
173− role: self.role.clone(),
174− text: join(&self.text_parts),
175− reasoning: (!reasoning.is_empty()).then_some(reasoning),
176− tools: self.tool_parts.iter().map(|(_, t)| t.clone()).collect(),
177− time: self.time,
178− status: self.status.clone(),
179− streaming: self.streaming,
180− }
181− }
123+ /// Accumulated crash logs (sent from the shell's uncaught-exception handler)
124+ crash_logs: Vec<String>,
182125}
183126
184−// ─── View model ──────────────────────────────────────────────────────────────
185−
186−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
187−pub enum Screen {
188− #[default]
189− Connect,
190− Sessions,
191− Chat,
192−}
193−
194−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195−pub struct SessionView {
127+#[derive(Clone, Debug, Default)]
128+pub struct SessionSummary {
196129 pub id: String,
197130 pub title: String,
198131}
199132
200−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
133+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
134+#[repr(C)]
201135pub enum MessageStatus {
202136 #[default]
203137 Sent,
@@ -205,26 +139,18 @@ pub enum MessageStatus {
205139 Failed,
206140}
207141
208−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
209−pub struct ToolView {
210− pub name: String,
211− pub status: String,
212− pub title: Option<String>,
213−}
214−
215−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
142+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
216143pub struct MessageView {
217144 pub id: String,
218145 pub role: String,
219146 pub text: String,
220− pub reasoning: Option<String>,
221− pub tools: Vec<ToolView>,
222147 pub time: u64,
223148 pub status: MessageStatus,
224− pub streaming: bool,
225149}
226150
227−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
151+// ─── ViewModel ─────────────────────────────────────────────────────────────
152+
153+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
228154pub struct ViewModel {
229155 pub screen: Screen,
230156 pub server_url: String,
@@ -238,42 +164,70 @@ pub struct ViewModel {
238164 pub current_session_id: Option<String>,
239165 pub current_session_title: String,
240166 pub messages: Vec<MessageView>,
241− pub draft: String,
167+ pub draft_message: String,
168+ /// True while waiting for the assistant to reply after the user sends.
242169 pub generating: bool,
170+ pub crash_log_count: u32,
171+ pub latest_crash_log: Option<String>,
243172}
244173
245−// ─── App ─────────────────────────────────────────────────────────────────────
174+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
175+#[repr(C)]
176+pub enum Screen {
177+ #[default]
178+ Connect,
179+ Sessions,
180+ Chat,
181+}
182+
183+#[derive(Facet, Serialize, Deserialize, Clone, Debug, Default)]
184+pub struct SessionView {
185+ pub id: String,
186+ pub title: String,
187+}
188+
189+// ─── App ───────────────────────────────────────────────────────────────────
246190
247191#[derive(Default)]
248192pub struct OpencodeApp;
249193
250194impl OpencodeApp {
195+ /// Build a GET request to `path`, adding the Basic-Auth header when the
196+ /// model has credentials. The header is always added once `authed` is true
197+ /// (i.e. after the server has accepted credentials), and also during the
198+ /// credential-retry attempt itself.
251199 fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252200 let url = format!("{}{path}", model.server_url);
253− self.with_auth(Http::get(&url).expect_string(), model)
201+ let req = Http::get(&url).expect_string();
202+ self.with_auth(req, model)
254203 }
255204
256− fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
205+ /// Build a POST request to `path` with the given body bytes.
206+ fn post(&self, model: &Model, path: &str, body: Vec<u8>) -> RequestBuilder<Effect, Event, String> {
257207 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);
208+ let req = Http::post(&url).expect_string().body_bytes(body);
263209 self.with_auth(req, model)
264210 }
265211
212+ /// Build a DELETE request to `path`.
266213 fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267214 let url = format!("{}{path}", model.server_url);
268− self.with_auth(Http::delete(&url).expect_string(), model)
215+ let req = Http::delete(&url).expect_string();
216+ self.with_auth(req, model)
269217 }
270218
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>
219+ /// Attach the Basic-Auth `Authorization` header if the model carries
220+ /// credentials. Uses RFC 7617 `Basic` scheme with base64-encoded
221+ /// `username:password`.
222+ fn with_auth<E>(
223+ &self,
224+ req: RequestBuilder<Effect, Event, E>,
225+ model: &Model,
226+ ) -> RequestBuilder<Effect, Event, E>
273227 where
274228 E: 'static,
275229 {
276− if model.has_credentials() {
230+ if !model.username.is_empty() || !model.password.is_empty() {
277231 let creds = format!("{}:{}", model.username, model.password);
278232 let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279233 req.header("Authorization", format!("Basic {encoded}"))
@@ -281,6 +235,13 @@ impl OpencodeApp {
281235 req
282236 }
283237 }
238+
239+ /// Render helper that also stamps the crash-log summary into the view.
240+ fn render_with_crashes(&self, _model: &Model) -> Command<Effect, Event> {
241+ // render() is the base; the view() function reads the model directly
242+ // for crash-log fields, so a plain render suffices.
243+ render()
244+ }
284245}
285246
286247impl App for OpencodeApp {
@@ -291,256 +252,292 @@ impl App for OpencodeApp {
291252
292253 fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
293254 match event {
294− // ── Lifecycle / connect ─────────────────────────────────────────
295− Event::Start => render(),
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.
255+ Event::Start => {
256+ // Don't reset server_url here — the shell restores it from
257+ // persisted storage via ServerUrlChanged before any connect.
258+ render()
259+ }
260+
302261 Event::ServerUrlChanged(url) => {
303262 model.server_url = url;
304− if model.error.is_some() {
305− model.error = None;
306− }
307263 render()
308264 }
265+
309266 Event::UsernameChanged(u) => {
310267 model.username = u;
311− if model.error.is_some() {
312− model.error = None;
313− }
314268 render()
315269 }
270+
316271 Event::PasswordChanged(p) => {
317272 model.password = p;
318− if model.error.is_some() {
319− model.error = None;
320− }
321273 render()
322274 }
323275
324276 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− }
330277 model.loading = true;
331278 model.error = None;
332− model.auth_required = false;
333− render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
279+ // Probe health. `with_auth` attaches credentials if present,
280+ // so a retry after a 401 carries the Authorization header.
281+ let req = self.get(model, "/global/health");
282+ req.build().then_send(|result| Event::HealthProbed(result))
334283 }
335284
336− Event::HealthProbed(result) => self.on_health_probed(model, result),
285+ Event::CancelAuth => {
286+ model.auth_required = false;
287+ model.username.clear();
288+ model.password.clear();
289+ model.loading = false;
290+ render()
291+ }
337292
338− // ── Sessions ────────────────────────────────────────────────────
339293 Event::LoadSessions => {
340294 model.loading = true;
341− render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
295+ self.get(model, "/session").build().then_send(Event::SessionsLoaded)
342296 }
343297
344298 Event::SessionsLoaded(result) => {
345299 model.loading = false;
346− match ok_body(result) {
347− Ok(body) => {
348− model.sessions = protocol::parse_sessions(&body)
349− .into_iter()
350− .map(|s| SessionView {
351− id: s.id,
352− title: display_title(&s.title),
353− })
354− .collect();
355− // Impose a stable alphabetical order so the list doesn't shuffle
356− // with whatever order the server happens to return sessions in.
357− model.sessions.sort_by_key(|s| s.title.to_lowercase());
300+ match result {
301+ Ok(mut response) => {
302+ let body = response.take_body().unwrap_or_default();
303+ model.sessions = parse_sessions(&body);
304+ model.connected = true;
305+ // Refresh the current session's title in case it was
306+ // populated after creation.
307+ if let Some(id) = model.current_session_id.clone() {
308+ if let Some(s) = model.sessions.iter().find(|s| s.id == id) {
309+ model.current_session_title = s.title.clone();
310+ }
311+ }
312+ render()
313+ }
314+ Err(e) => {
315+ model.error = Some(format!("Failed to load sessions: {e}"));
316+ render()
358317 }
359− Err(e) => model.error = Some(e),
360318 }
361− render()
362319 }
363320
364321 Event::SelectSession(id) => {
322+ model.current_session_id = Some(id.clone());
365323 model.current_session_title = model
366324 .sessions
367325 .iter()
368326 .find(|s| s.id == id)
369327 .map(|s| s.title.clone())
370328 .unwrap_or_default();
329+ Command::event(Event::NavigateToChat(id))
330+ }
331+
332+ Event::NavigateToChat(id) => {
371333 model.current_session_id = Some(id.clone());
334+ model.current_session_title = model
335+ .sessions
336+ .iter()
337+ .find(|s| s.id == id)
338+ .map(|s| s.title.clone())
339+ .unwrap_or_default();
372340 model.messages.clear();
373− model.screen = Screen::Chat;
374− model.loading = true;
375− model.error = None;
376341 render().and(Command::event(Event::LoadMessages(id)))
377342 }
378343
379− Event::CreateSession => {
380− model.loading = true;
381− render().and(
382− self.post_json(model, "/session", &serde_json::json!({}))
383− .build()
384− .then_send(Event::SessionCreated),
385− )
344+ Event::NavigateToSessions => {
345+ model.current_session_id = None;
346+ model.current_session_title.clear();
347+ model.messages.clear();
348+ model.generating = false;
349+ render()
386350 }
387351
388− Event::SessionCreated(result) => {
352+ Event::NavigateToConnect => {
353+ // Return to the connect screen to switch servers / credentials.
354+ // Keep the sessions list so a re-connect can refresh in place.
355+ model.current_session_id = None;
356+ model.current_session_title.clear();
357+ model.messages.clear();
358+ model.generating = false;
359+ model.connected = false;
360+ model.auth_required = false;
389361 model.loading = false;
390− match ok_body(result) {
391− Ok(body) => {
392− if let Some(s) = protocol::parse_session(&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− }
400− return render().and(Command::event(Event::SelectSession(s.id)));
401− }
402− model.error = Some("Malformed session response".to_string());
403− }
404− Err(e) => model.error = Some(e),
405− }
406362 render()
407363 }
408364
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
365+ Event::CreateSession => {
366+ model.loading = true;
367+ self.post(model, "/session", Vec::new())
368+ .build()
369+ .then_send(Event::SessionCreated)
419370 }
420371
421− Event::SessionDeleted(result) => {
422− if let Err(e) = ok_body(result) {
423− model.error = Some(e);
372+ Event::SessionCreated(result) => {
373+ model.loading = false;
374+ match result {
375+ Ok(mut response) => {
376+ let body = response.take_body().unwrap_or_default();
377+ let id = parse_session_id(&body);
378+ // Some servers echo the title in the created payload;
379+ // fall back to "New session" if absent.
380+ let title = extract_string(&body, "title")
381+ .filter(|t| !t.is_empty())
382+ .unwrap_or_else(|| "New session".to_string());
383+ model.current_session_title = title.clone();
384+ model.sessions.insert(
385+ 0,
386+ SessionSummary { id: id.clone(), title },
387+ );
388+ Command::event(Event::NavigateToChat(id))
389+ }
390+ Err(e) => {
391+ model.error = Some(format!("Failed to create session: {e}"));
392+ render()
393+ }
424394 }
425− render()
426395 }
427396
428− // ── Chat ────────────────────────────────────────────────────────
429397 Event::LoadMessages(id) => {
430− render().and(
431− self.get(model, &format!("/session/{id}/message"))
432− .build()
433− .then_send(move |result| Event::MessagesLoaded(id.clone(), result)),
434− )
398+ model.loading = true;
399+ let path = format!("/session/{id}/message");
400+ self.get(model, &path).build().then_send(Event::MessagesLoaded)
435401 }
436402
437− Event::MessagesLoaded(session_id, result) => {
438− if !model.is_current(&session_id) {
439− return render();
440− }
403+ Event::MessagesLoaded(result) => {
441404 model.loading = false;
442− match ok_body(result) {
443− Ok(body) => {
444− model.messages = protocol::parse_messages(&body).iter().map(message_from_wire).collect();
445− model.generating = model.messages.iter().any(|m| m.streaming);
405+ model.generating = false;
406+ match result {
407+ Ok(mut response) => {
408+ let body = response.take_body().unwrap_or_default();
409+ model.messages = parse_messages(&body);
410+ render()
411+ }
412+ Err(e) => {
413+ model.error = Some(format!("Failed to load messages: {e}"));
414+ render()
446415 }
447− Err(e) => model.error = Some(e),
448416 }
449− render()
450− }
451−
452− Event::DraftChanged(text) => {
453− model.draft = text;
454− render()
455417 }
456418
457419 Event::SendMessage(text) => {
458− let text = text.trim().to_string();
459420 let Some(session_id) = model.current_session_id.clone() else {
460421 return render();
461422 };
462− if text.is_empty() {
463− return render();
464− }
465− model.local_seq += 1;
466− let mut user = MsgState::new(format!("local-{}", model.local_seq), "user".to_string(), 0);
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();
423+ model.draft_message.clear();
471424 model.generating = true;
472− model.error = None;
473−
474− let body = serde_json::json!({ "parts": [{ "type": "text", "text": text }] });
475− render().and(
476− self.post_json(model, &format!("/session/{session_id}/prompt_async"), &body)
477− .build()
478− .then_send(Event::PromptSent),
479− )
480− }
481−
482− Event::PromptSent(result) => {
483− match ok_body(result) {
484− Ok(_) => set_last_pending(model, MessageStatus::Sent),
425+ let body = serde_json::json!({
426+ "sessionID": session_id,
427+ "parts": [{ "type": "text", "text": text }]
428+ })
429+ .to_string()
430+ .into_bytes();
431+ let path = format!("/session/{session_id}/prompt_async");
432+ self.post(model, &path, body)
433+ .build()
434+ .then_send(Event::MessageSent)
435+ }
436+
437+ Event::MessageSent(result) => {
438+ match result {
439+ Ok(_) => {
440+ // Reload messages to pick up the assistant reply.
441+ if let Some(session_id) = model.current_session_id.clone() {
442+ Command::event(Event::LoadMessages(session_id))
443+ } else {
444+ model.generating = false;
445+ render()
446+ }
447+ }
485448 Err(e) => {
486− set_last_pending(model, MessageStatus::Failed);
487449 model.generating = false;
488− model.error = Some(e);
450+ model.error = Some(format!("Failed to send message: {e}"));
451+ render()
489452 }
490453 }
491− render()
492454 }
493455
494− Event::CancelGeneration => {
495− model.generating = false;
496− match model.current_session_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− ),
502− None => render(),
456+ Event::EventReceived(_data) => {
457+ // SSE event — reload messages for the current session.
458+ if let Some(session_id) = model.current_session_id.clone() {
459+ Command::event(Event::LoadMessages(session_id))
460+ } else {
461+ render()
503462 }
504463 }
505464
506− Event::Aborted(_) => render(),
465+ Event::CrashLog(entry) => {
466+ model.crash_logs.push(entry);
467+ self.render_with_crashes(model)
468+ }
507469
508− // ── SSE ─────────────────────────────────────────────────────────
509− Event::ServerEvent(json) => {
510− if let Some(event) = protocol::parse_event(&json) {
511− apply_server_event(model, event);
512− }
470+ Event::DismissError => {
471+ model.error = None;
513472 render()
514473 }
515474
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))
475+ Event::DraftChanged(text) => {
476+ model.draft_message = text;
477+ render()
523478 }
524479
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;
480+ Event::CancelGeneration => {
481+ // Client-side stop: the server-side generation continues, but
482+ // we stop showing the "generating" state and return to ready.
483+ // A subsequent SSE event / manual refresh will pick up the
484+ // assistant reply when it arrives.
485+ model.generating = false;
531486 render()
532487 }
533488
534− Event::DismissError => {
535− model.error = None;
489+ Event::DeleteSession(id) => {
490+ // Optimistically remove the session from the list. If the
491+ // server delete fails, we reload the session list to restore.
492+ model.sessions.retain(|s| s.id != id);
493+ if model.current_session_id.as_deref() == Some(id.as_str()) {
494+ model.current_session_id = None;
495+ model.current_session_title.clear();
496+ model.messages.clear();
497+ model.generating = false;
498+ }
499+ let path = format!("/session/{id}");
500+ self.delete(model, &path).build().then_send(Event::SessionDeleted)
501+ }
502+
503+ Event::RetryMessage(_msg_id) => {
504+ // No persisted failed-message body to re-send in the core, so
505+ // retry is a no-op here. The shell can surface a hint. This
506+ // event exists so the UI has a stable retry path and so the
507+ // core can grow real per-message retry later without a
508+ // typegen change.
536509 render()
537510 }
511+
512+ // ── Internal events (not type-generated, sent via then_send) ───
513+ Event::HealthProbed(result) => handle_health_probed(self, model, result),
514+ Event::SessionDeleted(result) => {
515+ match result {
516+ Ok(_) => {
517+ render()
518+ }
519+ Err(e) => {
520+ // Optimistic delete failed — reload sessions to
521+ // restore the list, and surface the error.
522+ model.error = Some(format!("Failed to delete session: {e}"));
523+ self.get(model, "/session").build().then_send(Event::SessionsLoaded)
524+ }
525+ }
526+ }
538527 }
539528 }
540529
541− fn view(&self, model: &Model) -> ViewModel {
530+ fn view(&self, model: &Self::Model) -> Self::ViewModel {
531+ let screen = if model.current_session_id.is_some() {
532+ Screen::Chat
533+ } else if model.connected {
534+ Screen::Sessions
535+ } else {
536+ Screen::Connect
537+ };
538+
542539 ViewModel {
543− screen: model.screen.clone(),
540+ screen,
544541 server_url: model.server_url.clone(),
545542 username: model.username.clone(),
546543 password: model.password.clone(),
@@ -548,504 +545,459 @@ impl App for OpencodeApp {
548545 connected: model.connected,
549546 loading: model.loading,
550547 error: model.error.clone(),
551− sessions: model.sessions.clone(),
548+ sessions: model
549+ .sessions
550+ .iter()
551+ .map(|s| SessionView {
552+ id: s.id.clone(),
553+ title: s.title.clone(),
554+ })
555+ .collect(),
552556 current_session_id: model.current_session_id.clone(),
553557 current_session_title: model.current_session_title.clone(),
554− messages: model.messages.iter().map(MsgState::to_view).collect(),
555− draft: model.draft.clone(),
558+ messages: model.messages.clone(),
559+ draft_message: model.draft_message.clone(),
556560 generating: model.generating,
561+ crash_log_count: model.crash_logs.len() as u32,
562+ latest_crash_log: model.crash_logs.last().cloned(),
557563 }
558564 }
559565}
560566
561−impl OpencodeApp {
562− fn on_health_probed(&self, model: &mut Model, result: HttpResult) -> Command<Effect, Event> {
563− let has_creds = model.has_credentials();
564− match result {
565− Ok(response) => {
566− let status = response.status().as_u16();
567− if status == 401 {
568− model.loading = false;
569− if has_creds {
570− model.error = Some("Invalid credentials".to_string());
571− } else {
572− model.auth_required = true;
573− }
574− return render();
575− }
576− if (200..300).contains(&status) {
577− if has_creds {
578− model.authed = true;
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));
567+// ─── Basic-auth detection ──────────────────────────────────────────────────
568+//
569+// The opencode server uses HTTP Basic Auth when OPENCODE_SERVER_PASSWORD is set
570+// (username defaults to "opencode"). We detect this by probing /global/health
571+// without credentials:
572+//
573+// - 200 → server is open; proceed to load sessions.
574+// - 401 → server is behind basic auth; surface `auth_required` and wait for
575+// the user to supply credentials. The user then re-issues Connect,
576+// which now attaches the Authorization header to the same probe.
577+// A 200 on that retry flips `authed` and proceeds.
578+// - Other non-2xx → surface as a connection error.
579+// - Transport error → surface as a connection error.
580+
581+fn handle_health_probed(
582+ app: &OpencodeApp,
583+ model: &mut Model,
584+ result: HttpResult<String>,
585+) -> Command<Effect, Event> {
586+ let has_creds = !model.username.is_empty() || !model.password.is_empty();
587+ match result {
588+ Ok(response) => {
589+ let status = response.status().as_u16();
590+ if status == 401 { model.loading = false;
591+ if has_creds {
592+ // We sent credentials but the server still rejected them.
593+ model.error = Some("Invalid credentials".to_string());
594+ } else {
595+ // No credentials were sent — server is behind basic auth.
596+ // Surface the credential fields and wait for the user.
597+ model.auth_required = true;
586598 }
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()
599+ return render();
595600 }
601+ if (200..300).contains(&status) {
602+ if has_creds {
603+ model.authed = true;
604+ }
605+ model.loading = true;
606+ return app
607+ .get(model, "/session")
608+ .build()
609+ .then_send(Event::SessionsLoaded);
610+ }
611+ // Other status codes — treat as connection error.
612+ model.loading = false;
613+ model.error = Some(format!("Server returned status {status}"));
614+ render()
615+ }
616+ Err(e) => {
617+ model.loading = false;
618+ model.error = Some(format!("Connection failed: {e}"));
619+ render()
596620 }
597621 }
598622}
599623
600−// ─── SSE application ─────────────────────────────────────────────────────────
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;
624+// ─── JSON parsing (minimal, avoids pulling a JSON dep into the core) ───────
625+
626+fn parse_sessions(body: &str) -> Vec<SessionSummary> {
627+ let mut sessions = Vec::new();
628+ let mut depth = 0i32;
629+ let mut start = 0usize;
630+ for (i, ch) in body.char_indices() {
631+ match ch {
632+ '{' => {
633+ if depth == 0 {
634+ start = i;
617635 }
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− }
627− }
628−
629− // Apply only to a message we already track (an assistant message from the
630− // event above). Parts for unknown ids are ignored — see module docs.
631− ServerEvent::MessagePartUpdated { properties } => {
632− let part = properties.part;
633− let delta = properties.delta;
634− let msg_id = part.message_id().to_string();
635− let Some(msg) = model.message_mut(&msg_id) else {
636− return;
637− };
638− match part {
639− WirePart::Text { id, text, synthetic, .. } => {
640− if synthetic {
641− return;
636+ depth += 1;
637+ }
638+ '}' => {
639+ depth -= 1;
640+ if depth == 0 {
641+ let obj = &body[start..=i];
642+ let id = extract_string(obj, "id").unwrap_or_default();
643+ let title = extract_string(obj, "title").unwrap_or_default();
644+ if !id.is_empty() {
645+ sessions.push(SessionSummary {
646+ id,
647+ title: if title.is_empty() {
648+ "Untitled".to_string()
649+ } else {
650+ title
651+ },
652+ });
642653 }
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);
649654 }
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 => {}
655655 }
656+ _ => {}
656657 }
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 => {}
713658 }
659+ sessions
714660}
715661
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− }
662+fn parse_session_id(body: &str) -> String {
663+ extract_string(body, "id").unwrap_or_default()
729664}
730665
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());
666+fn parse_messages(body: &str) -> Vec<MessageView> {
667+ let mut messages = Vec::new();
668+ let mut depth = 0i32;
669+ let mut start = 0usize;
670+ let mut i = 0usize;
671+ let bytes = body.as_bytes();
672+ while i < body.len() {
673+ let ch = bytes[i] as char;
674+ match ch {
675+ '{' => {
676+ if depth == 0 {
677+ start = i;
678+ }
679+ depth += 1;
680+ }
681+ '}' => {
682+ depth -= 1;
683+ if depth == 0 {
684+ let obj = &body[start..=i];
685+ let id = extract_string(obj, "id").unwrap_or_default();
686+ let role = extract_string(obj, "role").unwrap_or_default();
687+ let time = extract_number(obj, "created").unwrap_or(0);
688+ if !id.is_empty() {
689+ let text = extract_message_text(obj);
690+ messages.push(MessageView { id, role, text, time, status: MessageStatus::Sent });
691+ }
740692 }
741693 }
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 => {}
694+ _ => {}
750695 }
696+ i += 1;
751697 }
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− }
698+ messages
759699}
760700
761−/// Trim whitespace and any trailing `/` so paths concatenate cleanly, and
762−/// default to an `http://` scheme when the user didn't type one (e.g.
763−/// "192.168.1.10:4096") — otherwise the shell's URL parser rejects it.
764−fn normalize_url(url: &str) -> String {
765− let trimmed = url.trim().trim_end_matches('/');
766− if trimmed.is_empty() {
767− String::new()
768− } else if trimmed.contains("://") {
769− trimmed.to_string()
770− } else {
771− format!("http://{trimmed}")
701+/// Extract the text from a message part. opencode messages nest the user/assistant
702+/// text inside a `parts` array; we do a shallow scan for `"text":"..."` after the
703+/// first `"parts"` key.
704+fn extract_message_text(obj: &str) -> String {
705+ if let Some(parts_idx) = obj.find("\"parts\"") {
706+ let rest = &obj[parts_idx..];
707+ if let Some(text) = extract_string(rest, "text") {
708+ return text;
709+ }
772710 }
711+ extract_string(obj, "text").unwrap_or_default()
773712}
774713
775−fn display_title(title: &str) -> String {
776− let t = title.trim();
777− if t.is_empty() {
778− "Untitled".to_string()
779− } else {
780− t.to_string()
781− }
714+fn extract_string(json: &str, key: &str) -> Option<String> {
715+ let pattern = format!("\"{key}\"");
716+ let idx = json.find(&pattern)?;
717+ let rest = &json[idx + pattern.len()..];
718+ let colon = rest.find(':')?;
719+ let rest = &rest[colon + 1..];
720+ let quote = rest.find('"')?;
721+ let rest = &rest[quote + 1..];
722+ let end = rest.find('"')?;
723+ Some(rest[..end].to_string())
782724}
783725
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− }
726+fn extract_number(json: &str, key: &str) -> Option<u64> {
727+ let pattern = format!("\"{key}\"");
728+ let idx = json.find(&pattern)?;
729+ let rest = &json[idx + pattern.len()..];
730+ let colon = rest.find(':')?;
731+ let rest = &rest[colon + 1..].trim_start();
732+ let end = rest
733+ .find(|c: char| !c.is_ascii_digit())
734+ .unwrap_or(rest.len());
735+ rest[..end].parse().ok()
797736}
798737
799−// ─── Tests ───────────────────────────────────────────────────────────────────
738+// ─── Tests ─────────────────────────────────────────────────────────────────
800739
801740#[cfg(test)]
802−mod tests {
741+mod test {
803742 use super::*;
804743
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 {
744+ #[test]
745+ fn start_does_not_reset_server_url() {
746+ // Start no longer overwrites server_url — the shell restores the
747+ // persisted value via ServerUrlChanged so the user's last server
748+ // survives a relaunch.
749+ let app = OpencodeApp;
811750 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);
751+ model.server_url = "http://10.0.0.1:4096".to_string();
752+ app.update(Event::Start, &mut model).expect_only_render();
753+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
821754 }
822755
823756 #[test]
824− fn server_url_is_kept_raw_while_typing() {
825− let app = app();
757+ fn server_url_change_updates_model() {
758+ let app = OpencodeApp;
826759 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.
829− let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
830− assert_eq!(app.view(&model).server_url, "http:/");
760+ app.update(Event::Start, &mut model);
761+ app.update(
762+ Event::ServerUrlChanged("http://10.0.0.1:4096".to_string()),
763+ &mut model,
764+ )
765+ .expect_only_render();
766+ assert_eq!(model.server_url, "http://10.0.0.1:4096");
831767 }
832768
833769 #[test]
834− fn connect_normalizes_scheme_less_url() {
835− let app = app();
770+ fn connect_emits_http_effect() {
771+ let app = OpencodeApp;
836772 let mut model = Model::default();
837− // A scheme-less host (what a user often types) must gain http:// on
838− // connect, or the shell's URL parser throws and the attempt crashes.
839− let _ = app.update(Event::ServerUrlChanged("192.168.1.10:4096".into()), &mut model);
840− assert_eq!(app.view(&model).server_url, "192.168.1.10:4096");
841− let _ = app.update(Event::Connect, &mut model);
842− assert_eq!(app.view(&model).server_url, "http://192.168.1.10:4096");
773+ model.server_url = "http://localhost:4096".to_string();
774+ app.update(Event::Start, &mut model);
775+ let mut cmd = app.update(Event::Connect, &mut model);
776+ assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
777+ assert!(model.loading);
843778 }
844779
845780 #[test]
846− fn connect_trims_and_keeps_explicit_scheme() {
847− let app = app();
848− let mut model = Model::default();
849− let _ = app.update(Event::ServerUrlChanged(" https://host:4096/ ".into()), &mut model);
850− let _ = app.update(Event::Connect, &mut model);
851− assert_eq!(app.view(&model).server_url, "https://host:4096");
781+ fn parse_sessions_extracts_id_and_title() {
782+ let json = r#"[{"id":"abc123","title":"My Session"},{"id":"def456","title":"Test"}]"#;
783+ let sessions = parse_sessions(json);
784+ assert_eq!(sessions.len(), 2);
785+ assert_eq!(sessions[0].id, "abc123");
786+ assert_eq!(sessions[0].title, "My Session");
787+ assert_eq!(sessions[1].id, "def456");
852788 }
853789
854790 #[test]
855− fn connect_requires_a_url() {
856− let app = app();
857− let mut model = Model::default();
858− let _ = app.update(Event::Connect, &mut model);
859− assert_eq!(app.view(&model).error.as_deref(), Some("Enter a server URL"));
791+ fn parse_session_id_extracts_id() {
792+ let json = r#"{"id":"xyz789","title":"New"}"#;
793+ let id = parse_session_id(json);
794+ assert_eq!(id, "xyz789");
860795 }
861796
862797 #[test]
863− fn sending_adds_optimistic_user_message_and_sets_generating() {
864− let app = app();
865− let mut model = in_chat("ses_1");
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);
798+ fn extract_string_finds_nested_keys() {
799+ let json = r#"{"a":"b","id":"target","c":"d"}"#;
800+ assert_eq!(extract_string(json, "id"), Some("target".to_string()));
801+ assert_eq!(extract_string(json, "missing"), None);
876802 }
877803
878804 #[test]
879− fn empty_send_is_ignored() {
880− let app = app();
881− let mut model = in_chat("ses_1");
882− let _ = app.update(Event::SendMessage(" ".into()), &mut model);
883− assert!(app.view(&model).messages.is_empty());
884− assert!(!app.view(&model).generating);
805+ fn extract_number_parses_values() {
806+ let json = r#"{"created":1234567890}"#;
807+ assert_eq!(extract_number(json, "created"), Some(1234567890));
885808 }
886809
887810 #[test]
888− fn assistant_reply_streams_in_from_sse() {
889− let app = app();
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− }));
811+ fn navigate_to_sessions_clears_state() {
812+ let app = OpencodeApp;
813+ let mut model = Model::default();
814+ model.current_session_id = Some("test".to_string());
815+ model.current_session_title = "Test".to_string();
816+ model.generating = true;
817+ model.messages.push(MessageView {
818+ id: "m1".to_string(),
819+ role: "user".to_string(),
820+ text: "hello".to_string(),
821+ time: 0,
822+ status: MessageStatus::Sent,
823+ });
824+ app.update(Event::NavigateToSessions, &mut model);
825+ assert!(model.current_session_id.is_none());
826+ assert!(model.current_session_title.is_empty());
827+ assert!(model.messages.is_empty());
828+ assert!(!model.generating);
829+ }
918830
831+ #[test]
832+ fn select_session_records_title_from_sessions_list() {
833+ let app = OpencodeApp;
834+ let mut model = Model::default();
835+ model.sessions = vec![
836+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
837+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
838+ ];
839+ app.update(Event::SelectSession("s2".to_string()), &mut model);
840+ assert_eq!(model.current_session_id.as_deref(), Some("s2"));
841+ assert_eq!(model.current_session_title, "Second");
919842 let view = app.view(&model);
920− assert_eq!(view.messages[0].role, "assistant");
921− assert_eq!(view.messages[0].text, "Hello, world");
843+ assert_eq!(view.current_session_title, "Second");
922844 }
923845
924846 #[test]
925− fn text_delta_accumulates_when_full_text_absent() {
926− let app = app();
927− let mut model = in_chat("ses_1");
928− feed(&app, &mut model, serde_json::json!({
929− "type": "message.updated",
930− "properties": { "info": { "id": "msg_a", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
931− }));
932− for delta in ["Wor", "ld"] {
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");
847+ fn send_message_sets_generating_until_messages_loaded() {
848+ let app = OpencodeApp;
849+ let mut model = Model::default();
850+ model.server_url = "http://localhost:4096".to_string();
851+ model.current_session_id = Some("s1".to_string());
852+ app.update(Event::SendMessage("hello".to_string()), &mut model);
853+ assert!(model.generating);
854+ // Simulate the MessagesLoaded internal event clearing it.
855+ let ok_resp = crux_http::testing::ResponseBuilder::ok().body("[]".to_string()).build();
856+ let result: HttpResult<String> = Ok(ok_resp);
857+ app.update(Event::MessagesLoaded(result), &mut model);
858+ assert!(!model.generating);
942859 }
943860
944861 #[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);
862+ fn failed_send_clears_generating() {
863+ let app = OpencodeApp;
864+ let mut model = Model::default();
865+ model.server_url = "http://localhost:4096".to_string();
866+ model.current_session_id = Some("s1".to_string());
867+ app.update(Event::SendMessage("hello".to_string()), &mut model);
868+ assert!(model.generating);
869+ let err = crux_http::HttpError::Io("boom".to_string());
870+ app.update(Event::MessageSent(Err(err)), &mut model);
871+ assert!(!model.generating);
872+ assert!(model.error.is_some());
956873 }
957874
958875 #[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);
876+ fn cancel_generation_clears_generating() {
877+ let app = OpencodeApp;
878+ let mut model = Model::default();
879+ model.server_url = "http://localhost:4096".to_string();
880+ model.current_session_id = Some("s1".to_string());
881+ app.update(Event::SendMessage("hello".to_string()), &mut model);
882+ assert!(model.generating);
883+ app.update(Event::CancelGeneration, &mut model);
884+ assert!(!model.generating);
968885 }
969886
970887 #[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);
888+ fn draft_changed_updates_model() {
889+ let app = OpencodeApp;
890+ let mut model = Model::default();
891+ app.update(Event::DraftChanged("in progress".to_string()), &mut model);
892+ assert_eq!(model.draft_message, "in progress");
893+ let view = app.view(&model);
894+ assert_eq!(view.draft_message, "in progress");
979895 }
980896
981897 #[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"));
898+ fn delete_session_optimistically_removes_from_list() {
899+ let app = OpencodeApp;
900+ let mut model = Model::default();
901+ model.server_url = "http://localhost:4096".to_string();
902+ model.sessions = vec![
903+ SessionSummary { id: "s1".to_string(), title: "First".to_string() },
904+ SessionSummary { id: "s2".to_string(), title: "Second".to_string() },
905+ ];
906+ let mut cmd = app.update(Event::DeleteSession("s1".to_string()), &mut model);
907+ // Session removed optimistically
908+ assert_eq!(model.sessions.len(), 1);
909+ assert_eq!(model.sessions[0].id, "s2");
910+ // HTTP effect emitted for the server-side delete
911+ assert!(cmd.effects().any(|e| matches!(e, Effect::Http(_))));
1006912 }
1007913
1008914 #[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"));
915+ fn delete_current_session_clears_chat_state() {
916+ let app = OpencodeApp;
917+ let mut model = Model::default();
918+ model.server_url = "http://localhost:4096".to_string();
919+ model.current_session_id = Some("s1".to_string());
920+ model.current_session_title = "First".to_string();
921+ model.generating = true;
922+ model.messages.push(MessageView::default());
923+ app.update(Event::DeleteSession("s1".to_string()), &mut model);
924+ assert!(model.current_session_id.is_none());
925+ assert!(model.current_session_title.is_empty());
926+ assert!(model.messages.is_empty());
927+ assert!(!model.generating);
1017928 }
1018929
1019930 #[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 }
1025− }));
1026− feed(&app, &mut model, serde_json::json!({ "type": "totally.new.event" }));
1027− // No panic, no state change.
1028− assert!(app.view(&model).messages.is_empty());
931+ fn auth_header_built_from_credentials() {
932+ let app = OpencodeApp;
933+ let mut model = Model::default();
934+ model.server_url = "http://localhost:4096".to_string();
935+ model.username = "opencode".to_string();
936+ model.password = "secret".to_string();
937+ // Verify the base64 encoding matches RFC 7617.
938+ let creds = format!("{}:{}", model.username, model.password);
939+ let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
940+ assert_eq!(encoded, "b3BlbmNvZGU6c2VjcmV0");
941+ // Building a request should not panic.
942+ let _ = app.get(&model, "/global/health");
1029943 }
1030944
1031945 #[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−
946+ fn crash_log_event_accumulates() {
947+ let app = OpencodeApp;
948+ let mut model = Model::default();
949+ app.update(Event::CrashLog("boom".to_string()), &mut model);
950+ assert_eq!(model.crash_logs.len(), 1);
1044951 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);
952+ assert_eq!(view.crash_log_count, 1);
953+ assert_eq!(view.latest_crash_log.as_deref(), Some("boom"));
954+ }
955+
956+ /// Verify that the bincode discriminant indices the shell sends match
957+ /// the Rust enum variant positions — the type generator skips
958+ /// `#[facet(skip)]` variants and renumbers the remaining ones, so the
959+ /// shell's index 7 (SelectSession) must land on Rust's SelectSession,
960+ /// not on a skipped variant.
961+ #[test]
962+ fn event_discriminants_match_generated_indices() {
963+ // The generated Kotlin Event enum (see generated/.../Core.kt) maps:
964+ // 0=Start, 1=ServerUrlChanged, 2=UsernameChanged, 3=PasswordChanged,
965+ // 4=Connect, 5=CancelAuth, 6=LoadSessions, 7=SelectSession,
966+ // 8=CreateSession, 9=LoadMessages, 10=SendMessage, 11=EventReceived,
967+ // 12=NavigateToChat, 13=NavigateToSessions, 14=NavigateToConnect,
968+ // 15=DismissError, 16=DraftChanged, 17=CancelGeneration,
969+ // 18=DeleteSession, 19=RetryMessage
970+ //
971+ // Serialize each Rust variant and check the leading u32 index.
972+ let cases: &[(Event, u32)] = &[
973+ (Event::Start, 0),
974+ (Event::ServerUrlChanged(String::new()), 1),
975+ (Event::UsernameChanged(String::new()), 2),
976+ (Event::PasswordChanged(String::new()), 3),
977+ (Event::Connect, 4),
978+ (Event::CancelAuth, 5),
979+ (Event::LoadSessions, 6),
980+ (Event::SelectSession(String::new()), 7),
981+ (Event::CreateSession, 8),
982+ (Event::LoadMessages(String::new()), 9),
983+ (Event::SendMessage(String::new()), 10),
984+ (Event::EventReceived(String::new()), 11),
985+ (Event::NavigateToChat(String::new()), 12),
986+ (Event::NavigateToSessions, 13),
987+ (Event::NavigateToConnect, 14),
988+ (Event::DismissError, 15),
989+ (Event::DraftChanged(String::new()), 16),
990+ (Event::CancelGeneration, 17),
991+ (Event::DeleteSession(String::new()), 18),
992+ (Event::RetryMessage(String::new()), 19),
993+ ];
994+ for (event, expected) in cases {
995+ let bytes = bincode::serialize(event).unwrap();
996+ let idx = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
997+ assert_eq!(
998+ idx, *expected,
999+ "Event discriminant mismatch for {event:?}: got {idx}, expected {expected}"
1000+ );
1001+ }
10501002 }
10511003}
shared/src/bin/codegen.rs+32−27
@@ -1,35 +1,40 @@
1−//! Crux type-generation for the shared domain (built with `--features typegen`).
2−//!
3−//! Demonstrates the Crux type-sharing pipeline: the core's view types are the
4−//! single source of truth, and this binary emits matching JVM type definitions
5−//! for the shell to consume across an FFI boundary. In this NDK-less build the
6−//! Android shell hand-mirrors these types (see `app/.../core/ViewModel.kt`);
7−//! wiring a real `.so` would have it consume the generated output instead.
8−//!
9−//! Run: `cd shared && cargo run --features typegen --bin codegen`
10−//! Output: `generated/` (JVM/Java sources + the bincode serde runtime).
11−
121use std::path::PathBuf;
132
14−use anyhow::Result;
15−use crux_core::typegen::TypeGen;
16−use shared::{MessageStatus, MessageView, Screen, SessionView, ToolView, ViewModel};
3+use clap::{Parser, ValueEnum};
4+use crux_core::type_generation::facet::{Config, TypeRegistry};
5+use shared::OpencodeApp;
6+
7+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
8+enum Language {
9+ Kotlin,
10+}
11+
12+#[derive(Parser)]
13+#[command(version, about, long_about = None)]
14+struct Args {
15+ #[arg(short, long, value_enum)]
16+ language: Language,
17+ #[arg(short, long)]
18+ output_dir: PathBuf,
19+}
20+
21+fn main() -> anyhow::Result<()> {
22+ let args = Args::parse();
1723
18−fn main() -> Result<()> {
19− let mut gen = TypeGen::new();
24+ let typegen_app = TypeRegistry::new()
25+ .register_app::<OpencodeApp>()?
26+ .build()?;
2027
21− // Register leaf types before the aggregates that reference them.
22− gen.register_type::<Screen>()?;
23− gen.register_type::<MessageStatus>()?;
24− gen.register_type::<ToolView>()?;
25− gen.register_type::<SessionView>()?;
26− gen.register_type::<MessageView>()?;
27− gen.register_type::<ViewModel>()?;
28+ let name = match args.language {
29+ Language::Kotlin => "soy.iko.opencode.core",
30+ };
31+ let config = Config::builder(name, &args.output_dir).build();
2832
29− let out = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../generated");
30− std::fs::create_dir_all(&out)?;
31− gen.java("soy.iko.opencode.shared", &out)?;
33+ match args.language {
34+ Language::Kotlin => {
35+ typegen_app.kotlin(&config)?;
36+ }
37+ }
3238
33− println!("Generated shared JVM types into {}", out.display());
3439 Ok(())
3540}
shared/src/ffi.rsnew file+53
@@ -0,0 +1,53 @@
1+use crux_core::{
2+ Core,
3+ bridge::{Bridge, EffectId},
4+};
5+
6+use crate::OpencodeApp;
7+
8+pub struct CoreFfi {
9+ core: Bridge<OpencodeApp>,
10+}
11+
12+impl Default for CoreFfi {
13+ fn default() -> Self {
14+ Self::new()
15+ }
16+}
17+
18+#[boltffi::export]
19+impl CoreFfi {
20+ #[must_use]
21+ pub fn new() -> Self {
22+ Self {
23+ core: Bridge::new(Core::new()),
24+ }
25+ }
26+
27+ #[must_use]
28+ pub fn update(&self, data: &[u8]) -> Vec<u8> {
29+ let mut effects = Vec::new();
30+ match self.core.update(data, &mut effects) {
31+ Ok(()) => effects,
32+ Err(e) => panic!("{e}"),
33+ }
34+ }
35+
36+ #[must_use]
37+ pub fn resolve(&self, id: u32, data: &[u8]) -> Vec<u8> {
38+ let mut effects = Vec::new();
39+ match self.core.resolve(EffectId(id), data, &mut effects) {
40+ Ok(()) => effects,
41+ Err(e) => panic!("{e}"),
42+ }
43+ }
44+
45+ #[must_use]
46+ pub fn view(&self) -> Vec<u8> {
47+ let mut view_model = Vec::new();
48+ match self.core.view(&mut view_model) {
49+ Ok(()) => view_model,
50+ Err(e) => panic!("{e}"),
51+ }
52+ }
53+}
shared/src/lib.rs+2−13
@@ -1,17 +1,6 @@
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.
1+#![allow(clippy::used_underscore_items)]
132
143mod app;
15−mod protocol;
4+pub mod ffi;
165
176pub use app::*;
shared/src/protocol.rsdeleted−248
@@ -1,248 +0,0 @@
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−}