git.iko.soy/opencode-android
on 0605447 help sign in
118 files changed+4174−8129
since splitsnapshots
unifiedsplit
.envrc−1 .github/workflows/build.yml+10−21 .github/workflows/release.yml−113 .gitignore−21 CLAUDE.md−40 README.md+53−135 app/build.gradle.kts+28−47 app/proguard-rules.pro+6−13 app/src/main/AndroidManifest.xml+4−3 app/src/main/java/soy/iko/opencode/CoreViewModel.kt−66 app/src/main/java/soy/iko/opencode/MainActivity.kt+14−13 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+14 app/src/main/java/soy/iko/opencode/core/HttpClient.kt−75 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/SseClient.kt−63 app/src/main/java/soy/iko/opencode/core/ViewModel.kt−77 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+29 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+109 app/src/main/java/soy/iko/opencode/data/model/Common.kt+24 app/src/main/java/soy/iko/opencode/data/model/Files.kt+34 app/src/main/java/soy/iko/opencode/data/model/Message.kt+60 app/src/main/java/soy/iko/opencode/data/model/Part.kt+91 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+43 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+26 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+66 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+18 app/src/main/java/soy/iko/opencode/data/model/Session.kt+28 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+49 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+75 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+79 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+131 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+36 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+118 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+157 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+28 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+39 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+31 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+80−107 app/src/main/java/soy/iko/opencode/ui/Routes.kt+19 app/src/main/java/soy/iko/opencode/ui/VmFactory.kt+16 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+85 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+271 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+167 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+75 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+82 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+70 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+158 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+55 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+131 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+23 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt−479 app/src/main/java/soy/iko/opencode/ui/components/StubScreen.kt+41 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+150 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+79 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+38 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+93 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt−590 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt−222 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt−402 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+107 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+75 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+132 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+52 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+184 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+104 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+96 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt−19 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+21−50 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt−15 app/src/main/res/values-night/colors.xml−4 app/src/main/res/values-night/themes.xml−8 app/src/main/res/values/colors.xml−4 app/src/main/res/values/themes.xml+1−10 app/src/main/res/xml/network_security_config.xml+13 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+107 app/src/test/java/soy/iko/opencode/M6Test.kt+102 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+88 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt−69 build.gradle.kts+1−1 flake.lock+3−3 flake.nix+17−27 generated/com/novi/bincode/BincodeDeserializer.java−38 generated/com/novi/bincode/BincodeSerializer.java−33 generated/com/novi/serde/ArrayLen.java−12 generated/com/novi/serde/BinaryDeserializer.java−195 generated/com/novi/serde/BinarySerializer.java−146 generated/com/novi/serde/Bytes.java−54 generated/com/novi/serde/DeserializationError.java−11 generated/com/novi/serde/Deserializer.java−56 generated/com/novi/serde/Int128.java−11 generated/com/novi/serde/SerializationError.java−11 generated/com/novi/serde/Serializer.java−58 generated/com/novi/serde/Slice.java−40 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/Tuple5.java−61 generated/com/novi/serde/Tuple6.java−67 generated/com/novi/serde/Unit.java−20 generated/com/novi/serde/Unsigned.java−11 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+1−6 gradle/libs.versions.toml+37−25 settings.gradle.kts−3 shared/Cargo.lock−1358 shared/Cargo.toml−31 shared/src/app.rs−1051 shared/src/bin/codegen.rs−35 shared/src/lib.rs−17 shared/src/protocol.rs−248
.envrcdeleted−1
@@ -1 +0,0 @@
1−use flake
.github/workflows/build.yml+10−21
@@ -5,26 +5,8 @@ on:
55 branches: [master]
66 pull_request:
77
8−# Cancel an in-progress run when a newer commit is pushed to the same ref, so CI minutes
9−# aren't spent finishing a build that's already superseded.
10−concurrency:
11− group: ${{ github.workflow }}-${{ github.ref }}
12− cancel-in-progress: true
13−
148jobs:
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−
269 build:
27− name: Android app
2810 runs-on: ubuntu-latest
2911
3012 steps:
@@ -42,15 +24,22 @@ jobs:
4224 - name: Run unit tests
4325 run: ./gradlew testDebugUnitTest --no-daemon
4426
45− - name: Lint
46− run: ./gradlew lintDebug --no-daemon
47−
4827 - name: Build debug APK
4928 run: ./gradlew assembleDebug --no-daemon
5029
30+ - name: Build release APK
31+ run: ./gradlew assembleRelease --no-daemon
32+
5133 - name: Upload debug APK
5234 uses: actions/upload-artifact@v4
5335 with:
5436 name: opencode-debug.apk
5537 path: app/build/outputs/apk/debug/*.apk
5638 if-no-files-found: error
39+
40+ - name: Upload release APK
41+ uses: actions/upload-artifact@v4
42+ with:
43+ name: opencode-release-unsigned.apk
44+ path: app/build/outputs/apk/release/*.apk
45+ if-no-files-found: error
.github/workflows/release.ymldeleted−113
@@ -1,113 +0,0 @@
1−name: Release
2−
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.
18−on:
19− push:
20− branches: [master]
21− tags: ["v*"]
22− workflow_dispatch:
23−
24−# Never abort a release mid-flight — a half-published release with a missing
25−# artifact is worse than a slightly delayed one.
26−concurrency:
27− group: ${{ github.workflow }}-${{ github.ref }}
28− cancel-in-progress: false
29−
30−jobs:
31− release:
32− runs-on: ubuntu-latest
33− 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 }}
42− steps:
43− - uses: actions/checkout@v4
44− with:
45− # Full history so the versionCode (git rev-list --count HEAD) is
46− # accurate instead of collapsing to 1 on a shallow clone.
47− fetch-depth: 0
48−
49− - name: Compute version
50− id: version
51− 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
59− 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)"
66−
67− - uses: actions/setup-java@v4
68− with:
69− distribution: temurin
70− java-version: 17
71−
72− - uses: android-actions/setup-android@v3
73−
74− - uses: gradle/actions/setup-gradle@v4
75−
76− # 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`.
78− - name: Decode signing keystore
79− if: env.OPENCODE_STORE_BASE64 != ''
80− run: |
81− STORE_FILE="$RUNNER_TEMP/release.keystore"
82− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83− echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84−
85− - name: Build release APK
86− run: |
87− ./gradlew assembleRelease \
88− -PversionName="${{ steps.version.outputs.version }}" \
89− -PversionCode="${{ steps.version.outputs.code }}" \
90− --no-daemon
91−
92− # Rename to a stable, version-tagged filename for the release attachment.
93− - name: Stage APK
94− run: |
95− mkdir -p staging
96− APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97− cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98−
99− # Signed build-provenance attestation for the exact artifact being
100− # published; later verifiable with `gh attestation verify <apk>`.
101− - name: Attest build provenance
102− uses: actions/attest-build-provenance@v2
103− with:
104− subject-path: staging/*.apk
105−
106− - name: Create release
107− uses: softprops/action-gh-release@v2
108− with:
109− tag_name: ${{ steps.version.outputs.tag }}
110− name: ${{ steps.version.outputs.version }}
111− make_latest: true
112− generate_release_notes: true
113− files: staging/*.apk
.gitignore−21
@@ -1,15 +1,12 @@
11# Gradle
22.gradle/
33build/
4−*.apk
5−*.aab
64
75# Kotlin
86.kotlin/
97
108# Local configuration (signing keys, api keys, etc.)
119local.properties
12−.buildenv.sh
1310*.keystore
1411*.jks
1512
@@ -31,21 +28,3 @@ Thumbs.db
3128
3229# Nix
3330result
34−result-*
35−.direnv/
36−
37−# Local tooling / editor state (not part of the project)
38−.crush/
39−.crush.db
40−*.log
41−
42−# Rust
43−shared/target/
44−/target/
45−
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.
49−generated/jniLibs/
50−
51−# 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+53−135
@@ -1,153 +1,71 @@
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**.
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
64
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.
5+Application id: `soy.iko.opencode`
106
11−<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
7+## Status
128
13−## Architecture
14−
15−The app follows the Crux pattern: **all application logic lives in a pure,
16−platform-agnostic core; the platform is a thin shell** that renders the core's
17−view model, sends it events, and performs the side effects it requests.
18−
19−```
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−└───────────────────────────────────────────────────────────────────────┘
33−```
34−
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`.
9+Milestones M0–M6 complete:
4610
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.
11+- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12+ of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
13+- **M5:** per-session **model picker** (`GET /config/providers`); **permission prompts** — the
14+ agent's `permission.updated` asks surface as an allow-once / always / reject dialog, answered via
15+ `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
16+ (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
17+ banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
18+- **M6:** **markdown rendering** of assistant text (headings, code blocks, lists, blockquotes, inline
19+ bold/italic/code/links/strikethrough); **rich diff/patch rendering** — tool outputs and file-viewer
20+ content that contain unified diffs are syntax-highlighted (added lines green, removed lines red,
21+ hunk headers); **agents & commands UI** — `GET /agent` powers a per-message agent picker (sent via
22+ the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
23+ command template with its configured agent; **multi-server quick-switch** — the session list title
24+ is a dropdown that lists all saved servers and switches instantly without navigating back.
5125
52−## Project layout
26+## Architecture
5327
28+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
29+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
30+ (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
31+ discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
32+ can't break decoding.
33+- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
34+- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
35+ `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
36+ Compose collects with `collectAsStateWithLifecycle`.
37+- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
38+
39+Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
40+(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
41+
42+## Build
43+
44+```bash
45+nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
46+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
47+./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
5448```
55−flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56−shared/ Crux core (Rust)
57− src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58− src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59− src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60−generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61−app/ Android app (Kotlin + Compose)
62− src/main/java/soy/iko/opencode/
63− core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64− ui/ Compose screens (Connect/Sessions/Chat), theme, components
65− MainActivity.kt, CoreViewModel.kt
66−```
67−
68−## Getting started
69−
70−### Dev shell
7149
72−Everything you need is provided by the Nix flake:
50+## Install & run
7351
74−```sh
75−nix develop # or: direnv allow (an .envrc with `use flake` is included)
52+```bash
53+adb install -r app/build/outputs/apk/debug/app-debug.apk
54+adb shell am start -n soy.iko.opencode/.MainActivity
55+adb logcat --pid=$(adb shell pidof soy.iko.opencode)
7656```
7757
78−This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79−build-tools 35.0.0) on your `PATH`.
58+## Connect to a server
8059
81−### Build & test
60+On the dev host:
8261
83−```sh
84−# Rust core — logic + streaming unit tests
85−cd shared && cargo test
86−
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
62+```bash
63+opencode serve --hostname 0.0.0.0 --port 4096
9264```
9365
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
109−
110−- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111− HTTP Basic auth.
112−- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113−- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114−- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115− streams over SSE).
116−- **Cancel** — `POST /session/{id}/abort`.
117−- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118− `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119− `session.idle` (clears the "generating" state), `session.error`, and
120− `session.{created,updated,deleted}` incrementally. Unknown event types are
121− ignored so new server versions don't break the stream.
122−
123−## Continuous integration & releases
124−
125−- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126− unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127− and builds the debug APK (uploaded as an artifact).
128−- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129− release — it builds a minified, signed release APK and publishes a GitHub
130− Release with the APK attached and a signed build-provenance attestation.
131−
132− ```sh
133− git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134− ```
135−
136− The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137− timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138−
139− **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140− keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141− `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142− build falls back to the debug key, so the APK is still installable.
143−
144−## Notes & limitations
145−
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).
150−
151−## License
66+In the app, add a server with base URL `http://<host-lan-ip>:4096`. If the host is reachable only
67+over USB, use `adb reverse tcp:4096 tcp:4096` and point the app at `http://127.0.0.1:4096`.
15268
153−Apache-2.0.
69+**Auth** is optional: opencode has no auth by default. If the server runs with
70+`OPENCODE_SERVER_PASSWORD=...`, enter username `opencode` (the default) and that password — the app
71+sends HTTP Basic auth. The app permits cleartext HTTP (opencode serves plain `http://`).
app/build.gradle.kts+28−47
@@ -1,22 +1,10 @@
11plugins {
22 alias(libs.plugins.android.application)
33 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.compose)
54 alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.compose)
66}
77
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−
208android {
219 namespace = "soy.iko.opencode"
2210 compileSdk = 35
@@ -25,35 +13,18 @@ android {
2513 applicationId = "soy.iko.opencode"
2614 minSdk = 26
2715 targetSdk = 35
28− versionCode = appVersionCode
29− versionName = appVersionName
30−
16+ versionCode = 1
17+ versionName = "0.1.0"
3118 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3219 }
3320
34− 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− }
42− }
43− }
44−
4521 buildTypes {
4622 release {
47− isMinifyEnabled = true
23+ isMinifyEnabled = false
4824 proguardFiles(
4925 getDefaultProguardFile("proguard-android-optimize.txt"),
5026 "proguard-rules.pro",
5127 )
52− signingConfig = if (hasReleaseKeystore) {
53− signingConfigs.getByName("release")
54− } else {
55− signingConfigs.getByName("debug")
56− }
5728 }
5829 }
5930
@@ -79,24 +50,34 @@ android {
7950
8051dependencies {
8152 implementation(libs.androidx.core.ktx)
82− implementation(libs.androidx.lifecycle.runtime.ktx)
83− implementation(libs.androidx.lifecycle.runtime.compose)
84− implementation(libs.androidx.lifecycle.viewmodel.ktx)
85− implementation(libs.androidx.lifecycle.viewmodel.compose)
8653 implementation(libs.androidx.activity.compose)
54+ implementation(libs.androidx.lifecycle.viewmodel.compose)
55+ implementation(libs.androidx.lifecycle.runtime.compose)
56+ implementation(libs.androidx.navigation.compose)
8757
88− implementation(platform(libs.androidx.compose.bom))
89− implementation(libs.androidx.compose.ui)
90− implementation(libs.androidx.compose.ui.graphics)
91− implementation(libs.androidx.compose.ui.tooling.preview)
92− implementation(libs.androidx.compose.material3)
93− implementation(libs.androidx.compose.material.icons.extended)
94− debugImplementation(libs.androidx.compose.ui.tooling)
58+ implementation(platform(libs.compose.bom))
59+ implementation(libs.compose.ui)
60+ implementation(libs.compose.ui.graphics)
61+ implementation(libs.compose.ui.tooling.preview)
62+ implementation(libs.compose.material3)
63+ implementation(libs.compose.material.icons.extended)
64+ debugImplementation(libs.compose.ui.tooling)
65+
66+ implementation(libs.ktor.client.core)
67+ implementation(libs.ktor.client.okhttp)
68+ implementation(libs.ktor.client.content.negotiation)
69+ implementation(libs.ktor.serialization.kotlinx.json)
70+ implementation(libs.ktor.client.auth)
71+ implementation(libs.ktor.client.logging)
9572
96− implementation(libs.kotlinx.coroutines.android)
9773 implementation(libs.kotlinx.serialization.json)
98− implementation(libs.okhttp)
99− implementation(libs.okhttp.sse)
74+ implementation(libs.kotlinx.coroutines.android)
75+
76+ implementation(libs.androidx.datastore.preferences)
77+ implementation(libs.androidx.security.crypto)
78+ implementation(libs.coil.compose)
79+ implementation(libs.markdown.renderer)
80+ implementation(libs.markdown.renderer.m3)
10081
10182 testImplementation(libs.junit)
10283 testImplementation(libs.kotlinx.coroutines.test)
app/proguard-rules.pro+6−13
@@ -1,16 +1,9 @@
1−# kotlinx.serialization — keep generated serializers and @Serializable metadata.
1+# kotlinx.serialization keeps generated serializers via the plugin; standard rules below.
22-keepattributes *Annotation*, InnerClasses
33-dontnote kotlinx.serialization.**
4−
5−-keepclassmembers class **$$serializer { *; }
6−-keepclasseswithmembers class * {
7− @kotlinx.serialization.Serializable <methods>;
4+-keepclassmembers class soy.iko.opencode.** {
5+ *** Companion;
6+}
7+-keepclasseswithmembers class soy.iko.opencode.** {
8+ kotlinx.serialization.KSerializer serializer(...);
89}
9−-if @kotlinx.serialization.Serializable class **
10−-keep, allowobfuscation, allowshrinking class <1>
11−
12−# OkHttp / okio — platform-specific bits referenced reflectively.
13−-dontwarn okhttp3.internal.platform.**
14−-dontwarn org.conscrypt.**
15−-dontwarn org.bouncycastle.**
16−-dontwarn org.openjsse.**
app/src/main/AndroidManifest.xml+4−3
@@ -2,12 +2,12 @@
22<manifest xmlns:android="http://schemas.android.com/apk/res/android">
33
44 <uses-permission android:name="android.permission.INTERNET" />
5− <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
65
76 <application
7+ android:name=".OpencodeApp"
88 android:allowBackup="true"
9− android:icon="@android:drawable/sym_def_app_icon"
109 android:label="@string/app_name"
10+ android:networkSecurityConfig="@xml/network_security_config"
1111 android:supportsRtl="true"
1212 android:theme="@style/Theme.OpencodeAndroid"
1313 android:usesCleartextTraffic="true">
@@ -15,7 +15,7 @@
1515 <activity
1616 android:name=".MainActivity"
1717 android:exported="true"
18− android:windowSoftInputMode="adjustResize"
18+ android:label="@string/app_name"
1919 android:theme="@style/Theme.OpencodeAndroid">
2020 <intent-filter>
2121 <action android:name="android.intent.action.MAIN" />
@@ -23,4 +23,5 @@
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/MainActivity.kt+14−13
@@ -2,29 +2,30 @@ 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.compose.foundation.isSystemInDarkTheme
88import androidx.compose.runtime.getValue
99import androidx.lifecycle.compose.collectAsStateWithLifecycle
10−import androidx.lifecycle.viewmodel.compose.viewModel
11−import soy.iko.opencode.ui.OpencodeApp
10+import soy.iko.opencode.data.repo.ThemeMode
11+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
1212import soy.iko.opencode.ui.theme.OpencodeTheme
1313
1414class MainActivity : ComponentActivity() {
1515 override fun onCreate(savedInstanceState: Bundle?) {
16− enableEdgeToEdge(
17− statusBarStyle = SystemBarStyle.auto(
18− lightScrim = 0xFF0F1115.toInt(),
19− darkScrim = 0xFF0F1115.toInt(),
20− ),
21− )
2216 super.onCreate(savedInstanceState)
17+ enableEdgeToEdge()
18+ val container = (application as OpencodeApp).container
2319 setContent {
24− OpencodeTheme {
25− val vm: CoreViewModel = viewModel()
26− val state by vm.core.view.collectAsStateWithLifecycle()
27− OpencodeApp(state = state, dispatch = vm.core::dispatch)
20+ val themeMode by container.settingsStore.themeMode
21+ .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
22+ val dark = when (themeMode) {
23+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
24+ ThemeMode.LIGHT -> false
25+ ThemeMode.DARK -> true
26+ }
27+ OpencodeTheme(darkTheme = dark) {
28+ OpencodeAppUi(container = container)
2829 }
2930 }
3031 }
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+14
@@ -0,0 +1,14 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import soy.iko.opencode.di.AppContainer
5+
6+class OpencodeApp : Application() {
7+ lateinit var container: AppContainer
8+ private set
9+
10+ override fun onCreate() {
11+ super.onCreate()
12+ container = AppContainer(this)
13+ }
14+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktdeleted−75
@@ -1,75 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.coroutines.Dispatchers
4−import kotlinx.coroutines.suspendCancellableCoroutine
5−import 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)
20−
21−/**
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.
25− */
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()))
57− }
58− }
59−
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− }
66−
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))
71− }
72− }
73− })
74− }
75−}
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/SseClient.ktdeleted−63
@@ -1,63 +0,0 @@
1−package soy.iko.opencode.core
2−
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
10−
11−/**
12− * Long-lived subscription to the opencode `/event` SSE stream.
13− *
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].
18− */
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)
39− }
40−
41− fun close() {
42− source?.cancel()
43− source = null
44− }
45−
46− private val listener = object : EventSourceListener() {
47− override fun onOpen(eventSource: EventSource, response: Response) {
48− onStateChange(true)
49− }
50−
51− override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52− if (data.isNotBlank()) onEvent(data)
53− }
54−
55− override fun onClosed(eventSource: EventSource) {
56− onStateChange(false)
57− }
58−
59− override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60− onStateChange(false)
61− }
62− }
63−}
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/data/model/Agent.ktnew file+29
@@ -0,0 +1,29 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Entry from `GET /agent`. */
6+@Serializable
7+data class Agent(
8+ val name: String,
9+ val description: String? = null,
10+ val mode: String? = null,
11+ val builtIn: Boolean = false,
12+ val prompt: String? = null,
13+) {
14+ val isPrimary: Boolean get() = mode == "primary"
15+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
16+}
17+
18+/** Entry from `GET /command`. */
19+@Serializable
20+data class Command(
21+ val name: String,
22+ val description: String? = null,
23+ val agent: String? = null,
24+ val model: String? = null,
25+ val template: String,
26+ val subtask: Boolean = false,
27+) {
28+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
29+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8+ * Discriminated on `type` (the global discriminator). Unknown events decode to
9+ * [UnknownEvent] so the stream never dies on a new event type.
10+ *
11+ * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12+ * drift across opencode versions; the reducer only reads the fields it needs.
13+ */
14+@Serializable
15+sealed interface BusEvent
16+
17+@Serializable
18+@SerialName("server.connected")
19+data object ServerConnected : BusEvent
20+
21+@Serializable
22+@SerialName("message.updated")
23+data class MessageUpdated(val properties: Props) : BusEvent {
24+ @Serializable
25+ data class Props(val info: MessageInfo)
26+}
27+
28+@Serializable
29+@SerialName("message.part.updated")
30+data class MessagePartUpdated(val properties: Props) : BusEvent {
31+ @Serializable
32+ data class Props(
33+ val part: Part,
34+ val sessionID: String? = null,
35+ val messageID: String? = null,
36+ )
37+}
38+
39+@Serializable
40+@SerialName("message.part.removed")
41+data class MessagePartRemoved(val properties: Props) : BusEvent {
42+ @Serializable
43+ data class Props(
44+ val sessionID: String? = null,
45+ val messageID: String? = null,
46+ val partID: String? = null,
47+ )
48+}
49+
50+@Serializable
51+@SerialName("message.removed")
52+data class MessageRemoved(val properties: Props) : BusEvent {
53+ @Serializable
54+ data class Props(
55+ val sessionID: String? = null,
56+ val messageID: String? = null,
57+ )
58+}
59+
60+@Serializable
61+@SerialName("session.idle")
62+data class SessionIdle(val properties: Props) : BusEvent {
63+ @Serializable
64+ data class Props(val sessionID: String? = null)
65+}
66+
67+@Serializable
68+@SerialName("session.error")
69+data class SessionError(val properties: Props) : BusEvent {
70+ @Serializable
71+ data class Props(
72+ val sessionID: String? = null,
73+ val error: kotlinx.serialization.json.JsonElement? = null,
74+ )
75+}
76+
77+@Serializable
78+@SerialName("session.updated")
79+data class SessionUpdated(val properties: Props) : BusEvent {
80+ @Serializable
81+ data class Props(val info: Session)
82+}
83+
84+@Serializable
85+@SerialName("session.deleted")
86+data class SessionDeleted(val properties: Props) : BusEvent {
87+ @Serializable
88+ data class Props(val info: Session? = null, val sessionID: String? = null)
89+}
90+
91+@Serializable
92+@SerialName("permission.updated")
93+data class PermissionUpdated(val properties: Permission) : BusEvent
94+
95+@Serializable
96+@SerialName("permission.replied")
97+data class PermissionReplied(val properties: Props) : BusEvent {
98+ @Serializable
99+ data class Props(
100+ val sessionID: String? = null,
101+ val permissionID: String? = null,
102+ val response: String? = null,
103+ )
104+}
105+
106+/** Fallback for any event type we don't model. */
107+@Serializable
108+@SerialName("__unknown")
109+data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktnew file+24
@@ -0,0 +1,24 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+@Serializable
6+data class TimeInfo(
7+ val created: Long? = null,
8+ val updated: Long? = null,
9+ val completed: Long? = null,
10+)
11+
12+@Serializable
13+data class Tokens(
14+ val input: Long = 0,
15+ val output: Long = 0,
16+ val reasoning: Long = 0,
17+ val cache: Cache = Cache(),
18+) {
19+ @Serializable
20+ data class Cache(
21+ val read: Long = 0,
22+ val write: Long = 0,
23+ )
24+}
app/src/main/java/soy/iko/opencode/data/model/Files.ktnew file+34
@@ -0,0 +1,34 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Entry from `GET /file?path=` (directory listing). */
6+@Serializable
7+data class FileNode(
8+ val name: String = "",
9+ val path: String = "",
10+ val type: String? = null,
11+) {
12+ val isDirectory: Boolean get() = type == "directory"
13+}
14+
15+/** Response of `GET /file/content?path=`. */
16+@Serializable
17+data class FileContent(
18+ val type: String? = null, // "text" | "binary"
19+ val content: String = "",
20+ val diff: String? = null,
21+ val mimeType: String? = null,
22+ val encoding: String? = null,
23+) {
24+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
25+}
26+
27+/** Entry from `GET /file/status` (VCS status). */
28+@Serializable
29+data class FileStatusEntry(
30+ val path: String = "",
31+ val added: Int = 0,
32+ val removed: Int = 0,
33+ val status: String? = null, // "added" | "deleted" | "modified"
34+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+60
@@ -0,0 +1,60 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonClassDiscriminator
7+
8+/**
9+ * The `info` object of a message. Discriminated on `role`, which differs from the
10+ * global `type` discriminator used by [Part], so this hierarchy overrides it.
11+ */
12+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
13+@Serializable
14+@JsonClassDiscriminator("role")
15+sealed interface MessageInfo {
16+ val id: String
17+ val sessionID: String
18+ val time: TimeInfo?
19+}
20+
21+@Serializable
22+@SerialName("user")
23+data class UserMessage(
24+ override val id: String,
25+ override val sessionID: String,
26+ override val time: TimeInfo? = null,
27+) : MessageInfo
28+
29+@Serializable
30+@SerialName("assistant")
31+data class AssistantMessage(
32+ override val id: String,
33+ override val sessionID: String,
34+ val parentID: String? = null,
35+ val providerID: String? = null,
36+ val modelID: String? = null,
37+ val cost: Double? = null,
38+ val tokens: Tokens? = null,
39+ val error: JsonObject? = null,
40+ override val time: TimeInfo? = null,
41+) : MessageInfo {
42+ /** A run is complete once the server stamps a completion time. */
43+ val isComplete: Boolean get() = time?.completed != null
44+}
45+
46+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
47+@Serializable
48+@SerialName("__unknown")
49+data class UnknownMessage(
50+ override val id: String = "",
51+ override val sessionID: String = "",
52+ override val time: TimeInfo? = null,
53+) : MessageInfo
54+
55+/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
56+@Serializable
57+data class MessageWithParts(
58+ val info: MessageInfo,
59+ val parts: List<Part> = emptyList(),
60+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+91
@@ -0,0 +1,91 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * A message part — a discriminated union on `type` (the global class discriminator).
8+ * Unknown types decode to [UnknownPart] via the registered polymorphic default,
9+ * so the stream survives server additions.
10+ *
11+ * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
12+ * uses to attach streamed parts to the right message.
13+ */
14+@Serializable
15+sealed interface Part {
16+ val id: String
17+ val messageID: String?
18+ val sessionID: String?
19+}
20+
21+@Serializable
22+@SerialName("text")
23+data class TextPart(
24+ override val id: String = "",
25+ override val messageID: String? = null,
26+ override val sessionID: String? = null,
27+ val text: String = "",
28+ val synthetic: Boolean = false,
29+ val ignored: Boolean = false,
30+ val time: TimeInfo? = null,
31+) : Part
32+
33+@Serializable
34+@SerialName("reasoning")
35+data class ReasoningPart(
36+ override val id: String = "",
37+ override val messageID: String? = null,
38+ override val sessionID: String? = null,
39+ val text: String = "",
40+ val time: TimeInfo? = null,
41+) : Part
42+
43+@Serializable
44+@SerialName("tool")
45+data class ToolPart(
46+ override val id: String = "",
47+ override val messageID: String? = null,
48+ override val sessionID: String? = null,
49+ val callID: String = "",
50+ val tool: String = "",
51+ val state: ToolState = ToolPending,
52+) : Part
53+
54+@Serializable
55+@SerialName("file")
56+data class FilePart(
57+ override val id: String = "",
58+ override val messageID: String? = null,
59+ override val sessionID: String? = null,
60+ val mime: String? = null,
61+ val filename: String? = null,
62+ val url: String? = null,
63+ val source: String? = null,
64+) : Part
65+
66+@Serializable
67+@SerialName("step-start")
68+data class StepStartPart(
69+ override val id: String = "",
70+ override val messageID: String? = null,
71+ override val sessionID: String? = null,
72+) : Part
73+
74+@Serializable
75+@SerialName("step-finish")
76+data class StepFinishPart(
77+ override val id: String = "",
78+ override val messageID: String? = null,
79+ override val sessionID: String? = null,
80+ val cost: Double? = null,
81+ val tokens: Tokens? = null,
82+) : Part
83+
84+/** Fallback for an unrecognized `type`. */
85+@Serializable
86+@SerialName("__unknown")
87+data class UnknownPart(
88+ override val id: String = "",
89+ override val messageID: String? = null,
90+ override val sessionID: String? = null,
91+) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktnew file+43
@@ -0,0 +1,43 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+import kotlinx.serialization.json.JsonElement
5+import kotlinx.serialization.json.JsonObject
6+import kotlinx.serialization.json.JsonPrimitive
7+import kotlinx.serialization.json.jsonArray
8+
9+/**
10+ * A permission request from `permission.updated`. The tool detail lives in [type] +
11+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12+ * it's kept as a [JsonElement] and flattened for display.
13+ */
14+@Serializable
15+data class Permission(
16+ val id: String,
17+ val sessionID: String = "",
18+ val type: String? = null,
19+ val pattern: JsonElement? = null,
20+ val messageID: String? = null,
21+ val callID: String? = null,
22+ val title: String? = null,
23+ val metadata: JsonObject? = null,
24+ val time: TimeInfo? = null,
25+) {
26+ val patternText: String?
27+ get() = when (val p = pattern) {
28+ null -> null
29+ is JsonPrimitive -> p.content
30+ else -> runCatching { p.jsonArray.joinToString(", ") { (it as JsonPrimitive).content } }.getOrNull()
31+ }
32+}
33+
34+/** Allowed values for the permission response body. */
35+enum class PermissionResponse(val wire: String) {
36+ ONCE("once"),
37+ ALWAYS("always"),
38+ REJECT("reject"),
39+}
40+
41+/** Body for `POST /session/:id/permissions/:permissionID`. */
42+@Serializable
43+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktnew file+26
@@ -0,0 +1,26 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Body for `POST /session/:id/message`. */
6+@Serializable
7+data class PromptRequest(
8+ val parts: List<PromptPart>,
9+ val model: ModelRef? = null,
10+ val agent: String? = null,
11+)
12+
13+/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14+@Serializable
15+data class PromptPart(
16+ val type: String = "text",
17+ val text: String,
18+)
19+
20+/** Body for `POST /session/:id/command`. */
21+@Serializable
22+data class CommandRequest(
23+ val command: String,
24+ val arguments: String = "",
25+ val agent: String? = null,
26+)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Response of `GET /config/providers`. */
6+@Serializable
7+data class ProvidersResponse(
8+ val providers: List<Provider> = emptyList(),
9+ val default: Map<String, String> = emptyMap(),
10+)
11+
12+@Serializable
13+data class Provider(
14+ val id: String,
15+ val name: String? = null,
16+ val models: Map<String, ModelInfo> = emptyMap(),
17+) {
18+ val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
19+}
20+
21+@Serializable
22+data class ModelInfo(
23+ val id: String? = null,
24+ val name: String? = null,
25+) {
26+ fun displayName(modelKey: String): String =
27+ name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
28+}
29+
30+/** A flattened provider/model selection used by the prompt body and the model picker. */
31+@Serializable
32+data class ModelRef(
33+ val providerID: String,
34+ val modelID: String,
35+)
36+
37+/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
38+data class ModelOption(
39+ val providerID: String,
40+ val modelID: String,
41+ val providerLabel: String,
42+ val modelLabel: String,
43+) {
44+ val ref: ModelRef get() = ModelRef(providerID, modelID)
45+}
46+
47+/** Flatten providers into a sorted list of pickable options. */
48+fun ProvidersResponse.toOptions(): List<ModelOption> =
49+ providers.flatMap { provider ->
50+ provider.models.map { (modelKey, info) ->
51+ ModelOption(
52+ providerID = provider.id,
53+ modelID = info.id ?: modelKey,
54+ providerLabel = provider.displayName,
55+ modelLabel = info.displayName(modelKey),
56+ )
57+ }
58+ }.sortedWith(compareBy({ it.providerLabel.lowercase() }, { it.modelLabel.lowercase() }))
59+
60+/** The default option to preselect: the server's default for some provider, else the first model. */
61+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
62+ default.entries.firstOrNull()?.let { (providerID, modelID) ->
63+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
64+ }
65+ return options.firstOrNull()
66+}
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktnew file+18
@@ -0,0 +1,18 @@
1+package soy.iko.opencode.data.model
2+
3+/**
4+ * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
5+ * separately from EncryptedSharedPreferences and is null when not loaded/needed.
6+ */
7+data class ServerProfile(
8+ val id: String,
9+ val label: String,
10+ val baseUrl: String,
11+ val username: String? = null,
12+ val password: String? = null,
13+ val lastUsed: Long = 0,
14+) {
15+ val hasAuth: Boolean get() = !username.isNullOrBlank()
16+
17+ val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
18+}
app/src/main/java/soy/iko/opencode/data/model/Session.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** A session as returned by `GET /session` and `POST /session`. */
6+@Serializable
7+data class Session(
8+ val id: String,
9+ val parentID: String? = null,
10+ val title: String? = null,
11+ val version: String? = null,
12+ val time: TimeInfo? = null,
13+) {
14+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
15+}
16+
17+/** Body for `POST /session`. */
18+@Serializable
19+data class CreateSessionRequest(
20+ val parentID: String? = null,
21+ val title: String? = null,
22+)
23+
24+/** Body for `PATCH /session/:id`. */
25+@Serializable
26+data class UpdateSessionRequest(
27+ val title: String? = null,
28+)
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonClassDiscriminator
7+
8+/**
9+ * The state machine of a tool call, discriminated on `status`.
10+ * pending -> running -> completed | error
11+ */
12+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
13+@Serializable
14+@JsonClassDiscriminator("status")
15+sealed interface ToolState
16+
17+@Serializable
18+@SerialName("pending")
19+data object ToolPending : ToolState
20+
21+@Serializable
22+@SerialName("running")
23+data class ToolRunning(
24+ val input: JsonElement? = null,
25+ val title: String? = null,
26+ val time: TimeInfo? = null,
27+) : ToolState
28+
29+@Serializable
30+@SerialName("completed")
31+data class ToolCompleted(
32+ val input: JsonElement? = null,
33+ val output: String? = null,
34+ val title: String? = null,
35+ val time: TimeInfo? = null,
36+) : ToolState
37+
38+@Serializable
39+@SerialName("error")
40+data class ToolError(
41+ val input: JsonElement? = null,
42+ val error: String? = null,
43+ val time: TimeInfo? = null,
44+) : ToolState
45+
46+/** Fallback for an unrecognized `status`. */
47+@Serializable
48+@SerialName("__unknown")
49+data object ToolUnknown : ToolState
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.ServerConnected
5+import io.ktor.client.HttpClient
6+import io.ktor.client.plugins.sse.sse
7+import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.delay
9+import kotlinx.coroutines.flow.Flow
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.SharedFlow
12+import kotlinx.coroutines.flow.StateFlow
13+import kotlinx.coroutines.flow.SharingStarted
14+import kotlinx.coroutines.flow.asStateFlow
15+import kotlinx.coroutines.flow.channelFlow
16+import kotlinx.coroutines.flow.shareIn
17+import kotlinx.coroutines.isActive
18+
19+/**
20+ * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
21+ * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
22+ * subscribes (WhileSubscribed) and reconnects with backoff on drop.
23+ *
24+ * Ordering contract: callers that send a prompt must already be collecting [events]
25+ * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
26+ */
27+class EventStreamClient(
28+ private val client: HttpClient,
29+ scope: CoroutineScope,
30+) {
31+ enum class ConnectionState { Disconnected, Connecting, Connected }
32+
33+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
34+ val state: StateFlow<ConnectionState> = _state.asStateFlow()
35+
36+ val events: SharedFlow<BusEvent> = stream()
37+ .shareIn(
38+ scope = scope,
39+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
40+ replay = 0,
41+ )
42+
43+ private fun stream(): Flow<BusEvent> = channelFlow {
44+ var backoffMs = INITIAL_BACKOFF_MS
45+ while (isActive) {
46+ _state.value = ConnectionState.Connecting
47+ try {
48+ client.sse("event") {
49+ backoffMs = INITIAL_BACKOFF_MS
50+ incoming.collect { sse ->
51+ val data = sse.data ?: return@collect
52+ val event = runCatching { OpencodeJson.decodeFromString(BusEvent.serializer(), data) }
53+ .getOrNull() ?: return@collect
54+ if (event is ServerConnected) {
55+ _state.value = ConnectionState.Connected
56+ }
57+ send(event)
58+ }
59+ }
60+ } catch (t: Throwable) {
61+ if (!isActive) throw t
62+ // fall through to reconnect
63+ }
64+ _state.value = ConnectionState.Disconnected
65+ if (!isActive) break
66+ delay(backoffMs)
67+ backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
68+ }
69+ }
70+
71+ private companion object {
72+ const val INITIAL_BACKOFF_MS = 500L
73+ const val MAX_BACKOFF_MS = 10_000L
74+ }
75+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import io.ktor.client.HttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.auth.Auth
8+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9+import io.ktor.client.plugins.auth.providers.basic
10+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+import io.ktor.client.plugins.defaultRequest
12+import io.ktor.client.plugins.sse.SSE
13+import io.ktor.http.URLBuilder
14+import io.ktor.http.takeFrom
15+import io.ktor.serialization.kotlinx.json.json
16+import java.util.concurrent.TimeUnit
17+
18+/**
19+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
20+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
21+ * SSE plugin used for `GET /event`.
22+ */
23+object HttpClientFactory {
24+
25+ fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
26+ expectSuccess = true
27+
28+ engine {
29+ config {
30+ // The SSE stream is long-lived; never time it out on read.
31+ readTimeout(0, TimeUnit.MILLISECONDS)
32+ connectTimeout(30, TimeUnit.SECONDS)
33+ retryOnConnectionFailure(true)
34+ pingInterval(20, TimeUnit.SECONDS)
35+ }
36+ }
37+
38+ install(ContentNegotiation) {
39+ json(OpencodeJson)
40+ }
41+
42+ install(SSE)
43+
44+ install(HttpTimeout) {
45+ connectTimeoutMillis = 30_000
46+ requestTimeoutMillis = 60_000
47+ }
48+
49+ if (profile.hasAuth) {
50+ install(Auth) {
51+ basic {
52+ credentials {
53+ BasicAuthCredentials(
54+ username = profile.username.orEmpty(),
55+ password = profile.password.orEmpty(),
56+ )
57+ }
58+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
59+ sendWithoutRequest { true }
60+ }
61+ }
62+ }
63+
64+ defaultRequest {
65+ url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
66+ }
67+ }
68+
69+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly. */
70+ fun normalizeBaseUrl(raw: String): String {
71+ val trimmed = raw.trim()
72+ val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
73+ trimmed
74+ } else {
75+ "http://$trimmed"
76+ }
77+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
78+ }
79+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+131
@@ -0,0 +1,131 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.Agent
4+import soy.iko.opencode.data.model.Command
5+import soy.iko.opencode.data.model.CommandRequest
6+import soy.iko.opencode.data.model.CreateSessionRequest
7+import soy.iko.opencode.data.model.FileContent
8+import soy.iko.opencode.data.model.FileNode
9+import soy.iko.opencode.data.model.FileStatusEntry
10+import soy.iko.opencode.data.model.MessageWithParts
11+import soy.iko.opencode.data.model.ModelRef
12+import soy.iko.opencode.data.model.PermissionReplyBody
13+import soy.iko.opencode.data.model.PermissionResponse
14+import soy.iko.opencode.data.model.PromptPart
15+import soy.iko.opencode.data.model.PromptRequest
16+import soy.iko.opencode.data.model.ProvidersResponse
17+import soy.iko.opencode.data.model.Session
18+import soy.iko.opencode.data.model.UpdateSessionRequest
19+import io.ktor.client.HttpClient
20+import io.ktor.client.call.body
21+import io.ktor.client.request.delete
22+import io.ktor.client.request.get
23+import io.ktor.client.request.parameter
24+import io.ktor.client.request.patch
25+import io.ktor.client.request.post
26+import io.ktor.client.request.setBody
27+import io.ktor.http.ContentType
28+import io.ktor.http.contentType
29+
30+/**
31+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
32+ * SSE stream lives in [EventStreamClient]; everything else is here.
33+ */
34+class OpencodeApiClient(private val client: HttpClient) {
35+
36+ /** Lightweight connectivity check used by the connect screen. */
37+ suspend fun ping(): Boolean {
38+ client.get("global/health")
39+ return true
40+ }
41+
42+ suspend fun listSessions(): List<Session> =
43+ client.get("session").body()
44+
45+ suspend fun createSession(title: String? = null): Session =
46+ client.post("session") {
47+ contentType(ContentType.Application.Json)
48+ setBody(CreateSessionRequest(title = title))
49+ }.body()
50+
51+ suspend fun updateSession(id: String, title: String): Session =
52+ client.patch("session/$id") {
53+ contentType(ContentType.Application.Json)
54+ setBody(UpdateSessionRequest(title = title))
55+ }.body()
56+
57+ suspend fun deleteSession(id: String) {
58+ client.delete("session/$id")
59+ }
60+
61+ suspend fun listMessages(sessionId: String): List<MessageWithParts> =
62+ client.get("session/$sessionId/message").body()
63+
64+ suspend fun sendPrompt(
65+ sessionId: String,
66+ text: String,
67+ model: ModelRef? = null,
68+ agent: String? = null,
69+ ): MessageWithParts =
70+ client.post("session/$sessionId/message") {
71+ contentType(ContentType.Application.Json)
72+ setBody(
73+ PromptRequest(
74+ parts = listOf(PromptPart(text = text)),
75+ model = model,
76+ agent = agent,
77+ ),
78+ )
79+ }.body()
80+
81+ suspend fun abort(sessionId: String) {
82+ client.post("session/$sessionId/abort")
83+ }
84+
85+ /** Invoke a slash-command by name via `POST /session/:id/command`. */
86+ suspend fun runCommand(
87+ sessionId: String,
88+ command: String,
89+ arguments: String = "",
90+ agent: String? = null,
91+ ): MessageWithParts =
92+ client.post("session/$sessionId/command") {
93+ contentType(ContentType.Application.Json)
94+ setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
95+ }.body()
96+
97+ suspend fun providers(): ProvidersResponse =
98+ client.get("config/providers").body()
99+
100+ suspend fun agents(): List<Agent> =
101+ client.get("agent").body()
102+
103+ suspend fun commands(): List<Command> =
104+ client.get("command").body()
105+
106+ /** Respond to a permission request so a paused tool run can proceed. */
107+ suspend fun respondPermission(
108+ sessionId: String,
109+ permissionId: String,
110+ response: PermissionResponse,
111+ ) {
112+ client.post("session/$sessionId/permissions/$permissionId") {
113+ contentType(ContentType.Application.Json)
114+ setBody(PermissionReplyBody(response.wire))
115+ }
116+ }
117+
118+ // --- Files ---
119+
120+ suspend fun findFiles(query: String): List<String> =
121+ client.get("find/file") { parameter("query", query) }.body()
122+
123+ suspend fun listDirectory(path: String): List<FileNode> =
124+ client.get("file") { parameter("path", path) }.body()
125+
126+ suspend fun readFile(path: String): FileContent =
127+ client.get("file/content") { parameter("path", path) }.body()
128+
129+ suspend fun fileStatus(): List<FileStatusEntry> =
130+ client.get("file/status").body()
131+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.Part
6+import soy.iko.opencode.data.model.ToolState
7+import soy.iko.opencode.data.model.UnknownEvent
8+import soy.iko.opencode.data.model.UnknownMessage
9+import soy.iko.opencode.data.model.UnknownPart
10+import soy.iko.opencode.data.model.ToolUnknown
11+import kotlinx.serialization.ExperimentalSerializationApi
12+import kotlinx.serialization.json.Json
13+import kotlinx.serialization.modules.SerializersModule
14+
15+/**
16+ * Single shared Json for the whole app.
17+ *
18+ * The polymorphic default deserializers are the resilience layer: when the server
19+ * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20+ * Unknown variant instead of throwing — so a new opencode release can't break the
21+ * event stream or message decoding.
22+ */
23+@OptIn(ExperimentalSerializationApi::class)
24+val OpencodeJson: Json = Json {
25+ ignoreUnknownKeys = true
26+ isLenient = true
27+ coerceInputValues = true
28+ explicitNulls = false
29+ classDiscriminator = "type"
30+ serializersModule = SerializersModule {
31+ polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
32+ polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
33+ polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
34+ polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
35+ }
36+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+118
@@ -0,0 +1,118 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.content.SharedPreferences
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import androidx.security.crypto.EncryptedSharedPreferences
9+import androidx.security.crypto.MasterKey
10+import soy.iko.opencode.data.model.ServerProfile
11+import soy.iko.opencode.data.network.OpencodeJson
12+import kotlinx.coroutines.flow.Flow
13+import kotlinx.coroutines.flow.map
14+import kotlinx.serialization.Serializable
15+import kotlinx.serialization.builtins.ListSerializer
16+
17+private val Context.dataStore by preferencesDataStore(name = "server_profiles")
18+
19+/**
20+ * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
21+ * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
22+ * by profile id, and merged back in on read.
23+ */
24+class ProfileStore(context: Context) {
25+
26+ private val appContext = context.applicationContext
27+ private val profilesKey = stringPreferencesKey("profiles_json")
28+
29+ private val securePrefs: SharedPreferences by lazy {
30+ val masterKey = MasterKey.Builder(appContext)
31+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
32+ .build()
33+ EncryptedSharedPreferences.create(
34+ appContext,
35+ "server_secrets",
36+ masterKey,
37+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
38+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
39+ )
40+ }
41+
42+ /** Stored shape on DataStore (everything except the secret password). */
43+ @Serializable
44+ private data class StoredProfile(
45+ val id: String,
46+ val label: String,
47+ val baseUrl: String,
48+ val username: String? = null,
49+ val lastUsed: Long = 0,
50+ )
51+
52+ val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
53+ val json = prefs[profilesKey] ?: return@map emptyList()
54+ runCatching {
55+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
56+ }.getOrDefault(emptyList())
57+ .sortedByDescending { it.lastUsed }
58+ .map { it.toProfile() }
59+ }
60+
61+ private fun StoredProfile.toProfile() = ServerProfile(
62+ id = id,
63+ label = label,
64+ baseUrl = baseUrl,
65+ username = username,
66+ password = username?.let { passwordFor(id) },
67+ lastUsed = lastUsed,
68+ )
69+
70+ /** Resolve a single profile with its password loaded, for opening a connection. */
71+ suspend fun resolve(profile: ServerProfile): ServerProfile =
72+ profile.copy(password = if (profile.hasAuth) passwordFor(profile.id) else null)
73+
74+ fun passwordFor(id: String): String? = securePrefs.getString(passwordKey(id), null)
75+
76+ suspend fun save(profile: ServerProfile) {
77+ appContext.dataStore.edit { prefs ->
78+ val current = prefs[profilesKey]?.let {
79+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
80+ .getOrDefault(emptyList())
81+ } ?: emptyList()
82+ val stored = StoredProfile(
83+ id = profile.id,
84+ label = profile.label,
85+ baseUrl = profile.baseUrl,
86+ username = profile.username?.takeIf { it.isNotBlank() },
87+ lastUsed = profile.lastUsed,
88+ )
89+ val updated = current.filterNot { it.id == profile.id } + stored
90+ prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
91+ }
92+ // Secret goes to encrypted storage (or is cleared when auth removed).
93+ securePrefs.edit().apply {
94+ val pw = profile.password
95+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
96+ putString(passwordKey(profile.id), pw)
97+ } else {
98+ remove(passwordKey(profile.id))
99+ }
100+ }.apply()
101+ }
102+
103+ suspend fun delete(id: String) {
104+ appContext.dataStore.edit { prefs ->
105+ val current = prefs[profilesKey]?.let {
106+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
107+ .getOrDefault(emptyList())
108+ } ?: emptyList()
109+ prefs[profilesKey] = OpencodeJson.encodeToString(
110+ ListSerializer(StoredProfile.serializer()),
111+ current.filterNot { it.id == id },
112+ )
113+ }
114+ securePrefs.edit().remove(passwordKey(id)).apply()
115+ }
116+
117+ private fun passwordKey(id: String) = "pw_$id"
118+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktnew file+157
@@ -0,0 +1,157 @@
1+package soy.iko.opencode.data.repo
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.MessagePartRemoved
6+import soy.iko.opencode.data.model.MessagePartUpdated
7+import soy.iko.opencode.data.model.MessageRemoved
8+import soy.iko.opencode.data.model.MessageUpdated
9+import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ModelRef
11+import soy.iko.opencode.data.model.Part
12+import soy.iko.opencode.data.model.SessionError
13+import soy.iko.opencode.data.model.SessionIdle
14+import soy.iko.opencode.data.network.EventStreamClient
15+import soy.iko.opencode.data.network.OpencodeApiClient
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.channelFlow
18+import kotlinx.coroutines.launch
19+import kotlinx.coroutines.sync.Mutex
20+import kotlinx.coroutines.sync.withLock
21+
22+/**
23+ * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
24+ * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
25+ * each keyed by message id with parts keyed by part id (idempotent upserts).
26+ */
27+class SessionRepository(
28+ private val api: OpencodeApiClient,
29+ private val eventStream: EventStreamClient,
30+) {
31+ suspend fun listSessions() = api.listSessions()
32+ suspend fun createSession(title: String? = null) = api.createSession(title)
33+ suspend fun deleteSession(id: String) = api.deleteSession(id)
34+ suspend fun abort(sessionId: String) = api.abort(sessionId)
35+
36+ suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
37+ api.sendPrompt(sessionId, text, model, agent)
38+
39+ suspend fun runCommand(sessionId: String, command: String, agent: String? = null) =
40+ api.runCommand(sessionId, command, agent = agent)
41+
42+ /**
43+ * A live, ordered view of [sessionId]'s messages. Begins collecting the event
44+ * stream before the initial REST load so no streamed part is missed, then reduces
45+ * events into the in-memory state.
46+ */
47+ fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
48+ val store = MessageStore()
49+ val lock = Mutex()
50+
51+ suspend fun publish() = send(store.snapshot())
52+
53+ // Subscribe to events first so we don't miss early deltas during the initial load.
54+ val job = launch {
55+ eventStream.events.collect { event ->
56+ lock.withLock {
57+ if (store.reduce(sessionId, event)) publish()
58+ }
59+ }
60+ }
61+
62+ val initial = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
63+ lock.withLock {
64+ store.seed(initial)
65+ publish()
66+ }
67+
68+ // Keep the flow alive until the collector cancels; the launched job is torn down with it.
69+ job.join()
70+ }
71+
72+ /** In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex. */
73+ private class MessageStore {
74+ // messageId -> (info + parts), insertion-ordered.
75+ private val messages = LinkedHashMap<String, MessageWithParts>()
76+
77+ fun snapshot(): List<MessageWithParts> = messages.values.toList()
78+
79+ fun seed(initial: List<MessageWithParts>) {
80+ for (m in initial) messages[m.info.id] = m
81+ }
82+
83+ /** Returns true if the state changed (and a new snapshot should be published). */
84+ fun reduce(sessionId: String, event: BusEvent): Boolean {
85+ return when (event) {
86+ is MessageUpdated -> {
87+ val info = event.properties.info
88+ if (info.sessionID != sessionId) {
89+ false
90+ } else {
91+ val existing = messages[info.id]
92+ messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
93+ true
94+ }
95+ }
96+
97+ is MessagePartUpdated -> {
98+ val part = event.properties.part
99+ val messageId = part.messageID ?: event.properties.messageID
100+ val partSession = part.sessionID ?: event.properties.sessionID
101+ if (messageId == null || (partSession != null && partSession != sessionId)) {
102+ false
103+ } else {
104+ upsertPart(messageId, part)
105+ }
106+ }
107+
108+ is MessagePartRemoved -> {
109+ val messageId = event.properties.messageID
110+ val partId = event.properties.partID
111+ if (messageId == null || partId == null) false else removePart(messageId, partId)
112+ }
113+
114+ is MessageRemoved -> {
115+ val id = event.properties.messageID
116+ if (id == null) false else messages.remove(id) != null
117+ }
118+
119+ else -> false
120+ }
121+ }
122+
123+ private fun upsertPart(messageId: String, part: Part): Boolean {
124+ val current = messages[messageId]
125+ val parts = current?.parts.orEmpty()
126+ val idx = parts.indexOfFirst { it.id == part.id }
127+ val newParts = if (idx >= 0) {
128+ parts.toMutableList().also { it[idx] = part }
129+ } else {
130+ parts + part
131+ }
132+ messages[messageId] = current?.copy(parts = newParts)
133+ ?: MessageWithParts(
134+ info = AssistantMessage(id = messageId, sessionID = part.sessionID ?: ""),
135+ parts = newParts,
136+ )
137+ return true
138+ }
139+
140+ private fun removePart(messageId: String, partId: String): Boolean {
141+ val current = messages[messageId] ?: return false
142+ val newParts = current.parts.filterNot { it.id == partId }
143+ if (newParts.size == current.parts.size) return false
144+ messages[messageId] = current.copy(parts = newParts)
145+ return true
146+ }
147+ }
148+
149+ companion object {
150+ /** Convenience: is this event a run-completion signal for [sessionId]? */
151+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
152+ event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
153+
154+ fun isError(event: BusEvent, sessionId: String): Boolean =
155+ event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
156+ }
157+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.edit
5+import androidx.datastore.preferences.core.stringPreferencesKey
6+import androidx.datastore.preferences.preferencesDataStore
7+import kotlinx.coroutines.flow.Flow
8+import kotlinx.coroutines.flow.map
9+
10+enum class ThemeMode { SYSTEM, LIGHT, DARK }
11+
12+private val Context.settingsDataStore by preferencesDataStore(name = "settings")
13+
14+/** Small app-preferences store (theme mode for now; default agent etc. can join later). */
15+class SettingsStore(context: Context) {
16+
17+ private val appContext = context.applicationContext
18+ private val themeKey = stringPreferencesKey("theme_mode")
19+
20+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
21+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
22+ .getOrDefault(ThemeMode.SYSTEM)
23+ }
24+
25+ suspend fun setThemeMode(mode: ThemeMode) {
26+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
27+ }
28+}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+39
@@ -0,0 +1,39 @@
1+package soy.iko.opencode.di
2+
3+import android.content.Context
4+import soy.iko.opencode.data.model.ServerProfile
5+import soy.iko.opencode.data.repo.ProfileStore
6+import soy.iko.opencode.data.repo.SettingsStore
7+import kotlinx.coroutines.CoroutineScope
8+import kotlinx.coroutines.Dispatchers
9+import kotlinx.coroutines.SupervisorJob
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+
14+/**
15+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
16+ * process-wide singletons and the currently active [OpencodeConnection].
17+ */
18+class AppContainer(context: Context) {
19+
20+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
21+ val profileStore = ProfileStore(context)
22+ val settingsStore = SettingsStore(context)
23+
24+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
25+ val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
26+
27+ /** Open (or re-open) a connection to [profile], replacing any current one. */
28+ suspend fun connect(profile: ServerProfile): OpencodeConnection {
29+ disconnect()
30+ val resolved = profileStore.resolve(profile).copy(lastUsed = System.currentTimeMillis())
31+ profileStore.save(resolved)
32+ return OpencodeConnection(resolved).also { _activeConnection.value = it }
33+ }
34+
35+ fun disconnect() {
36+ _activeConnection.value?.close()
37+ _activeConnection.value = null
38+ }
39+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+31
@@ -0,0 +1,31 @@
1+package soy.iko.opencode.di
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import soy.iko.opencode.data.network.EventStreamClient
5+import soy.iko.opencode.data.network.HttpClientFactory
6+import soy.iko.opencode.data.network.OpencodeApiClient
7+import soy.iko.opencode.data.repo.SessionRepository
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.SupervisorJob
11+import kotlinx.coroutines.cancel
12+
13+/**
14+ * An active connection to one opencode server. Bundles the Ktor client, REST API,
15+ * SSE event stream, and the reducing repository, all sharing a connection-scoped
16+ * coroutine scope that is cancelled on [close].
17+ */
18+class OpencodeConnection(val profile: ServerProfile) {
19+
20+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
21+
22+ private val client = HttpClientFactory.create(profile)
23+ val api = OpencodeApiClient(client)
24+ val events = EventStreamClient(client, scope)
25+ val repository = SessionRepository(api, events)
26+
27+ fun close() {
28+ scope.cancel()
29+ client.close()
30+ }
31+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+80−107
@@ -1,124 +1,97 @@
11package soy.iko.opencode.ui
22
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
173import androidx.compose.runtime.Composable
18−import androidx.compose.runtime.LaunchedEffect
194import 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
5+import androidx.navigation.NavType
6+import androidx.navigation.compose.NavHost
7+import androidx.navigation.compose.composable
8+import androidx.navigation.compose.rememberNavController
9+import androidx.navigation.navArgument
10+import androidx.lifecycle.compose.collectAsStateWithLifecycle
11+import soy.iko.opencode.di.AppContainer
12+import soy.iko.opencode.ui.chat.ChatScreen
13+import soy.iko.opencode.ui.file.FileBrowserScreen
14+import soy.iko.opencode.ui.file.FileViewScreen
15+import soy.iko.opencode.ui.server.ServerEditScreen
16+import soy.iko.opencode.ui.server.ServerListScreen
17+import soy.iko.opencode.ui.session.SessionListScreen
18+import soy.iko.opencode.ui.settings.SettingsScreen
3019
3120@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) }
21+fun OpencodeApp(container: AppContainer) {
22+ val navController = rememberNavController()
23+ val active by container.activeConnection.collectAsStateWithLifecycle()
3624
37− LaunchedEffect(state.error) {
38− state.error?.let {
39− snackbar.showSnackbar(
40− message = it,
41− actionLabel = "Dismiss",
42− duration = SnackbarDuration.Short,
25+ NavHost(navController = navController, startDestination = Routes.SERVERS) {
26+
27+ composable(Routes.SERVERS) {
28+ ServerListScreen(
29+ container = container,
30+ onConnected = { navController.navigate(Routes.SESSIONS) },
31+ onAddProfile = { navController.navigate(Routes.serverEdit()) },
32+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
4333 )
44− // Always clear the error from state after showing.
45− dispatch(Event.DismissError)
4634 }
47− }
4835
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
36+ composable(
37+ route = "${Routes.SERVER_EDIT}?id={id}",
38+ arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
39+ ) { entry ->
40+ ServerEditScreen(
41+ container = container,
42+ profileId = entry.arguments?.getString("id"),
43+ onDone = { navController.popBackStack() },
44+ )
5645 }
57− }
5846
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− }
47+ composable(Routes.SESSIONS) {
48+ SessionListScreen(
49+ container = container,
50+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
51+ onDisconnect = {
52+ container.disconnect()
53+ navController.popBackStack(Routes.SERVERS, inclusive = false)
54+ },
55+ onOpenFiles = { navController.navigate(Routes.FILES) },
56+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
57+ )
58+ }
8059
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− }
60+ composable(
61+ route = "${Routes.CHAT}/{sessionId}",
62+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
63+ ) { entry ->
64+ ChatScreen(
65+ container = container,
66+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
67+ onBack = { navController.popBackStack() },
68+ )
69+ }
70+
71+ composable(Routes.FILES) {
72+ FileBrowserScreen(
73+ container = container,
74+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
75+ onBack = { navController.popBackStack() },
76+ )
77+ }
10178
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− }
79+ composable(
80+ route = "${Routes.FILE_VIEW}?path={path}",
81+ arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
82+ ) { entry ->
83+ FileViewScreen(
84+ container = container,
85+ path = entry.arguments?.getString("path").orEmpty(),
86+ onBack = { navController.popBackStack() },
87+ )
88+ }
89+
90+ composable(Routes.SETTINGS) {
91+ SettingsScreen(
92+ container = container,
93+ onBack = { navController.popBackStack() },
94+ )
12295 }
12396 }
12497}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui
2+
3+/** String routes for the app's NavHost. Kept simple and centralized. */
4+object Routes {
5+ const val SERVERS = "servers"
6+ const val SESSIONS = "sessions"
7+ const val SETTINGS = "settings"
8+ const val FILES = "files"
9+
10+ const val SERVER_EDIT = "server_edit"
11+ fun serverEdit(id: String? = null) =
12+ if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=$id"
13+
14+ const val CHAT = "chat"
15+ fun chat(sessionId: String) = "$CHAT/$sessionId"
16+
17+ const val FILE_VIEW = "file_view"
18+ fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
19+}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktnew file+16
@@ -0,0 +1,16 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewmodel.CreationExtras
5+import androidx.lifecycle.viewmodel.initializer
6+import androidx.lifecycle.viewmodel.viewModelFactory
7+
8+/**
9+ * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10+ * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11+ */
12+inline fun <reified VM : ViewModel> vmFactory(
13+ crossinline create: (CreationExtras) -> VM,
14+) = viewModelFactory {
15+ initializer { create(this) }
16+}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktnew file+85
@@ -0,0 +1,85 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.ModalBottomSheet
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+import soy.iko.opencode.data.model.Agent
18+
19+/** Bottom sheet that lists every agent and lets the user pick one. */
20+@OptIn(ExperimentalMaterial3Api::class)
21+@Composable
22+fun AgentPickerSheet(
23+ agents: List<Agent>,
24+ selected: String?,
25+ onSelect: (Agent?) -> Unit,
26+ onDismiss: () -> Unit,
27+) {
28+ ModalBottomSheet(onDismissRequest = onDismiss) {
29+ Text(
30+ "Agent",
31+ style = MaterialTheme.typography.titleMedium,
32+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33+ )
34+ if (agents.isEmpty()) {
35+ Text(
36+ "No agents reported by the server.",
37+ style = MaterialTheme.typography.bodyMedium,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
40+ )
41+ } else {
42+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
43+ item(key = "__default") {
44+ Column(
45+ modifier = Modifier
46+ .fillMaxWidth()
47+ .clickable { onSelect(null); onDismiss() }
48+ .padding(horizontal = 24.dp, vertical = 12.dp),
49+ ) {
50+ Text(
51+ "Default",
52+ style = MaterialTheme.typography.bodyLarge,
53+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
54+ )
55+ Text(
56+ "Server default agent" + if (selected == null) " ✓" else "",
57+ style = MaterialTheme.typography.bodySmall,
58+ color = MaterialTheme.colorScheme.onSurfaceVariant,
59+ )
60+ }
61+ }
62+ items(agents, key = { it.name }) { agent ->
63+ val isSelected = agent.name == selected
64+ Column(
65+ modifier = Modifier
66+ .fillMaxWidth()
67+ .clickable { onSelect(agent); onDismiss() }
68+ .padding(horizontal = 24.dp, vertical = 12.dp),
69+ ) {
70+ Text(
71+ agent.name,
72+ style = MaterialTheme.typography.bodyLarge,
73+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
74+ )
75+ Text(
76+ agent.displayDescription + if (isSelected) " ✓" else "",
77+ style = MaterialTheme.typography.bodySmall,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ }
81+ }
82+ }
83+ }
84+ }
85+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+271
@@ -0,0 +1,271 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.lazy.LazyColumn
14+import androidx.compose.foundation.lazy.items
15+import androidx.compose.foundation.lazy.rememberLazyListState
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.SmartToy
20+import androidx.compose.material.icons.filled.Stop
21+import androidx.compose.material.icons.filled.Terminal
22+import androidx.compose.material.icons.filled.Tune
23+import androidx.compose.material3.CircularProgressIndicator
24+import androidx.compose.material3.ExperimentalMaterial3Api
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.OutlinedTextField
29+import androidx.compose.material3.Scaffold
30+import androidx.compose.material3.SnackbarHost
31+import androidx.compose.material3.SnackbarHostState
32+import androidx.compose.material3.Surface
33+import androidx.compose.material3.Text
34+import androidx.compose.material3.TopAppBar
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.LaunchedEffect
37+import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
39+import androidx.compose.runtime.remember
40+import androidx.compose.runtime.setValue
41+import androidx.compose.ui.Alignment
42+import androidx.compose.ui.Modifier
43+import androidx.compose.ui.unit.dp
44+import androidx.lifecycle.compose.collectAsStateWithLifecycle
45+import androidx.lifecycle.viewmodel.compose.viewModel
46+import soy.iko.opencode.di.AppContainer
47+import soy.iko.opencode.ui.vmFactory
48+
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun ChatScreen(
52+ container: AppContainer,
53+ sessionId: String,
54+ onBack: () -> Unit,
55+) {
56+ val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
57+ val messages by vm.messages.collectAsStateWithLifecycle()
58+ val running by vm.running.collectAsStateWithLifecycle()
59+ val error by vm.error.collectAsStateWithLifecycle()
60+ val models by vm.models.collectAsStateWithLifecycle()
61+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
62+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
63+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
64+ val agents by vm.agents.collectAsStateWithLifecycle()
65+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66+ val commands by vm.commands.collectAsStateWithLifecycle()
67+
68+ val listState = rememberLazyListState()
69+ val snackbar = remember { SnackbarHostState() }
70+ var input by remember { mutableStateOf("") }
71+ var showModelPicker by remember { mutableStateOf(false) }
72+ var showAgentPicker by remember { mutableStateOf(false) }
73+ var showCommandPicker by remember { mutableStateOf(false) }
74+
75+ // Keep the newest content in view as parts stream in.
76+ LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
77+ if (messages.isNotEmpty()) listState.animateScrollToItem(messages.lastIndex)
78+ }
79+ LaunchedEffect(error) {
80+ error?.let {
81+ snackbar.showSnackbar(it)
82+ vm.clearError()
83+ }
84+ }
85+
86+ Scaffold(
87+ topBar = {
88+ TopAppBar(
89+ title = {
90+ Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
91+ Text("Session")
92+ Text(
93+ buildString {
94+ append(selectedModel?.modelLabel ?: "default model")
95+ selectedAgent?.let { append(" • $it") }
96+ },
97+ style = MaterialTheme.typography.labelSmall,
98+ color = MaterialTheme.colorScheme.onSurfaceVariant,
99+ )
100+ }
101+ },
102+ navigationIcon = {
103+ IconButton(onClick = onBack) {
104+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
105+ }
106+ },
107+ actions = {
108+ IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
109+ Icon(Icons.Filled.Terminal, contentDescription = "Commands")
110+ }
111+ IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
112+ Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
113+ }
114+ IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
115+ Icon(Icons.Filled.Tune, contentDescription = "Choose model")
116+ }
117+ },
118+ )
119+ },
120+ snackbarHost = { SnackbarHost(snackbar) },
121+ bottomBar = {
122+ ChatInputBar(
123+ value = input,
124+ onValueChange = { input = it },
125+ running = running,
126+ enabled = vm.connected,
127+ onSend = {
128+ vm.send(input)
129+ input = ""
130+ },
131+ onAbort = { vm.abort() },
132+ )
133+ },
134+ ) { padding ->
135+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
136+ if (!vm.connected) {
137+ Text(
138+ "Not connected.",
139+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
140+ color = MaterialTheme.colorScheme.error,
141+ )
142+ } else {
143+ ConnectionBanner(
144+ state = connectionState,
145+ modifier = Modifier.align(Alignment.TopCenter),
146+ )
147+ LazyColumn(
148+ state = listState,
149+ modifier = Modifier.fillMaxSize(),
150+ contentPadding = PaddingValues(16.dp),
151+ verticalArrangement = Arrangement.spacedBy(16.dp),
152+ ) {
153+ items(messages, key = { it.info.id }) { message ->
154+ MessageBubble(message)
155+ }
156+ if (running) {
157+ item(key = "__typing") {
158+ Row(verticalAlignment = Alignment.CenterVertically) {
159+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
160+ Text(" working…", style = MaterialTheme.typography.labelMedium)
161+ }
162+ }
163+ }
164+ }
165+ }
166+ }
167+ }
168+
169+ if (showModelPicker) {
170+ ModelPickerSheet(
171+ options = models,
172+ selected = selectedModel,
173+ onSelect = { vm.selectModel(it) },
174+ onDismiss = { showModelPicker = false },
175+ )
176+ }
177+
178+ if (showAgentPicker) {
179+ AgentPickerSheet(
180+ agents = agents,
181+ selected = selectedAgent,
182+ onSelect = { vm.selectAgent(it?.name) },
183+ onDismiss = { showAgentPicker = false },
184+ )
185+ }
186+
187+ if (showCommandPicker) {
188+ CommandPickerSheet(
189+ commands = commands,
190+ onSelect = { vm.runCommand(it) },
191+ onDismiss = { showCommandPicker = false },
192+ )
193+ }
194+
195+ pendingPermission?.let { permission ->
196+ PermissionDialog(
197+ permission = permission,
198+ onRespond = { response -> vm.respondPermission(permission, response) },
199+ )
200+ }
201+}
202+
203+@Composable
204+private fun ConnectionBanner(
205+ state: soy.iko.opencode.data.network.EventStreamClient.ConnectionState,
206+ modifier: Modifier = Modifier,
207+) {
208+ val text = when (state) {
209+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connecting -> "Connecting…"
210+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Disconnected -> "Reconnecting…"
211+ soy.iko.opencode.data.network.EventStreamClient.ConnectionState.Connected -> null
212+ }
213+ if (text != null) {
214+ Surface(
215+ modifier = modifier.fillMaxWidth(),
216+ color = MaterialTheme.colorScheme.tertiaryContainer,
217+ ) {
218+ Row(
219+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
220+ horizontalArrangement = Arrangement.Center,
221+ verticalAlignment = Alignment.CenterVertically,
222+ ) {
223+ CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
224+ Text(
225+ " $text",
226+ style = MaterialTheme.typography.labelMedium,
227+ color = MaterialTheme.colorScheme.onTertiaryContainer,
228+ )
229+ }
230+ }
231+ }
232+}
233+
234+@Composable
235+private fun ChatInputBar(
236+ value: String,
237+ onValueChange: (String) -> Unit,
238+ running: Boolean,
239+ enabled: Boolean,
240+ onSend: () -> Unit,
241+ onAbort: () -> Unit,
242+) {
243+ Surface(tonalElevation = 3.dp) {
244+ Row(
245+ modifier = Modifier.fillMaxWidth().padding(8.dp),
246+ verticalAlignment = Alignment.Bottom,
247+ ) {
248+ OutlinedTextField(
249+ value = value,
250+ onValueChange = onValueChange,
251+ modifier = Modifier.weight(1f),
252+ placeholder = { Text("Message opencode…") },
253+ enabled = enabled,
254+ maxLines = 6,
255+ )
256+ if (running) {
257+ IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
258+ Icon(Icons.Filled.Stop, contentDescription = "Stop")
259+ }
260+ } else {
261+ IconButton(
262+ onClick = onSend,
263+ enabled = enabled && value.isNotBlank(),
264+ modifier = Modifier.padding(start = 4.dp),
265+ ) {
266+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
267+ }
268+ }
269+ }
270+ }
271+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+167
@@ -0,0 +1,167 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Agent
6+import soy.iko.opencode.data.model.Command
7+import soy.iko.opencode.data.model.MessageWithParts
8+import soy.iko.opencode.data.model.ModelOption
9+import soy.iko.opencode.data.model.Permission
10+import soy.iko.opencode.data.model.PermissionReplied
11+import soy.iko.opencode.data.model.PermissionResponse
12+import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.defaultOption
14+import soy.iko.opencode.data.model.toOptions
15+import soy.iko.opencode.data.network.EventStreamClient
16+import soy.iko.opencode.data.repo.SessionRepository
17+import soy.iko.opencode.di.AppContainer
18+import kotlinx.coroutines.flow.MutableStateFlow
19+import kotlinx.coroutines.flow.SharingStarted
20+import kotlinx.coroutines.flow.StateFlow
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.flowOf
23+import kotlinx.coroutines.flow.stateIn
24+import kotlinx.coroutines.launch
25+
26+class ChatViewModel(
27+ private val container: AppContainer,
28+ private val sessionId: String,
29+) : ViewModel() {
30+
31+ private val connection = container.activeConnection.value
32+
33+ val connected: Boolean get() = connection != null
34+
35+ val messages: StateFlow<List<MessageWithParts>> =
36+ (connection?.repository?.observeMessages(sessionId) ?: flowOf(emptyList<MessageWithParts>()))
37+ .stateIn(
38+ scope = viewModelScope,
39+ started = SharingStarted.WhileSubscribed(5_000),
40+ initialValue = emptyList(),
41+ )
42+
43+ private val _running = MutableStateFlow(false)
44+ val running: StateFlow<Boolean> = _running.asStateFlow()
45+
46+ private val _error = MutableStateFlow<String?>(null)
47+ val error: StateFlow<String?> = _error.asStateFlow()
48+
49+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
50+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
51+
52+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
53+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
54+
55+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
56+ connection?.events?.state
57+ ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected).asStateFlow()
58+
59+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
60+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
61+
62+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
63+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
64+
65+ private val _selectedAgent = MutableStateFlow<String?>(null)
66+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
67+
68+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
69+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
70+
71+ fun clearError() { _error.value = null }
72+
73+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
74+
75+ fun selectAgent(name: String?) { _selectedAgent.value = name }
76+
77+ init {
78+ // Watch the bus for run completion / errors / permission asks for this session.
79+ val conn = connection
80+ if (conn != null) {
81+ viewModelScope.launch {
82+ conn.events.events.collect { event ->
83+ if (SessionRepository.isIdle(event, sessionId)) _running.value = false
84+ if (SessionRepository.isError(event, sessionId)) {
85+ _running.value = false
86+ _error.value = "The agent reported an error."
87+ }
88+ when (event) {
89+ is PermissionUpdated ->
90+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
91+ is PermissionReplied ->
92+ if (event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
93+ else -> {}
94+ }
95+ }
96+ }
97+ // Load the model catalog; preselect the server default. Failure is non-fatal —
98+ // sending with no model just uses the server's default agent/model.
99+ viewModelScope.launch {
100+ runCatching { conn.api.providers() }.getOrNull()?.let { resp ->
101+ val options = resp.toOptions()
102+ _models.value = options
103+ _selectedModel.value = resp.defaultOption(options)
104+ }
105+ }
106+ // Load the agent catalog (non-fatal).
107+ viewModelScope.launch {
108+ runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
109+ }
110+ // Load the command catalog (non-fatal).
111+ viewModelScope.launch {
112+ runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
113+ }
114+ }
115+ }
116+
117+ fun send(text: String) {
118+ val conn = connection ?: return
119+ val trimmed = text.trim()
120+ if (trimmed.isEmpty() || _running.value) return
121+ _running.value = true
122+ _error.value = null
123+ viewModelScope.launch {
124+ runCatching {
125+ conn.repository.sendPrompt(
126+ sessionId,
127+ trimmed,
128+ model = _selectedModel.value?.ref,
129+ agent = _selectedAgent.value,
130+ )
131+ }.onFailure { _error.value = it.message ?: "Failed to send" }
132+ _running.value = false
133+ }
134+ }
135+
136+ /** Invoke a slash-command by name via the server's /command endpoint. */
137+ fun runCommand(command: Command) {
138+ val conn = connection ?: return
139+ if (_running.value) return
140+ _running.value = true
141+ _error.value = null
142+ viewModelScope.launch {
143+ runCatching {
144+ conn.repository.runCommand(sessionId, command.name, agent = command.agent)
145+ }.onFailure { _error.value = it.message ?: "Failed to run command" }
146+ _running.value = false
147+ }
148+ }
149+
150+ fun abort() {
151+ val conn = connection ?: return
152+ viewModelScope.launch {
153+ runCatching { conn.repository.abort(sessionId) }
154+ _running.value = false
155+ }
156+ }
157+
158+ fun respondPermission(permission: Permission, response: PermissionResponse) {
159+ val conn = connection ?: return
160+ // Clear optimistically; permission.replied will confirm.
161+ _pendingPermission.value = null
162+ viewModelScope.launch {
163+ runCatching { conn.api.respondPermission(sessionId, permission.id, response) }
164+ .onFailure { _error.value = it.message ?: "Failed to respond to permission" }
165+ }
166+ }
167+}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
12+import androidx.compose.material3.ExperimentalMaterial3Api
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.ModalBottomSheet
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.text.style.TextOverflow
20+import androidx.compose.ui.unit.dp
21+import soy.iko.opencode.data.model.Command
22+
23+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
24+@OptIn(ExperimentalMaterial3Api::class)
25+@Composable
26+fun CommandPickerSheet(
27+ commands: List<Command>,
28+ onSelect: (Command) -> Unit,
29+ onDismiss: () -> Unit,
30+) {
31+ ModalBottomSheet(onDismissRequest = onDismiss) {
32+ Text(
33+ "Commands",
34+ style = MaterialTheme.typography.titleMedium,
35+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
36+ )
37+ if (commands.isEmpty()) {
38+ Text(
39+ "No commands reported by the server.",
40+ style = MaterialTheme.typography.bodyMedium,
41+ color = MaterialTheme.colorScheme.onSurfaceVariant,
42+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
43+ )
44+ } else {
45+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
46+ items(commands, key = { it.name }) { cmd ->
47+ Column(
48+ modifier = Modifier
49+ .fillMaxWidth()
50+ .clickable { onSelect(cmd); onDismiss() }
51+ .padding(horizontal = 24.dp, vertical = 12.dp),
52+ ) {
53+ androidx.compose.foundation.layout.Row(
54+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
55+ ) {
56+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
57+ Text(
58+ " /${cmd.name}",
59+ style = MaterialTheme.typography.bodyLarge,
60+ )
61+ }
62+ Text(
63+ cmd.displayDescription,
64+ style = MaterialTheme.typography.bodySmall,
65+ color = MaterialTheme.colorScheme.onSurfaceVariant,
66+ maxLines = 2,
67+ overflow = TextOverflow.Ellipsis,
68+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
69+ )
70+ }
71+ }
72+ }
73+ }
74+ }
75+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+82
@@ -0,0 +1,82 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.widthIn
10+import androidx.compose.foundation.shape.RoundedCornerShape
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.Text
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.draw.clip
17+import androidx.compose.ui.unit.dp
18+import soy.iko.opencode.data.model.AssistantMessage
19+import soy.iko.opencode.data.model.MessageWithParts
20+import soy.iko.opencode.data.model.UserMessage
21+
22+/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
23+@Composable
24+fun MessageBubble(message: MessageWithParts, modifier: Modifier = Modifier) {
25+ when (message.info) {
26+ is UserMessage -> UserBubble(message, modifier)
27+ else -> AssistantBlock(message, modifier)
28+ }
29+}
30+
31+@Composable
32+private fun UserBubble(message: MessageWithParts, modifier: Modifier) {
33+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
34+ Column(
35+ modifier = Modifier
36+ .widthIn(max = 320.dp)
37+ .clip(RoundedCornerShape(16.dp))
38+ .background(MaterialTheme.colorScheme.primaryContainer)
39+ .padding(12.dp),
40+ verticalArrangement = Arrangement.spacedBy(6.dp),
41+ ) {
42+ for (part in message.parts) PartView(part)
43+ }
44+ }
45+}
46+
47+@Composable
48+private fun AssistantBlock(message: MessageWithParts, modifier: Modifier) {
49+ Column(
50+ modifier = modifier.fillMaxWidth(),
51+ verticalArrangement = Arrangement.spacedBy(8.dp),
52+ ) {
53+ val info = message.info
54+ if (info is AssistantMessage && info.modelID != null) {
55+ Text(
56+ info.modelID,
57+ style = MaterialTheme.typography.labelSmall,
58+ color = MaterialTheme.colorScheme.onSurfaceVariant,
59+ )
60+ }
61+ for (part in message.parts) {
62+ PartView(part, modifier = Modifier.fillMaxWidth())
63+ }
64+ if (info is AssistantMessage) {
65+ val cost = info.cost
66+ val tokens = info.tokens
67+ if (info.isComplete && (cost != null || tokens != null)) {
68+ val parts = buildList {
69+ tokens?.let { add("${it.input}→${it.output} tok") }
70+ cost?.takeIf { it > 0 }?.let { add("$%.4f".format(it)) }
71+ }
72+ if (parts.isNotEmpty()) {
73+ Text(
74+ parts.joinToString(" • "),
75+ style = MaterialTheme.typography.labelSmall,
76+ color = MaterialTheme.colorScheme.onSurfaceVariant,
77+ )
78+ }
79+ }
80+ }
81+ }
82+}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+70
@@ -0,0 +1,70 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.ModalBottomSheet
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+import soy.iko.opencode.data.model.ModelOption
18+
19+/** Bottom sheet that lists every provider/model and lets the user pick one. */
20+@OptIn(ExperimentalMaterial3Api::class)
21+@Composable
22+fun ModelPickerSheet(
23+ options: List<ModelOption>,
24+ selected: ModelOption?,
25+ onSelect: (ModelOption) -> Unit,
26+ onDismiss: () -> Unit,
27+) {
28+ ModalBottomSheet(onDismissRequest = onDismiss) {
29+ Text(
30+ "Model",
31+ style = MaterialTheme.typography.titleMedium,
32+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33+ )
34+ if (options.isEmpty()) {
35+ Text(
36+ "No models reported by the server.",
37+ style = MaterialTheme.typography.bodyMedium,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
40+ )
41+ } else {
42+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
43+ items(options, key = { it.providerID + "/" + it.modelID }) { option ->
44+ val isSelected = option.providerID == selected?.providerID &&
45+ option.modelID == selected.modelID
46+ Column(
47+ modifier = Modifier
48+ .fillMaxWidth()
49+ .clickable {
50+ onSelect(option)
51+ onDismiss()
52+ }
53+ .padding(horizontal = 24.dp, vertical = 12.dp),
54+ ) {
55+ Text(
56+ option.modelLabel,
57+ style = MaterialTheme.typography.bodyLarge,
58+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
59+ )
60+ Text(
61+ option.providerLabel + if (isSelected) " ✓" else "",
62+ style = MaterialTheme.typography.bodySmall,
63+ color = MaterialTheme.colorScheme.onSurfaceVariant,
64+ )
65+ }
66+ }
67+ }
68+ }
69+ }
70+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+158
@@ -0,0 +1,158 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.shape.RoundedCornerShape
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.filled.Bolt
14+import androidx.compose.material.icons.filled.CheckCircle
15+import androidx.compose.material.icons.filled.Error
16+import androidx.compose.material.icons.filled.Psychology
17+import androidx.compose.material3.CircularProgressIndicator
18+import androidx.compose.material3.Icon
19+import androidx.compose.material3.MaterialTheme
20+import androidx.compose.material3.Text
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.setValue
26+import androidx.compose.ui.Alignment
27+import androidx.compose.ui.Modifier
28+import androidx.compose.ui.draw.clip
29+import androidx.compose.ui.text.font.FontFamily
30+import androidx.compose.ui.unit.dp
31+import soy.iko.opencode.data.model.FilePart
32+import soy.iko.opencode.data.model.Part
33+import soy.iko.opencode.data.model.ReasoningPart
34+import soy.iko.opencode.data.model.StepFinishPart
35+import soy.iko.opencode.data.model.StepStartPart
36+import soy.iko.opencode.data.model.TextPart
37+import soy.iko.opencode.data.model.ToolCompleted
38+import soy.iko.opencode.data.model.ToolError
39+import soy.iko.opencode.data.model.ToolPart
40+import soy.iko.opencode.data.model.ToolPending
41+import soy.iko.opencode.data.model.ToolRunning
42+import soy.iko.opencode.data.model.ToolState
43+import soy.iko.opencode.data.model.ToolUnknown
44+import soy.iko.opencode.data.model.UnknownPart
45+import soy.iko.opencode.ui.components.DiffView
46+import soy.iko.opencode.ui.components.MarkdownText
47+import soy.iko.opencode.ui.components.looksLikeDiff
48+
49+/**
50+ * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
51+ * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
52+ */
53+@Composable
54+fun PartView(part: Part, modifier: Modifier = Modifier) {
55+ when (part) {
56+ is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
57+ MarkdownText(part.text, modifier = modifier)
58+ }
59+ is ReasoningPart -> ReasoningBlock(part.text, modifier)
60+ is ToolPart -> ToolCallView(part, modifier)
61+ is FilePart -> FileChip(part, modifier)
62+ is StepStartPart -> {} // boundary marker — nothing to draw
63+ is StepFinishPart -> {} // metrics handled at message level
64+ is UnknownPart -> {} // forward-compat: unknown part types are ignored
65+ }
66+}
67+
68+@Composable
69+private fun ReasoningBlock(text: String, modifier: Modifier) {
70+ if (text.isBlank()) return
71+ var expanded by remember { mutableStateOf(false) }
72+ Column(modifier = modifier.fillMaxWidth()) {
73+ Row(
74+ modifier = Modifier.clickable { expanded = !expanded }.padding(vertical = 4.dp),
75+ verticalAlignment = Alignment.CenterVertically,
76+ ) {
77+ Icon(
78+ Icons.Filled.Psychology,
79+ contentDescription = null,
80+ modifier = Modifier.size(16.dp),
81+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
82+ )
83+ Text(
84+ if (expanded) " Thinking" else " Thinking…",
85+ style = MaterialTheme.typography.labelMedium,
86+ color = MaterialTheme.colorScheme.onSurfaceVariant,
87+ )
88+ }
89+ AnimatedVisibility(visible = expanded) {
90+ Text(
91+ text,
92+ style = MaterialTheme.typography.bodySmall,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ )
95+ }
96+ }
97+}
98+
99+@Composable
100+private fun ToolCallView(part: ToolPart, modifier: Modifier) {
101+ Column(
102+ modifier = modifier
103+ .fillMaxWidth()
104+ .clip(RoundedCornerShape(8.dp))
105+ .background(MaterialTheme.colorScheme.surfaceVariant)
106+ .padding(10.dp),
107+ ) {
108+ Row(verticalAlignment = Alignment.CenterVertically) {
109+ ToolStatusIcon(part.state)
110+ Text(
111+ " ${part.tool}",
112+ style = MaterialTheme.typography.labelLarge,
113+ fontFamily = FontFamily.Monospace,
114+ )
115+ }
116+ val detail = when (val s = part.state) {
117+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
118+ is ToolError -> s.error ?: "error"
119+ else -> null
120+ }
121+ if (detail != null) {
122+ val truncated = detail.take(5000)
123+ if (looksLikeDiff(truncated)) {
124+ DiffView(truncated)
125+ } else {
126+ Text(
127+ truncated.take(2000),
128+ style = MaterialTheme.typography.bodySmall,
129+ fontFamily = FontFamily.Monospace,
130+ modifier = Modifier.padding(top = 6.dp),
131+ )
132+ }
133+ }
134+ }
135+}
136+
137+@Composable
138+private fun ToolStatusIcon(state: ToolState) {
139+ when (state) {
140+ is ToolPending, is ToolRunning ->
141+ CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
142+ is ToolCompleted ->
143+ Icon(Icons.Filled.CheckCircle, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
144+ is ToolError ->
145+ Icon(Icons.Filled.Error, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
146+ is ToolUnknown ->
147+ Icon(Icons.Filled.Bolt, null, Modifier.size(16.dp))
148+ }
149+}
150+
151+@Composable
152+private fun FileChip(part: FilePart, modifier: Modifier) {
153+ Text(
154+ "📎 ${part.filename ?: part.url ?: "file"}",
155+ modifier = modifier.padding(vertical = 2.dp),
156+ style = MaterialTheme.typography.bodyMedium,
157+ )
158+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+55
@@ -0,0 +1,55 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.material3.AlertDialog
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.Text
8+import androidx.compose.material3.TextButton
9+import androidx.compose.runtime.Composable
10+import androidx.compose.ui.Modifier
11+import androidx.compose.ui.text.font.FontFamily
12+import soy.iko.opencode.data.model.Permission
13+import soy.iko.opencode.data.model.PermissionResponse
14+
15+/**
16+ * Modal asking the user to approve a tool the agent wants to run. The agent run is
17+ * paused until one of these responses is sent. Non-dismissable by tap-outside so the
18+ * decision is explicit.
19+ */
20+@Composable
21+fun PermissionDialog(
22+ permission: Permission,
23+ onRespond: (PermissionResponse) -> Unit,
24+) {
25+ AlertDialog(
26+ onDismissRequest = { /* require an explicit choice */ },
27+ title = { Text(permission.title ?: "Permission requested") },
28+ text = {
29+ Column {
30+ permission.type?.let {
31+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
32+ }
33+ permission.patternText?.let {
34+ Text(
35+ it,
36+ style = MaterialTheme.typography.bodySmall,
37+ fontFamily = FontFamily.Monospace,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ )
40+ }
41+ }
42+ },
43+ confirmButton = {
44+ Column(modifier = Modifier.fillMaxWidth()) {
45+ TextButton(onClick = { onRespond(PermissionResponse.ONCE) }) { Text("Allow once") }
46+ TextButton(onClick = { onRespond(PermissionResponse.ALWAYS) }) { Text("Always allow") }
47+ }
48+ },
49+ dismissButton = {
50+ TextButton(onClick = { onRespond(PermissionResponse.REJECT) }) {
51+ Text("Reject", color = MaterialTheme.colorScheme.error)
52+ }
53+ },
54+ )
55+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+131
@@ -0,0 +1,131 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.width
10+import androidx.compose.foundation.layout.widthIn
11+import androidx.compose.foundation.rememberScrollState
12+import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.Text
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.ui.Alignment
19+import androidx.compose.ui.Modifier
20+import androidx.compose.ui.draw.clip
21+import androidx.compose.ui.graphics.Color
22+import androidx.compose.ui.text.font.FontFamily
23+import androidx.compose.ui.unit.dp
24+
25+/** A single line of a parsed unified diff. */
26+sealed interface DiffLine {
27+ val text: String
28+
29+ data class Hunk(override val text: String) : DiffLine // @@ ... @@
30+ data class FileHeader(override val text: String) : DiffLine // --- / +++
31+ data class Context(override val text: String) : DiffLine
32+ data class Add(override val text: String) : DiffLine
33+ data class Remove(override val text: String) : DiffLine
34+}
35+
36+/** Parse a unified diff string into typed [DiffLine]s. */
37+fun parseDiff(diff: String): List<DiffLine> {
38+ val result = mutableListOf<DiffLine>()
39+ for (raw in diff.split("\n")) {
40+ when {
41+ raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
42+ raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
43+ raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
44+ raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
45+ raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
46+ raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
47+ raw.isNotBlank() -> result.add(DiffLine.Context(raw))
48+ }
49+ }
50+ return result
51+}
52+
53+/** Heuristic: does this string look like a unified diff? */
54+fun looksLikeDiff(text: String): Boolean {
55+ val lines = text.lineSequence()
56+ var hunkCount = 0
57+ var addCount = 0
58+ var removeCount = 0
59+ for (line in lines) {
60+ when {
61+ line.startsWith("@@") -> hunkCount++
62+ line.startsWith("+") && !line.startsWith("+++") -> addCount++
63+ line.startsWith("-") && !line.startsWith("---") -> removeCount++
64+ }
65+ if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
66+ }
67+ return false
68+}
69+
70+@Composable
71+fun DiffView(diff: String, modifier: Modifier = Modifier) {
72+ val lines = remember(diff) { parseDiff(diff) }
73+ val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
74+ val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
75+ val addText = MaterialTheme.colorScheme.primary
76+ val removeText = MaterialTheme.colorScheme.error
77+
78+ Column(
79+ modifier = modifier
80+ .fillMaxWidth()
81+ .padding(vertical = 4.dp)
82+ .clip(RoundedCornerShape(8.dp))
83+ .background(MaterialTheme.colorScheme.surfaceVariant)
84+ .verticalScroll(rememberScrollState())
85+ .horizontalScroll(rememberScrollState())
86+ .padding(10.dp),
87+ ) {
88+ for (line in lines) {
89+ when (line) {
90+ is DiffLine.Hunk -> Text(
91+ line.text,
92+ style = MaterialTheme.typography.bodySmall,
93+ fontFamily = FontFamily.Monospace,
94+ color = MaterialTheme.colorScheme.tertiary,
95+ modifier = Modifier.padding(vertical = 2.dp),
96+ )
97+ is DiffLine.FileHeader -> Text(
98+ line.text,
99+ style = MaterialTheme.typography.bodySmall,
100+ fontFamily = FontFamily.Monospace,
101+ color = MaterialTheme.colorScheme.onSurfaceVariant,
102+ modifier = Modifier.padding(vertical = 1.dp),
103+ )
104+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
105+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
106+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
107+ }
108+ }
109+ }
110+}
111+
112+@Composable
113+private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
114+ Row(
115+ modifier = Modifier.fillMaxWidth().background(bg),
116+ ) {
117+ Text(
118+ prefix,
119+ style = MaterialTheme.typography.bodySmall,
120+ fontFamily = FontFamily.Monospace,
121+ color = textColor,
122+ modifier = Modifier.width(16.dp),
123+ )
124+ Text(
125+ text,
126+ style = MaterialTheme.typography.bodySmall,
127+ fontFamily = FontFamily.Monospace,
128+ color = textColor,
129+ )
130+ }
131+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+23
@@ -0,0 +1,23 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.ui.Modifier
6+import androidx.compose.ui.text.TextStyle
7+import com.mikepenz.markdown.m3.Markdown
8+
9+/**
10+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
11+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
12+ */
13+@Composable
14+fun MarkdownText(
15+ markdown: String,
16+ modifier: Modifier = Modifier,
17+ style: TextStyle = MaterialTheme.typography.bodyLarge,
18+) {
19+ Markdown(
20+ content = markdown,
21+ modifier = modifier,
22+ )
23+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktdeleted−479
@@ -1,479 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.content.Intent
7−import android.net.Uri
8−import android.widget.Toast
9−import androidx.compose.foundation.ExperimentalFoundationApi
10−import androidx.compose.foundation.background
11−import androidx.compose.foundation.clickable
12−import androidx.compose.foundation.combinedClickable
13−import androidx.compose.foundation.layout.Arrangement
14−import androidx.compose.foundation.layout.Column
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.fillMaxWidth
17−import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
19−import androidx.compose.foundation.layout.widthIn
20−import androidx.compose.foundation.shape.RoundedCornerShape
21−import androidx.compose.foundation.text.selection.SelectionContainer
22−import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Build
24−import androidx.compose.material.icons.filled.Check
25−import androidx.compose.material.icons.filled.ErrorOutline
26−import androidx.compose.material.icons.filled.ExpandLess
27−import androidx.compose.material.icons.filled.ExpandMore
28−import androidx.compose.material.icons.filled.Schedule
29−import androidx.compose.material.icons.filled.SmartToy
30−import androidx.compose.material3.DropdownMenu
31−import androidx.compose.material3.DropdownMenuItem
32−import androidx.compose.material3.Icon
33−import androidx.compose.material3.MaterialTheme
34−import androidx.compose.material3.Text
35−import androidx.compose.runtime.Composable
36−import androidx.compose.runtime.getValue
37−import androidx.compose.runtime.mutableStateOf
38−import androidx.compose.runtime.remember
39−import androidx.compose.runtime.setValue
40−import androidx.compose.ui.Alignment
41−import androidx.compose.ui.Modifier
42−import androidx.compose.ui.draw.clip
43−import androidx.compose.ui.graphics.Color
44−import androidx.compose.ui.platform.LocalContext
45−import androidx.compose.ui.text.AnnotatedString
46−import androidx.compose.ui.text.LinkAnnotation
47−import androidx.compose.ui.text.SpanStyle
48−import androidx.compose.ui.text.TextLinkStyles
49−import androidx.compose.ui.text.buildAnnotatedString
50−import androidx.compose.ui.text.font.FontFamily
51−import androidx.compose.ui.text.font.FontStyle
52−import androidx.compose.ui.text.font.FontWeight
53−import androidx.compose.ui.text.withLink
54−import androidx.compose.ui.text.withStyle
55−import androidx.compose.ui.text.style.TextDecoration
56−import androidx.compose.ui.text.style.TextOverflow
57−import androidx.compose.ui.unit.dp
58−import androidx.compose.ui.unit.sp
59−import soy.iko.opencode.core.MessageStatus
60−import soy.iko.opencode.core.MessageView
61−import soy.iko.opencode.core.ToolView
62−import soy.iko.opencode.ui.theme.MonoStyle
63−
64−private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65−
66−@OptIn(ExperimentalFoundationApi::class)
67−@Composable
68−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69− val isUser = message.isUser
70− val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71− val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72− val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73− val shape = RoundedCornerShape(
74− topStart = 16.dp,
75− topEnd = 16.dp,
76− bottomStart = if (isUser) 16.dp else 4.dp,
77− bottomEnd = if (isUser) 4.dp else 16.dp,
78− )
79−
80− val context = LocalContext.current
81− var showMenu by remember { mutableStateOf(false) }
82−
83− Row(
84− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86− ) {
87− if (!isUser) {
88− Icon(
89− Icons.Filled.SmartToy,
90− contentDescription = null,
91− tint = MaterialTheme.colorScheme.primary,
92− modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93− )
94− }
95− Column(
96− modifier = Modifier
97− .fillMaxWidth(0.85f)
98− .widthIn(max = 320.dp)
99− .clip(shape)
100− .background(bubbleColor)
101− .combinedClickable(
102− onClick = {},
103− onLongClick = {
104− if (message.text.isNotEmpty()) {
105− showMenu = true
106− }
107− },
108− )
109− .padding(horizontal = 14.dp, vertical = 10.dp),
110− ) {
111− DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112− DropdownMenuItem(
113− text = { Text("Copy") },
114− onClick = {
115− copyToClipboard(context, message.text)
116− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117− showMenu = false
118− },
119− )
120− DropdownMenuItem(
121− text = { Text("Share") },
122− onClick = {
123− shareText(context, message.text)
124− showMenu = false
125− },
126− )
127− }
128−
129− message.reasoning?.let { ReasoningBlock(it, context) }
130−
131− // Give a quick overview of how many tools ran before listing them.
132− if (message.tools.size > 1) {
133− Text(
134− text = "${message.tools.size} tool calls",
135− style = MaterialTheme.typography.labelSmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− modifier = Modifier.padding(bottom = 4.dp),
138− )
139− }
140−
141− message.tools.forEach { ToolRow(it) }
142−
143− SelectionContainer {
144− if (message.text.isNotEmpty()) {
145− val linkColor = MaterialTheme.colorScheme.primary
146− // Tint the code background off the bubble's own foreground colour so it
147− // contrasts on both bubble types (user bubbles use primaryContainer).
148− val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149− val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150− buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151− }
152− Text(
153− text = annotatedText,
154− color = textColor,
155− // Give assistant replies (often long, code-heavy) extra line
156− // spacing for readability; user bubbles keep the default.
157− style = if (isUser) {
158− MaterialTheme.typography.bodyLarge
159− } else {
160− MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161− },
162− )
163− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165− }
166− }
167−
168− if (message.time > 0) {
169− Text(
170− text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171− style = MaterialTheme.typography.labelSmall,
172− color = mutedColor,
173− modifier = Modifier.padding(top = 4.dp),
174− )
175− }
176−
177− if (isUser && message.status != MessageStatus.Sent) {
178− StatusLine(message.status, onRetry = onRetry)
179− }
180− }
181− }
182−}
183−
184−private fun copyToClipboard(context: Context, text: String) {
185− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186− clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187−}
188−
189−private val urlRegex = Regex("""https?://[^\s]+""")
190−
191−/**
192− * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193− * prose and code segments: code segments are rendered monospace on a tinted
194− * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195− * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196− */
197−private fun buildLinkedText(
198− text: String,
199− streaming: Boolean,
200− linkColor: Color,
201− codeBackground: Color,
202− context: Context,
203−): AnnotatedString = buildAnnotatedString {
204− // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205− // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206− val segments = text.split("```")
207− segments.forEachIndexed { index, segment ->
208− if (index % 2 == 1) {
209− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210− append(segment)
211− }
212− } else {
213− appendLinkedText(segment, linkColor, codeBackground, context)
214− }
215− }
216− if (streaming) append(" ▌")
217−}
218−
219−/**
220− * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221− * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222− * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223− * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224− * inline spans reach here.
225− */
226−private fun AnnotatedString.Builder.appendLinkedText(
227− text: String,
228− linkColor: Color,
229− codeBackground: Color,
230− context: Context,
231−) {
232− // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233− // inline code and links inside it. Odd-indexed segments sit between a pair of **
234− // markers, so they're bold (markers dropped); even segments are ordinary prose.
235− // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236− // segment on "*" would misfire on the leftover asterisks from the ** markers.
237− val segments = text.split("**")
238− // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239− // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240− val unclosed = segments.size % 2 == 0
241− segments.forEachIndexed { index, segment ->
242− val isUnclosedTail = unclosed && index == segments.size - 1
243− if (index % 2 == 1 && !isUnclosedTail) {
244− withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245− appendInlineCode(segment, linkColor, codeBackground, context)
246− }
247− } else {
248− if (isUnclosedTail) append("**")
249− appendItalicText(segment, linkColor, codeBackground, context)
250− }
251− }
252−}
253−
254−/**
255− * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256− * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257− * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258− */
259−private fun AnnotatedString.Builder.appendItalicText(
260− text: String,
261− linkColor: Color,
262− codeBackground: Color,
263− context: Context,
264−) {
265− // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266− // (markers dropped); even segments are ordinary prose.
267− val segments = text.split("*")
268− // An even segment count means an odd number of * markers, so the final * is unclosed:
269− // its trailing segment isn't italic. Restore the literal * and render it as prose.
270− val unclosed = segments.size % 2 == 0
271− segments.forEachIndexed { index, segment ->
272− val isUnclosedTail = unclosed && index == segments.size - 1
273− if (index % 2 == 1 && !isUnclosedTail) {
274− withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275− appendInlineCode(segment, linkColor, codeBackground, context)
276− }
277− } else {
278− if (isUnclosedTail) append("*")
279− appendInlineCode(segment, linkColor, codeBackground, context)
280− }
281− }
282−}
283−
284−/**
285− * Append [text] to the builder, rendering single-backtick pairs as inline code
286− * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287− * into a clickable link.
288− */
289−private fun AnnotatedString.Builder.appendInlineCode(
290− text: String,
291− linkColor: Color,
292− codeBackground: Color,
293− context: Context,
294−) {
295− // Odd-indexed segments sit between a pair of single backticks, so they're inline
296− // code. Even segments are ordinary prose and get URL linking applied.
297− val segments = text.split("`")
298− // An even segment count means an odd number of backtick markers, so the final backtick
299− // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300− val unclosed = segments.size % 2 == 0
301− segments.forEachIndexed { index, segment ->
302− val isUnclosedTail = unclosed && index == segments.size - 1
303− if (index % 2 == 1 && !isUnclosedTail) {
304− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305− append(segment)
306− }
307− } else {
308− if (isUnclosedTail) append("`")
309− appendUrls(segment, linkColor, context)
310− }
311− }
312−}
313−
314−/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315−private fun AnnotatedString.Builder.appendUrls(
316− text: String,
317− linkColor: Color,
318− context: Context,
319−) {
320− val matches = urlRegex.findAll(text).toList()
321− var cursor = 0
322− for (match in matches) {
323− append(text.substring(cursor, match.range.first))
324− val url = match.value
325− withLink(
326− LinkAnnotation.Clickable(
327− tag = url,
328− styles = TextLinkStyles(
329− SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330− ),
331− linkInteractionListener = {
332− runCatching {
333− context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334− }
335− },
336− ),
337− ) {
338− append(url)
339− }
340− cursor = match.range.last + 1
341− }
342− append(text.substring(cursor))
343−}
344−
345−private fun shareText(context: Context, text: String) {
346− val intent = Intent(Intent.ACTION_SEND).apply {
347− type = "text/plain"
348− putExtra(Intent.EXTRA_TEXT, text)
349− }
350− context.startActivity(Intent.createChooser(intent, null))
351−}
352−
353−@Composable
354−private fun ReasoningBlock(text: String, context: Context) {
355− var expanded by remember { mutableStateOf(false) }
356− Column(modifier = Modifier.padding(bottom = 6.dp)) {
357− Row(
358− verticalAlignment = Alignment.CenterVertically,
359− modifier = Modifier.clickable { expanded = !expanded },
360− ) {
361− Text(
362− text = if (expanded) "Show less" else "Reasoning",
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
365− )
366− Icon(
367− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368− contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369− tint = MaterialTheme.colorScheme.onSurfaceVariant,
370− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371− )
372− }
373− if (expanded) {
374− Text(
375− text = text,
376− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377− color = MaterialTheme.colorScheme.onSurfaceVariant,
378− modifier = Modifier.padding(top = 4.dp),
379− )
380− Text(
381− text = "Copy",
382− style = MaterialTheme.typography.labelSmall,
383− color = MaterialTheme.colorScheme.primary,
384− modifier = Modifier
385− .padding(top = 4.dp)
386− .clickable {
387− copyToClipboard(context, text)
388− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389− },
390− )
391− }
392− }
393−}
394−
395−@Composable
396−private fun ToolRow(tool: ToolView) {
397− var expanded by remember { mutableStateOf(false) }
398− val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399− val statusColor = when (tool.status) {
400− "completed", "success" -> MaterialTheme.colorScheme.primary
401− "error", "failed" -> MaterialTheme.colorScheme.error
402− "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403− else -> MaterialTheme.colorScheme.onSurfaceVariant
404− }
405− Row(
406− verticalAlignment = Alignment.CenterVertically,
407− modifier = Modifier
408− .fillMaxWidth()
409− .clickable { expanded = !expanded }
410− .padding(bottom = 6.dp),
411− ) {
412− Icon(
413− Icons.Filled.Build,
414− contentDescription = null,
415− tint = MaterialTheme.colorScheme.primary,
416− modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417− )
418− Text(
419− text = buildAnnotatedString {
420− withStyle(SpanStyle(color = baseColor)) {
421− append(tool.name)
422− append(" · ")
423− }
424− withStyle(SpanStyle(color = statusColor)) {
425− append(tool.status)
426− }
427− if (expanded) tool.title?.let {
428− withStyle(SpanStyle(color = baseColor)) {
429− append(" — ")
430− append(it)
431− }
432− }
433− },
434− style = MonoStyle,
435− color = baseColor,
436− maxLines = if (expanded) Int.MAX_VALUE else 1,
437− overflow = TextOverflow.Ellipsis,
438− modifier = Modifier.weight(1f),
439− )
440− Icon(
441− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442− contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443− tint = MaterialTheme.colorScheme.onSurfaceVariant,
444− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445− )
446− }
447−}
448−
449−@Composable
450−private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451− val (icon, label, tint) = when (status) {
452− MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453− MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454− MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455− }
456− Row(
457− verticalAlignment = Alignment.CenterVertically,
458− horizontalArrangement = Arrangement.End,
459− modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460− ) {
461− Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462− Text(
463− text = label,
464− style = MaterialTheme.typography.labelSmall,
465− color = tint,
466− modifier = Modifier.padding(start = 4.dp),
467− )
468− if (status == MessageStatus.Failed) {
469− Text(
470− text = "Retry",
471− style = MaterialTheme.typography.labelSmall,
472− color = MaterialTheme.colorScheme.primary,
473− modifier = Modifier
474− .padding(start = 12.dp)
475− .clickable { onRetry() },
476− )
477− }
478− }
479−}
app/src/main/java/soy/iko/opencode/ui/components/StubScreen.ktnew file+41
@@ -0,0 +1,41 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.layout.Box
4+import androidx.compose.foundation.layout.padding
5+import androidx.compose.material.icons.Icons
6+import androidx.compose.material.icons.automirrored.filled.ArrowBack
7+import androidx.compose.material3.ExperimentalMaterial3Api
8+import androidx.compose.material3.Icon
9+import androidx.compose.material3.IconButton
10+import androidx.compose.material3.Scaffold
11+import androidx.compose.material3.Text
12+import androidx.compose.material3.TopAppBar
13+import androidx.compose.runtime.Composable
14+import androidx.compose.ui.Alignment
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+
18+/** Shared placeholder for not-yet-implemented destinations. */
19+@OptIn(ExperimentalMaterial3Api::class)
20+@Composable
21+fun StubScreen(title: String, message: String, onBack: () -> Unit) {
22+ Scaffold(
23+ topBar = {
24+ TopAppBar(
25+ title = { Text(title) },
26+ navigationIcon = {
27+ IconButton(onClick = onBack) {
28+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
29+ }
30+ },
31+ )
32+ },
33+ ) { padding ->
34+ Box(
35+ modifier = Modifier.padding(padding).padding(24.dp),
36+ contentAlignment = Alignment.Center,
37+ ) {
38+ Text(message)
39+ }
40+ }
41+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.foundation.lazy.LazyColumn
12+import androidx.compose.foundation.lazy.items
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.automirrored.filled.ArrowBack
15+import androidx.compose.material.icons.filled.Description
16+import androidx.compose.material.icons.filled.Folder
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.HorizontalDivider
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Scaffold
26+import androidx.compose.material3.Text
27+import androidx.compose.material3.TopAppBar
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.ui.Alignment
31+import androidx.compose.ui.Modifier
32+import androidx.compose.ui.text.style.TextOverflow
33+import androidx.compose.ui.unit.dp
34+import androidx.lifecycle.compose.collectAsStateWithLifecycle
35+import androidx.lifecycle.viewmodel.compose.viewModel
36+import soy.iko.opencode.di.AppContainer
37+import soy.iko.opencode.ui.vmFactory
38+
39+@OptIn(ExperimentalMaterial3Api::class)
40+@Composable
41+fun FileBrowserScreen(
42+ container: AppContainer,
43+ onOpenFile: (String) -> Unit,
44+ onBack: () -> Unit,
45+) {
46+ val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
47+ val state by vm.state.collectAsStateWithLifecycle()
48+
49+ Scaffold(
50+ topBar = {
51+ TopAppBar(
52+ title = { Text(if (state.path.isBlank()) "Files" else state.path, maxLines = 1, overflow = TextOverflow.Ellipsis) },
53+ navigationIcon = {
54+ IconButton(onClick = onBack) {
55+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
56+ }
57+ },
58+ )
59+ },
60+ ) { padding ->
61+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
62+ OutlinedTextField(
63+ value = state.query,
64+ onValueChange = vm::setQuery,
65+ modifier = Modifier.fillMaxWidth().padding(12.dp),
66+ placeholder = { Text("Search files…") },
67+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
68+ singleLine = true,
69+ )
70+
71+ Box(modifier = Modifier.fillMaxSize()) {
72+ when {
73+ state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
74+ state.error != null && !state.isSearching -> Text(
75+ state.error!!,
76+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
77+ color = MaterialTheme.colorScheme.error,
78+ )
79+ state.isSearching -> SearchResults(state.results, onOpenFile)
80+ else -> DirectoryListing(
81+ state = state,
82+ onOpenDir = vm::open,
83+ onUp = vm::up,
84+ onOpenFile = onOpenFile,
85+ )
86+ }
87+ }
88+ }
89+ }
90+}
91+
92+@Composable
93+private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
94+ if (results.isEmpty()) {
95+ Text("No matches.", modifier = Modifier.padding(24.dp))
96+ return
97+ }
98+ LazyColumn(modifier = Modifier.fillMaxSize()) {
99+ items(results, key = { it }) { path ->
100+ FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
101+ HorizontalDivider()
102+ }
103+ }
104+}
105+
106+@Composable
107+private fun DirectoryListing(
108+ state: FileBrowserState,
109+ onOpenDir: (String) -> Unit,
110+ onUp: () -> Unit,
111+ onOpenFile: (String) -> Unit,
112+) {
113+ LazyColumn(modifier = Modifier.fillMaxSize()) {
114+ if (state.path.isNotBlank()) {
115+ item(key = "__up") {
116+ FileRow(icon = true, label = "..", onClick = onUp)
117+ HorizontalDivider()
118+ }
119+ }
120+ items(state.entries, key = { it.path }) { node ->
121+ FileRow(
122+ icon = node.isDirectory,
123+ label = node.name,
124+ onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
125+ )
126+ HorizontalDivider()
127+ }
128+ }
129+}
130+
131+@Composable
132+private fun FileRow(icon: Boolean, label: String, onClick: () -> Unit) {
133+ Row(
134+ modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
135+ verticalAlignment = Alignment.CenterVertically,
136+ ) {
137+ Icon(
138+ if (icon) Icons.Filled.Folder else Icons.Filled.Description,
139+ contentDescription = null,
140+ modifier = Modifier.size(20.dp),
141+ tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
142+ )
143+ Text(
144+ " $label",
145+ style = MaterialTheme.typography.bodyLarge,
146+ maxLines = 1,
147+ overflow = TextOverflow.Ellipsis,
148+ )
149+ }
150+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+79
@@ -0,0 +1,79 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.Job
8+import kotlinx.coroutines.delay
9+import kotlinx.coroutines.flow.MutableStateFlow
10+import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.launch
13+
14+data class FileBrowserState(
15+ val path: String = "",
16+ val entries: List<FileNode> = emptyList(),
17+ val query: String = "",
18+ val results: List<String> = emptyList(),
19+ val searching: Boolean = false,
20+ val loading: Boolean = true,
21+ val error: String? = null,
22+) {
23+ val isSearching: Boolean get() = query.isNotBlank()
24+}
25+
26+class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
27+
28+ private val api get() = container.activeConnection.value?.api
29+
30+ private val _state = MutableStateFlow(FileBrowserState())
31+ val state: StateFlow<FileBrowserState> = _state.asStateFlow()
32+
33+ private var searchJob: Job? = null
34+
35+ init { open("") }
36+
37+ fun open(path: String) {
38+ val client = api ?: run {
39+ _state.value = _state.value.copy(loading = false, error = "Not connected")
40+ return
41+ }
42+ _state.value = _state.value.copy(path = path, loading = true, error = null)
43+ viewModelScope.launch {
44+ runCatching { client.listDirectory(path) }
45+ .onSuccess { entries ->
46+ _state.value = _state.value.copy(
47+ entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
48+ loading = false,
49+ )
50+ }
51+ .onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Failed to list $path") }
52+ }
53+ }
54+
55+ /** Navigate up one path segment. */
56+ fun up() {
57+ val current = _state.value.path
58+ if (current.isBlank()) return
59+ val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
60+ open(parent)
61+ }
62+
63+ fun setQuery(query: String) {
64+ _state.value = _state.value.copy(query = query)
65+ searchJob?.cancel()
66+ if (query.isBlank()) {
67+ _state.value = _state.value.copy(results = emptyList(), searching = false)
68+ return
69+ }
70+ val client = api ?: return
71+ searchJob = viewModelScope.launch {
72+ delay(250) // debounce
73+ _state.value = _state.value.copy(searching = true)
74+ runCatching { client.findFiles(query) }
75+ .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
76+ .onFailure { _state.value = _state.value.copy(searching = false, error = it.message) }
77+ }
78+ }
79+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+38
@@ -0,0 +1,38 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.FileContent
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.StateFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.launch
11+
12+data class FileViewState(
13+ val loading: Boolean = true,
14+ val content: FileContent? = null,
15+ val error: String? = null,
16+)
17+
18+class FileViewModel(
19+ private val container: AppContainer,
20+ private val path: String,
21+) : ViewModel() {
22+
23+ private val _state = MutableStateFlow(FileViewState())
24+ val state: StateFlow<FileViewState> = _state.asStateFlow()
25+
26+ init {
27+ val api = container.activeConnection.value?.api
28+ if (api == null) {
29+ _state.value = FileViewState(loading = false, error = "Not connected")
30+ } else {
31+ viewModelScope.launch {
32+ runCatching { api.readFile(path) }
33+ .onSuccess { _state.value = FileViewState(loading = false, content = it) }
34+ .onFailure { _state.value = FileViewState(loading = false, error = it.message ?: "Failed to read $path") }
35+ }
36+ }
37+ }
38+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+93
@@ -0,0 +1,93 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.horizontalScroll
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxSize
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.rememberScrollState
8+import androidx.compose.foundation.verticalScroll
9+import androidx.compose.material.icons.Icons
10+import androidx.compose.material.icons.automirrored.filled.ArrowBack
11+import androidx.compose.material3.CircularProgressIndicator
12+import androidx.compose.material3.ExperimentalMaterial3Api
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Scaffold
17+import androidx.compose.material3.Text
18+import androidx.compose.material3.TopAppBar
19+import androidx.compose.runtime.Composable
20+import androidx.compose.runtime.getValue
21+import androidx.compose.ui.Alignment
22+import androidx.compose.ui.Modifier
23+import androidx.compose.ui.text.font.FontFamily
24+import androidx.compose.ui.text.style.TextOverflow
25+import androidx.compose.ui.unit.dp
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+import androidx.lifecycle.viewmodel.compose.viewModel
28+import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.components.DiffView
30+import soy.iko.opencode.ui.vmFactory
31+
32+@OptIn(ExperimentalMaterial3Api::class)
33+@Composable
34+fun FileViewScreen(
35+ container: AppContainer,
36+ path: String,
37+ onBack: () -> Unit,
38+) {
39+ val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
40+ val state by vm.state.collectAsStateWithLifecycle()
41+
42+ Scaffold(
43+ topBar = {
44+ TopAppBar(
45+ title = { Text(path.substringAfterLast('/'), maxLines = 1, overflow = TextOverflow.Ellipsis) },
46+ navigationIcon = {
47+ IconButton(onClick = onBack) {
48+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
49+ }
50+ },
51+ )
52+ },
53+ ) { padding ->
54+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
55+ when {
56+ state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
57+ state.error != null -> Text(
58+ state.error!!,
59+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
60+ color = MaterialTheme.colorScheme.error,
61+ )
62+ state.content?.isBinary == true -> Text(
63+ "Binary file — preview not supported.",
64+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
65+ color = MaterialTheme.colorScheme.onSurfaceVariant,
66+ )
67+ else -> {
68+ val content = state.content
69+ val scrollState = rememberScrollState()
70+ if (content?.diff != null && content.diff.isNotBlank()) {
71+ DiffView(
72+ diff = content.diff,
73+ modifier = Modifier
74+ .fillMaxSize()
75+ .padding(padding),
76+ )
77+ } else {
78+ Text(
79+ text = content?.content.orEmpty(),
80+ modifier = Modifier
81+ .fillMaxSize()
82+ .verticalScroll(scrollState)
83+ .horizontalScroll(rememberScrollState())
84+ .padding(12.dp),
85+ style = MaterialTheme.typography.bodySmall,
86+ fontFamily = FontFamily.Monospace,
87+ )
88+ }
89+ }
90+ }
91+ }
92+ }
93+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktdeleted−590
@@ -1,590 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.animation.AnimatedVisibility
8−import androidx.compose.animation.core.RepeatMode
9−import androidx.compose.animation.core.animateFloat
10−import androidx.compose.animation.core.infiniteRepeatable
11−import androidx.compose.animation.core.rememberInfiniteTransition
12−import androidx.compose.animation.core.tween
13−import androidx.compose.animation.fadeIn
14−import androidx.compose.animation.fadeOut
15−import androidx.compose.animation.scaleIn
16−import androidx.compose.animation.scaleOut
17−import androidx.compose.foundation.ExperimentalFoundationApi
18−import androidx.compose.foundation.background
19−import androidx.compose.foundation.clickable
20−import androidx.compose.foundation.combinedClickable
21−import androidx.compose.foundation.layout.Arrangement
22−import androidx.compose.foundation.layout.Box
23−import androidx.compose.foundation.layout.BoxScope
24−import androidx.compose.foundation.layout.Column
25−import androidx.compose.foundation.layout.Row
26−import androidx.compose.foundation.layout.Spacer
27−import androidx.compose.foundation.layout.fillMaxSize
28−import 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
34−import androidx.compose.foundation.layout.padding
35−import androidx.compose.foundation.layout.size
36−import androidx.compose.foundation.lazy.LazyColumn
37−import androidx.compose.foundation.lazy.itemsIndexed
38−import 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
43−import androidx.compose.material.icons.Icons
44−import 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
48−import 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
52−import androidx.compose.material3.ExperimentalMaterial3Api
53−import androidx.compose.material3.FilledIconButton
54−import androidx.compose.material3.FilledTonalButton
55−import androidx.compose.material3.FloatingActionButton
56−import androidx.compose.material3.Icon
57−import androidx.compose.material3.IconButton
58−import androidx.compose.material3.LinearProgressIndicator
59−import androidx.compose.material3.MaterialTheme
60−import androidx.compose.material3.OutlinedTextField
61−import androidx.compose.material3.Scaffold
62−import androidx.compose.material3.Surface
63−import androidx.compose.material3.Text
64−import androidx.compose.material3.TextButton
65−import androidx.compose.material3.TopAppBar
66−import androidx.compose.runtime.Composable
67−import androidx.compose.runtime.LaunchedEffect
68−import androidx.compose.runtime.derivedStateOf
69−import androidx.compose.runtime.getValue
70−import androidx.compose.runtime.mutableStateOf
71−import androidx.compose.runtime.remember
72−import androidx.compose.runtime.rememberCoroutineScope
73−import androidx.compose.runtime.setValue
74−import androidx.compose.ui.Alignment
75−import androidx.compose.ui.Modifier
76−import androidx.compose.ui.draw.clip
77−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
78−import androidx.compose.ui.platform.LocalClipboardManager
79−import androidx.compose.ui.platform.LocalContext
80−import androidx.compose.ui.platform.LocalDensity
81−import androidx.compose.ui.platform.LocalHapticFeedback
82−import androidx.compose.ui.text.font.FontStyle
83−import androidx.compose.ui.text.input.ImeAction
84−import androidx.compose.ui.text.style.TextAlign
85−import androidx.compose.ui.text.style.TextOverflow
86−import androidx.compose.ui.unit.dp
87−import kotlinx.coroutines.launch
88−import soy.iko.opencode.core.Event
89−import soy.iko.opencode.core.UiState
90−import soy.iko.opencode.ui.components.MessageBubble
91−
92−@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
93−@Composable
94−fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
95− 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
103−
104− // Show a jump-to-bottom button once the user scrolls up away from the latest
105− // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
106− // it only flips when the near-bottom threshold is actually crossed.
107− val showScrollButton by remember {
108− derivedStateOf {
109− val layoutInfo = listState.layoutInfo
110− val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111− val totalItems = layoutInfo.totalItemsCount
112− totalItems > 0 && lastVisibleIndex < totalItems - 3
113− }
114− }
115−
116− // Offer a jump-to-top shortcut once the conversation is long and the user has
117− // scrolled a few messages down — saves flick-scrolling back through history.
118− val showScrollTop by remember {
119− derivedStateOf { state.messages.size > 10 && listState.firstVisibleItemIndex > 3 }
120− }
121−
122− // Track how many messages the user has "seen" (i.e. was at the bottom for). While
123− // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
124− var lastSeenCount by remember { mutableStateOf(0) }
125− LaunchedEffect(showScrollButton, state.messages.size) {
126− if (!showScrollButton) {
127− lastSeenCount = state.messages.size
128− }
129− }
130− val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131−
132− // Keep the newest content in view as it streams in, but only when the user is
133− // already near the bottom. If they've scrolled up to read history, leave it alone.
134− val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
135− LaunchedEffect(state.messages.size, lastLen, state.generating) {
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)
143− }
144− }
145− }
146−
147− Scaffold(
148− topBar = {
149− TopAppBar(
150− 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− )
166− },
167− navigationIcon = {
168− IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
169− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
170− }
171− },
172− actions = {
173− if (state.generating) {
174− CircularProgressIndicator(
175− modifier = Modifier.padding(end = 16.dp).size(22.dp),
176− strokeWidth = 2.dp,
177− )
178− }
179− },
180− )
181− },
182− bottomBar = {
183− 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) },
190− )
191− },
192− ) { 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− }
226− }
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− )
245− }
246− // While the assistant is spinning up but hasn't emitted a streaming
247− // message yet (only the user's message is present), show a typing
248− // indicator so the wait doesn't look like a stall.
249− if (state.generating && state.messages.none { it.streaming }) {
250− item(key = "typing-indicator") {
251− TypingIndicator()
252− }
253− }
254− }
255− ScrollToBottomButton(
256− visible = showScrollButton && !isImeVisible,
257− showBadge = hasUnseenMessages,
258− onClick = {
259− coroutineScope.launch {
260− if (state.messages.isNotEmpty()) {
261− // When the typing indicator is showing it occupies the item
262− // just past the last message, so scroll one further to reach it.
263− val target = if (state.generating && state.messages.none { it.streaming })
264− state.messages.size
265− else
266− state.messages.size - 1
267− listState.animateScrollToItem(target)
268− }
269− }
270− },
271− )
272− ScrollToTopButton(
273− visible = showScrollTop,
274− onClick = {
275− coroutineScope.launch {
276− listState.animateScrollToItem(0)
277− }
278− },
279− )
280− }
281− }
282− }
283−}
284−
285−// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
286−// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
287−// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
288−@Composable
289−private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
290− AnimatedVisibility(
291− visible = visible,
292− modifier = Modifier.align(Alignment.BottomEnd),
293− enter = scaleIn() + fadeIn(),
294− exit = scaleOut() + fadeOut(),
295− ) {
296− Box(Modifier.padding(16.dp)) {
297− FloatingActionButton(
298− onClick = onClick,
299− modifier = Modifier.size(44.dp),
300− ) {
301− Icon(
302− Icons.Filled.KeyboardArrowDown,
303− contentDescription = "Scroll to latest message",
304− )
305− }
306− if (showBadge) {
307− Box(
308− modifier = Modifier
309− .align(Alignment.TopEnd)
310− .size(8.dp)
311− .clip(CircleShape)
312− .background(MaterialTheme.colorScheme.error),
313− )
314− }
315− }
316− }
317−}
318−
319−// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320−// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
321−@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)
374− }
375−}
376−
377−@Composable
378−private fun DateSeparator(epochMillis: Long) {
379− Text(
380− text = dateSeparatorLabel(localDayOf(epochMillis)),
381− style = MaterialTheme.typography.labelSmall,
382− color = MaterialTheme.colorScheme.onSurfaceVariant,
383− textAlign = TextAlign.Center,
384− modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
385− )
386−}
387−
388−// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
389−// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
390−// surfaceVariant look so it reads as an incoming message.
391−@Composable
392−private fun TypingIndicator() {
393− val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
394− val transition = rememberInfiniteTransition(label = "typing")
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− }
433− }
434−}
435−
436−@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− }
459−}
460−
461−@Composable
462−private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
463− Surface(
464− modifier = Modifier.fillMaxWidth(),
465− color = MaterialTheme.colorScheme.errorContainer,
466− ) {
467− Row(
468− modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
469− verticalAlignment = Alignment.CenterVertically,
470− horizontalArrangement = Arrangement.spacedBy(8.dp),
471− ) {
472− Text(
473− text = message,
474− style = MaterialTheme.typography.bodySmall,
475− color = MaterialTheme.colorScheme.onErrorContainer,
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,
493− generating: Boolean,
494− loading: Boolean,
495− onDraftChange: (String) -> Unit,
496− onSend: () -> Unit,
497− onStop: () -> Unit,
498−) {
499− val haptic = LocalHapticFeedback.current
500− val clipboardManager = LocalClipboardManager.current
501− Surface(tonalElevation = 3.dp) {
502− Column(
503− modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
504− ) {
505− Row(
506− modifier = Modifier.fillMaxWidth(),
507− verticalAlignment = Alignment.Bottom,
508− horizontalArrangement = Arrangement.spacedBy(8.dp),
509− ) {
510− OutlinedTextField(
511− value = draft,
512− onValueChange = onDraftChange,
513− modifier = Modifier.weight(1f).heightIn(min = 48.dp),
514− enabled = !loading,
515− placeholder = { Text("Message opencode…") },
516− maxLines = 5,
517− // Offer a quick way to wipe the draft once there's something to clear
518− // and we're not mid-generation (where the field is a poor place to fuss).
519− trailingIcon = if (draft.isNotEmpty() && !generating) {
520− {
521− IconButton(
522− onClick = { onDraftChange("") },
523− modifier = Modifier.size(20.dp),
524− ) {
525− Icon(
526− Icons.Filled.Close,
527− contentDescription = "Clear draft",
528− modifier = Modifier.size(16.dp),
529− )
530− }
531− }
532− } else {
533− null
534− },
535− // Surface a running count once the draft gets long, so the user has a
536− // sense of scale before sending a wall of text.
537− supportingText = if (draft.length > 500) {
538− {
539− Text(
540− text = "${draft.length} characters",
541− style = MaterialTheme.typography.labelSmall,
542− color = MaterialTheme.colorScheme.onSurfaceVariant,
543− textAlign = TextAlign.End,
544− modifier = Modifier.fillMaxWidth(),
545− )
546− }
547− } else {
548− null
549− },
550− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551− keyboardActions = KeyboardActions(onSend = {
552− if (!generating && !loading && draft.isNotBlank()) onSend()
553− }),
554− )
555− if (generating) {
556− FilledTonalButton(onClick = onStop) {
557− Icon(
558− Icons.Filled.Stop,
559− contentDescription = null,
560− modifier = Modifier.size(18.dp),
561− )
562− Spacer(Modifier.size(6.dp))
563− Text("Stop")
564− }
565− } else {
566− FilledIconButton(
567− onClick = {
568− haptic.performHapticFeedback(HapticFeedbackType.LongPress)
569− onSend()
570− },
571− enabled = draft.isNotBlank() && !loading,
572− ) {
573− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
574− }
575− }
576− }
577− // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
578− if (draft.isEmpty() && !generating && !loading) {
579− TextButton(
580− onClick = {
581− clipboardManager.getText()?.text?.let { onDraftChange(it) }
582− },
583− contentPadding = PaddingValues(0.dp),
584− ) {
585− Text("Paste")
586− }
587− }
588− }
589− }
590−}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktdeleted−222
@@ -1,222 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Spacer
6−import androidx.compose.foundation.layout.fillMaxSize
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.height
9−import androidx.compose.foundation.layout.imePadding
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.text.KeyboardActions
14−import androidx.compose.foundation.text.KeyboardOptions
15−import androidx.compose.foundation.verticalScroll
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material.icons.filled.Person
19−import androidx.compose.material.icons.filled.Terminal
20−import androidx.compose.material.icons.filled.Visibility
21−import androidx.compose.material.icons.filled.VisibilityOff
22−import androidx.compose.material3.Button
23−import androidx.compose.material3.CircularProgressIndicator
24−import androidx.compose.material3.Icon
25−import androidx.compose.material3.IconButton
26−import androidx.compose.material3.MaterialTheme
27−import androidx.compose.material3.OutlinedTextField
28−import androidx.compose.material3.Text
29−import androidx.compose.runtime.Composable
30−import androidx.compose.runtime.LaunchedEffect
31−import androidx.compose.runtime.getValue
32−import androidx.compose.runtime.mutableStateOf
33−import androidx.compose.runtime.remember
34−import androidx.compose.runtime.setValue
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.focus.FocusRequester
38−import androidx.compose.ui.focus.focusRequester
39−import androidx.compose.ui.text.input.ImeAction
40−import androidx.compose.ui.text.input.KeyboardType
41−import androidx.compose.ui.text.input.PasswordVisualTransformation
42−import androidx.compose.ui.text.input.VisualTransformation
43−import androidx.compose.ui.text.style.TextAlign
44−import androidx.compose.ui.unit.dp
45−import soy.iko.opencode.core.Event
46−import soy.iko.opencode.core.UiState
47−
48−@Composable
49−fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50− val focusRequester = remember { FocusRequester() }
51− var passwordVisible by remember { mutableStateOf(false) }
52−
53− // Focus the server URL field when the screen opens, but only when it's empty
54− // so we don't steal focus (and pop the keyboard) on a return visit.
55− LaunchedEffect(Unit) {
56− if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
57− }
58−
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− )
103−
104− // Point first-time users at the expected address format. Hidden once they
105− // start typing so it doesn't compete with the field's own contents.
106− if (state.serverUrl.isEmpty()) {
107− Spacer(Modifier.height(6.dp))
108− Text(
109− text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110− style = MaterialTheme.typography.bodySmall,
111− color = MaterialTheme.colorScheme.onSurfaceVariant,
112− textAlign = TextAlign.Center,
113− )
114− }
115−
116− // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117− // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118− if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119− Spacer(Modifier.height(6.dp))
120− 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,
125− )
126− }
127−
128− if (state.authRequired) {
129− Spacer(Modifier.height(12.dp))
130− OutlinedTextField(
131− value = state.username,
132− onValueChange = { dispatch(Event.UsernameChanged(it)) },
133− label = { Text("Username") },
134− singleLine = true,
135− leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136− modifier = Modifier.fillMaxWidth(),
137− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138− enabled = !state.loading,
139− )
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− )
154− }
155− },
156− modifier = Modifier.fillMaxWidth(),
157− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158− keyboardActions = KeyboardActions(onGo = {
159− // Mirror the connect button's enabled condition so the keyboard's
160− // Go action can't fire a request the form isn't ready for.
161− if (!state.loading && state.serverUrl.isNotBlank() &&
162− (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163− ) {
164− dispatch(Event.Connect)
165− }
166− }),
167− enabled = !state.loading,
168− )
169− Spacer(Modifier.height(4.dp))
170− Text(
171− text = "This server requires authentication.",
172− style = MaterialTheme.typography.bodySmall,
173− color = MaterialTheme.colorScheme.onSurfaceVariant,
174− )
175− }
176−
177− Spacer(Modifier.height(24.dp))
178−
179− Button(
180− onClick = { dispatch(Event.Connect) },
181− // When auth is required, keep the button disabled until at least one
182− // credential is entered — otherwise it just triggers a validation error.
183− enabled = !state.loading && state.serverUrl.isNotBlank() &&
184− (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
185− modifier = Modifier.fillMaxWidth(),
186− ) {
187− if (state.loading) {
188− CircularProgressIndicator(
189− modifier = Modifier.height(20.dp),
190− strokeWidth = 2.dp,
191− color = MaterialTheme.colorScheme.onPrimary,
192− )
193− } else {
194− Text(if (state.authRequired) "Sign in" else "Connect")
195− }
196− }
197−
198− // While connecting (including auto-connect on launch), echo the target URL
199− // so the user knows what the spinner is waiting on.
200− if (state.loading) {
201− Spacer(Modifier.height(8.dp))
202− Text(
203− text = "Connecting to ${state.serverUrl}...",
204− style = MaterialTheme.typography.bodySmall,
205− color = MaterialTheme.colorScheme.onSurfaceVariant,
206− textAlign = TextAlign.Center,
207− )
208− }
209−
210− // Surface the error inline as well, so it stays visible while the user
211− // corrects the form (the snackbar is transient).
212− if (state.error != null && !state.loading) {
213− Spacer(Modifier.height(8.dp))
214− Text(
215− text = state.error,
216− style = MaterialTheme.typography.bodySmall,
217− color = MaterialTheme.colorScheme.error,
218− textAlign = TextAlign.Center,
219− )
220− }
221− }
222−}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktdeleted−402
@@ -1,402 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.foundation.ExperimentalFoundationApi
8−import androidx.compose.foundation.background
9−import androidx.compose.foundation.clickable
10−import androidx.compose.foundation.combinedClickable
11−import androidx.compose.foundation.layout.Arrangement
12−import androidx.compose.foundation.layout.Box
13−import androidx.compose.foundation.layout.Column
14−import androidx.compose.foundation.layout.PaddingValues
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.Spacer
17−import androidx.compose.foundation.layout.fillMaxSize
18−import androidx.compose.foundation.layout.fillMaxWidth
19−import androidx.compose.foundation.layout.height
20−import androidx.compose.foundation.layout.padding
21−import androidx.compose.foundation.layout.size
22−import androidx.compose.foundation.lazy.LazyColumn
23−import androidx.compose.foundation.lazy.items
24−import androidx.compose.foundation.lazy.rememberLazyListState
25−import androidx.compose.foundation.shape.CircleShape
26−import androidx.compose.material.icons.Icons
27−import androidx.compose.material.icons.automirrored.filled.ArrowBack
28−import 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
33−import androidx.compose.material3.AlertDialog
34−import androidx.compose.material3.CircularProgressIndicator
35−import androidx.compose.material3.ExperimentalMaterial3Api
36−import androidx.compose.material3.ExtendedFloatingActionButton
37−import androidx.compose.material3.HorizontalDivider
38−import androidx.compose.material3.Icon
39−import androidx.compose.material3.IconButton
40−import androidx.compose.material3.LinearProgressIndicator
41−import androidx.compose.material3.MaterialTheme
42−import androidx.compose.material3.OutlinedTextField
43−import androidx.compose.material3.Scaffold
44−import androidx.compose.material3.Surface
45−import androidx.compose.material3.SwipeToDismissBox
46−import androidx.compose.material3.SwipeToDismissBoxValue
47−import androidx.compose.material3.Text
48−import androidx.compose.material3.TextButton
49−import androidx.compose.material3.TopAppBar
50−import androidx.compose.material3.rememberSwipeToDismissBoxState
51−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52−import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
53−import androidx.compose.runtime.Composable
54−import androidx.compose.runtime.LaunchedEffect
55−import androidx.compose.runtime.getValue
56−import androidx.compose.runtime.mutableStateOf
57−import androidx.compose.runtime.remember
58−import androidx.compose.runtime.setValue
59−import androidx.compose.ui.Alignment
60−import androidx.compose.ui.Modifier
61−import androidx.compose.ui.draw.clip
62−import androidx.compose.ui.graphics.Color
63−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
64−import androidx.compose.ui.platform.LocalContext
65−import androidx.compose.ui.platform.LocalHapticFeedback
66−import androidx.compose.ui.text.style.TextAlign
67−import androidx.compose.ui.text.style.TextOverflow
68−import androidx.compose.ui.unit.dp
69−import soy.iko.opencode.core.Event
70−import soy.iko.opencode.core.SessionView
71−import soy.iko.opencode.core.UiState
72−
73−@OptIn(ExperimentalMaterial3Api::class)
74−@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()
81−
82− // Bring a newly created session (which lands at the top of the list) into view.
83− // Only scroll when the list actually grows, not on every reload of the same sessions.
84− var prevSize by remember { mutableStateOf(state.sessions.size) }
85− LaunchedEffect(state.sessions.size) {
86− if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
87− prevSize = state.sessions.size
88− }
89−
90− // The pull-to-refresh spinner should only reflect a user-initiated pull, not
91− // every loading operation. Clear it once the underlying load completes.
92− LaunchedEffect(state.loading) {
93− if (!state.loading) isPullRefreshing = false
94− }
95−
96− pendingDeleteId?.let { deleteId ->
97− val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
98− AlertDialog(
99− onDismissRequest = { pendingDeleteId = null },
100− title = { Text("Delete session?") },
101− text = { Text(pendingSession?.title ?: deleteId) },
102− confirmButton = {
103− TextButton(onClick = {
104− haptic.performHapticFeedback(HapticFeedbackType.LongPress)
105− dispatch(Event.DeleteSession(deleteId))
106− pendingDeleteId = null
107− }) {
108− Text("Delete")
109− }
110− },
111− dismissButton = {
112− TextButton(onClick = { pendingDeleteId = null }) {
113− Text("Cancel")
114− }
115− },
116− )
117− }
118−
119− Scaffold(
120− topBar = {
121− 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− },
139− navigationIcon = {
140− IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
142− }
143− },
144− actions = {
145− IconButton(onClick = { dispatch(Event.LoadSessions) }) {
146− Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
147− }
148− },
149− )
150− },
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− },
162− ) { 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− }
189− when {
190− state.sessions.isEmpty() && !state.loading -> EmptySessions()
191− filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
192− else -> PullToRefreshBox(
193− isRefreshing = isPullRefreshing,
194− onRefresh = {
195− isPullRefreshing = true
196− dispatch(Event.LoadSessions)
197− },
198− modifier = Modifier.fillMaxSize(),
199− state = rememberPullToRefreshState(),
200− ) {
201− LazyColumn(
202− state = sessionsListState,
203− modifier = Modifier.fillMaxSize(),
204− contentPadding = PaddingValues(bottom = 88.dp),
205− ) {
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− },
219− )
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))
232− }
233− }
234− }
235− }
236− }
237− }
238− }
239−}
240−
241−@OptIn(ExperimentalFoundationApi::class)
242−@Composable
243−private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
244− val context = LocalContext.current
245− Row(
246− modifier = Modifier
247− .fillMaxWidth()
248− .background(MaterialTheme.colorScheme.surface)
249− .combinedClickable(
250− onClick = onOpen,
251− onLongClick = {
252− copyToClipboard(context, session.id)
253− Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
254− },
255− )
256− .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
257− verticalAlignment = Alignment.CenterVertically,
258− ) {
259− Icon(
260− Icons.Filled.ChatBubbleOutline,
261− contentDescription = null,
262− tint = MaterialTheme.colorScheme.primary,
263− modifier = Modifier.padding(end = 16.dp),
264− )
265− Column(Modifier.weight(1f)) {
266− Text(
267− text = session.title,
268− style = MaterialTheme.typography.bodyLarge,
269− maxLines = 1,
270− overflow = TextOverflow.Ellipsis,
271− )
272− Text(
273− text = shortSessionId(session.id),
274− style = MaterialTheme.typography.bodySmall,
275− color = MaterialTheme.colorScheme.onSurfaceVariant,
276− maxLines = 1,
277− overflow = TextOverflow.Ellipsis,
278− )
279− }
280− Icon(
281− Icons.Filled.ChevronRight,
282− contentDescription = null,
283− tint = MaterialTheme.colorScheme.onSurfaceVariant,
284− modifier = Modifier.padding(horizontal = 4.dp),
285− )
286− IconButton(onClick = onDelete) {
287− Icon(
288− Icons.Filled.DeleteOutline,
289− contentDescription = "Delete session",
290− tint = MaterialTheme.colorScheme.error,
291− )
292− }
293− }
294−}
295−
296−/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
297−@Composable
298−private fun SwipeDeleteBackground() {
299− Box(
300− modifier = Modifier
301− .fillMaxSize()
302− .background(MaterialTheme.colorScheme.error)
303− .padding(horizontal = 24.dp),
304− contentAlignment = Alignment.CenterEnd,
305− ) {
306− Row(verticalAlignment = Alignment.CenterVertically) {
307− Icon(
308− Icons.Filled.DeleteOutline,
309− contentDescription = null,
310− tint = MaterialTheme.colorScheme.onError,
311− )
312− Text(
313− text = "Delete",
314− style = MaterialTheme.typography.labelLarge,
315− color = MaterialTheme.colorScheme.onError,
316− modifier = Modifier.padding(start = 4.dp),
317− )
318− }
319− }
320−}
321−
322−/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
323−@Composable
324−private fun ReconnectingBanner() {
325− Surface(
326− modifier = Modifier.fillMaxWidth(),
327− color = MaterialTheme.colorScheme.tertiaryContainer,
328− ) {
329− Row(
330− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
331− verticalAlignment = Alignment.CenterVertically,
332− horizontalArrangement = Arrangement.spacedBy(8.dp),
333− ) {
334− CircularProgressIndicator(
335− modifier = Modifier.size(14.dp),
336− strokeWidth = 2.dp,
337− color = MaterialTheme.colorScheme.onTertiaryContainer,
338− )
339− Text(
340− text = "Reconnecting…",
341− style = MaterialTheme.typography.bodySmall,
342− color = MaterialTheme.colorScheme.onTertiaryContainer,
343− )
344− }
345− }
346−}
347−
348−private fun copyToClipboard(context: Context, text: String) {
349− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
350− clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
351−}
352−
353−/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
354−private fun shortSessionId(id: String): String =
355− if (id.length > 12) id.take(8) + "…" else id
356−
357−@Composable
358−private fun NoSearchResults(query: String) {
359− Box(
360− modifier = Modifier.fillMaxSize().padding(32.dp),
361− contentAlignment = Alignment.Center,
362− ) {
363− Text(
364− text = "No results for “$query”",
365− style = MaterialTheme.typography.bodyMedium,
366− color = MaterialTheme.colorScheme.onSurfaceVariant,
367− )
368− }
369−}
370−
371−@Composable
372−private fun EmptySessions() {
373− Column(
374− modifier = Modifier.fillMaxSize().padding(32.dp),
375− verticalArrangement = Arrangement.Center,
376− horizontalAlignment = Alignment.CenterHorizontally,
377− ) {
378− Icon(
379− Icons.Filled.ChatBubbleOutline,
380− contentDescription = null,
381− modifier = Modifier.size(64.dp),
382− tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
383− )
384− Spacer(Modifier.height(16.dp))
385− Text(
386− text = "No sessions yet",
387− style = MaterialTheme.typography.titleMedium,
388− )
389− Text(
390− text = "Tap “New session” to start chatting.",
391− style = MaterialTheme.typography.bodyMedium,
392− color = MaterialTheme.colorScheme.onSurfaceVariant,
393− )
394− Spacer(Modifier.height(8.dp))
395− Text(
396− text = "Sessions you create on this server will appear here.",
397− style = MaterialTheme.typography.bodySmall,
398− color = MaterialTheme.colorScheme.onSurfaceVariant,
399− textAlign = TextAlign.Center,
400− )
401− }
402−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+107
@@ -0,0 +1,107 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.foundation.layout.Column
4+import androidx.compose.foundation.layout.fillMaxWidth
5+import androidx.compose.foundation.layout.padding
6+import androidx.compose.foundation.rememberScrollState
7+import androidx.compose.foundation.verticalScroll
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.automirrored.filled.ArrowBack
10+import androidx.compose.material3.Button
11+import androidx.compose.material3.ExperimentalMaterial3Api
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.IconButton
14+import androidx.compose.material3.OutlinedTextField
15+import androidx.compose.material3.Scaffold
16+import androidx.compose.material3.Text
17+import androidx.compose.material3.TopAppBar
18+import androidx.compose.runtime.Composable
19+import androidx.compose.runtime.getValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.text.input.KeyboardType
22+import androidx.compose.ui.text.input.PasswordVisualTransformation
23+import androidx.compose.foundation.text.KeyboardOptions
24+import androidx.compose.foundation.layout.Arrangement
25+import androidx.compose.ui.unit.dp
26+import androidx.lifecycle.compose.collectAsStateWithLifecycle
27+import androidx.lifecycle.viewmodel.compose.viewModel
28+import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.vmFactory
30+
31+@OptIn(ExperimentalMaterial3Api::class)
32+@Composable
33+fun ServerEditScreen(
34+ container: AppContainer,
35+ profileId: String?,
36+ onDone: () -> Unit,
37+) {
38+ val vm: ServerEditViewModel =
39+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
40+ val state by vm.state.collectAsStateWithLifecycle()
41+
42+ Scaffold(
43+ topBar = {
44+ TopAppBar(
45+ title = { Text(if (state.isNew) "Add server" else "Edit server") },
46+ navigationIcon = {
47+ IconButton(onClick = onDone) {
48+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
49+ }
50+ },
51+ )
52+ },
53+ ) { padding ->
54+ Column(
55+ modifier = Modifier
56+ .padding(padding)
57+ .padding(16.dp)
58+ .verticalScroll(rememberScrollState()),
59+ verticalArrangement = Arrangement.spacedBy(12.dp),
60+ ) {
61+ OutlinedTextField(
62+ value = state.label,
63+ onValueChange = { v -> vm.update { it.copy(label = v) } },
64+ label = { Text("Label (optional)") },
65+ singleLine = true,
66+ modifier = Modifier.fillMaxWidth(),
67+ )
68+ OutlinedTextField(
69+ value = state.baseUrl,
70+ onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
71+ label = { Text("Base URL") },
72+ placeholder = { Text("http://192.168.1.10:4096") },
73+ singleLine = true,
74+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
75+ modifier = Modifier.fillMaxWidth(),
76+ )
77+ Text(
78+ "Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. " +
79+ "Username defaults to \"opencode\".",
80+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
81+ )
82+ OutlinedTextField(
83+ value = state.username,
84+ onValueChange = { v -> vm.update { it.copy(username = v) } },
85+ label = { Text("Username (optional)") },
86+ singleLine = true,
87+ modifier = Modifier.fillMaxWidth(),
88+ )
89+ OutlinedTextField(
90+ value = state.password,
91+ onValueChange = { v -> vm.update { it.copy(password = v) } },
92+ label = { Text("Password (optional)") },
93+ singleLine = true,
94+ visualTransformation = PasswordVisualTransformation(),
95+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
96+ modifier = Modifier.fillMaxWidth(),
97+ )
98+ Button(
99+ onClick = { vm.save(onDone) },
100+ enabled = state.canSave,
101+ modifier = Modifier.fillMaxWidth(),
102+ ) {
103+ Text("Save")
104+ }
105+ }
106+ }
107+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.StateFlow
9+import kotlinx.coroutines.flow.asStateFlow
10+import kotlinx.coroutines.flow.first
11+import kotlinx.coroutines.launch
12+import java.util.UUID
13+
14+data class ServerEditState(
15+ val id: String? = null,
16+ val label: String = "",
17+ val baseUrl: String = "",
18+ val username: String = "",
19+ val password: String = "",
20+ val loaded: Boolean = false,
21+) {
22+ val canSave: Boolean get() = baseUrl.isNotBlank()
23+ val isNew: Boolean get() = id == null
24+}
25+
26+class ServerEditViewModel(
27+ private val container: AppContainer,
28+ private val profileId: String?,
29+) : ViewModel() {
30+
31+ private val _state = MutableStateFlow(ServerEditState(id = profileId))
32+ val state: StateFlow<ServerEditState> = _state.asStateFlow()
33+
34+ init {
35+ viewModelScope.launch {
36+ if (profileId != null) {
37+ val existing = container.profileStore.profiles.first().firstOrNull { it.id == profileId }
38+ if (existing != null) {
39+ _state.value = ServerEditState(
40+ id = existing.id,
41+ label = existing.label,
42+ baseUrl = existing.baseUrl,
43+ username = existing.username.orEmpty(),
44+ password = existing.password.orEmpty(),
45+ loaded = true,
46+ )
47+ return@launch
48+ }
49+ }
50+ _state.value = _state.value.copy(loaded = true)
51+ }
52+ }
53+
54+ fun update(transform: (ServerEditState) -> ServerEditState) {
55+ _state.value = transform(_state.value)
56+ }
57+
58+ fun save(onDone: () -> Unit) {
59+ val s = _state.value
60+ if (!s.canSave) return
61+ viewModelScope.launch {
62+ container.profileStore.save(
63+ ServerProfile(
64+ id = s.id ?: UUID.randomUUID().toString(),
65+ label = s.label.trim(),
66+ baseUrl = s.baseUrl.trim(),
67+ username = s.username.trim().takeIf { it.isNotBlank() },
68+ password = s.password.takeIf { it.isNotEmpty() },
69+ lastUsed = 0,
70+ ),
71+ )
72+ onDone()
73+ }
74+ }
75+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+132
@@ -0,0 +1,132 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.lazy.LazyColumn
13+import androidx.compose.foundation.lazy.items
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Add
16+import androidx.compose.material.icons.filled.Delete
17+import androidx.compose.material.icons.filled.Edit
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.FloatingActionButton
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.Card
25+import androidx.compose.material3.Scaffold
26+import androidx.compose.material3.SnackbarHost
27+import androidx.compose.material3.SnackbarHostState
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TopAppBar
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.LaunchedEffect
32+import androidx.compose.runtime.getValue
33+import androidx.compose.runtime.remember
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.text.style.TextOverflow
37+import androidx.compose.ui.unit.dp
38+import androidx.lifecycle.compose.collectAsStateWithLifecycle
39+import androidx.lifecycle.viewmodel.compose.viewModel
40+import soy.iko.opencode.di.AppContainer
41+import soy.iko.opencode.ui.vmFactory
42+
43+@OptIn(ExperimentalMaterial3Api::class)
44+@Composable
45+fun ServerListScreen(
46+ container: AppContainer,
47+ onConnected: () -> Unit,
48+ onAddProfile: () -> Unit,
49+ onEditProfile: (String) -> Unit,
50+) {
51+ val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
52+ val profiles by vm.profiles.collectAsStateWithLifecycle()
53+ val connectingId by vm.connectingId.collectAsStateWithLifecycle()
54+ val error by vm.error.collectAsStateWithLifecycle()
55+ val snackbar = remember { SnackbarHostState() }
56+
57+ LaunchedEffect(error) {
58+ error?.let {
59+ snackbar.showSnackbar(it)
60+ vm.clearError()
61+ }
62+ }
63+
64+ Scaffold(
65+ topBar = { TopAppBar(title = { Text("opencode servers") }) },
66+ snackbarHost = { SnackbarHost(snackbar) },
67+ floatingActionButton = {
68+ FloatingActionButton(onClick = onAddProfile) {
69+ Icon(Icons.Filled.Add, contentDescription = "Add server")
70+ }
71+ },
72+ ) { padding ->
73+ if (profiles.isEmpty()) {
74+ Box(
75+ modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
76+ contentAlignment = Alignment.Center,
77+ ) {
78+ Text(
79+ "No servers yet.\nTap + to add an opencode serve endpoint.",
80+ style = MaterialTheme.typography.bodyLarge,
81+ )
82+ }
83+ } else {
84+ LazyColumn(
85+ modifier = Modifier.fillMaxSize().padding(padding),
86+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
87+ verticalArrangement = Arrangement.spacedBy(12.dp),
88+ ) {
89+ items(profiles, key = { it.id }) { profile ->
90+ Card(
91+ modifier = Modifier
92+ .fillMaxWidth()
93+ .clickable(enabled = connectingId == null) {
94+ vm.connect(profile, onConnected)
95+ },
96+ ) {
97+ Row(
98+ modifier = Modifier.fillMaxWidth().padding(16.dp),
99+ verticalAlignment = Alignment.CenterVertically,
100+ ) {
101+ Column(modifier = Modifier.weight(1f)) {
102+ Text(
103+ profile.displayLabel,
104+ style = MaterialTheme.typography.titleMedium,
105+ maxLines = 1,
106+ overflow = TextOverflow.Ellipsis,
107+ )
108+ Text(
109+ profile.baseUrl + if (profile.hasAuth) " • auth" else "",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ maxLines = 1,
113+ overflow = TextOverflow.Ellipsis,
114+ )
115+ }
116+ if (connectingId == profile.id) {
117+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
118+ } else {
119+ IconButton(onClick = { onEditProfile(profile.id) }) {
120+ Icon(Icons.Filled.Edit, contentDescription = "Edit")
121+ }
122+ IconButton(onClick = { vm.delete(profile) }) {
123+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
124+ }
125+ }
126+ }
127+ }
128+ }
129+ }
130+ }
131+ }
132+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+52
@@ -0,0 +1,52 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.di.AppContainer
7+import kotlinx.coroutines.flow.MutableStateFlow
8+import kotlinx.coroutines.flow.SharingStarted
9+import kotlinx.coroutines.flow.StateFlow
10+import kotlinx.coroutines.flow.asStateFlow
11+import kotlinx.coroutines.flow.stateIn
12+import kotlinx.coroutines.launch
13+
14+class ServerListViewModel(private val container: AppContainer) : ViewModel() {
15+
16+ val profiles: StateFlow<List<ServerProfile>> =
17+ container.profileStore.profiles.stateIn(
18+ scope = viewModelScope,
19+ started = SharingStarted.WhileSubscribed(5_000),
20+ initialValue = emptyList(),
21+ )
22+
23+ private val _connecting = MutableStateFlow<String?>(null)
24+ val connectingId: StateFlow<String?> = _connecting.asStateFlow()
25+
26+ private val _error = MutableStateFlow<String?>(null)
27+ val error: StateFlow<String?> = _error.asStateFlow()
28+
29+ fun clearError() { _error.value = null }
30+
31+ fun connect(profile: ServerProfile, onConnected: () -> Unit) {
32+ if (_connecting.value != null) return
33+ _connecting.value = profile.id
34+ _error.value = null
35+ viewModelScope.launch {
36+ val result = runCatching {
37+ val conn = container.connect(profile)
38+ conn.api.ping()
39+ }
40+ _connecting.value = null
41+ result.onSuccess { onConnected() }
42+ .onFailure {
43+ container.disconnect()
44+ _error.value = it.message ?: "Could not reach ${profile.baseUrl}"
45+ }
46+ }
47+ }
48+
49+ fun delete(profile: ServerProfile) {
50+ viewModelScope.launch { container.profileStore.delete(profile.id) }
51+ }
52+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+184
@@ -0,0 +1,184 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.PaddingValues
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.lazy.LazyColumn
14+import androidx.compose.foundation.lazy.items
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.automirrored.filled.Logout
17+import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.ArrowDropDown
19+import androidx.compose.material.icons.filled.Delete
20+import androidx.compose.material.icons.filled.Folder
21+import androidx.compose.material.icons.filled.Refresh
22+import androidx.compose.material.icons.filled.Settings
23+import androidx.compose.material3.Card
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
27+import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.ExtendedFloatingActionButton
29+import androidx.compose.material3.Icon
30+import androidx.compose.material3.IconButton
31+import androidx.compose.material3.MaterialTheme
32+import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.Text
34+import androidx.compose.material3.TopAppBar
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.text.style.TextOverflow
43+import androidx.compose.ui.unit.dp
44+import androidx.lifecycle.compose.collectAsStateWithLifecycle
45+import androidx.lifecycle.viewmodel.compose.viewModel
46+import soy.iko.opencode.di.AppContainer
47+import soy.iko.opencode.ui.vmFactory
48+
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun SessionListScreen(
52+ container: AppContainer,
53+ onOpenSession: (String) -> Unit,
54+ onDisconnect: () -> Unit,
55+ onOpenFiles: () -> Unit,
56+ onOpenSettings: () -> Unit,
57+) {
58+ val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
59+ val state by vm.state.collectAsStateWithLifecycle()
60+ val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
61+ val profiles by vm.profiles.collectAsStateWithLifecycle()
62+ val switchingId by vm.switchingId.collectAsStateWithLifecycle()
63+ var showServerMenu by remember { mutableStateOf(false) }
64+
65+ Scaffold(
66+ topBar = {
67+ TopAppBar(
68+ title = {
69+ Column {
70+ Box {
71+ Row(
72+ modifier = Modifier.clickable { showServerMenu = true },
73+ verticalAlignment = Alignment.CenterVertically,
74+ ) {
75+ Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
76+ Icon(Icons.Filled.ArrowDropDown, contentDescription = "Switch server")
77+ }
78+ DropdownMenu(
79+ expanded = showServerMenu,
80+ onDismissRequest = { showServerMenu = false },
81+ ) {
82+ profiles.forEach { profile ->
83+ DropdownMenuItem(
84+ text = {
85+ Row(verticalAlignment = Alignment.CenterVertically) {
86+ Column(modifier = Modifier.weight(1f)) {
87+ Text(
88+ profile.displayLabel,
89+ style = MaterialTheme.typography.bodyLarge,
90+ maxLines = 1,
91+ overflow = TextOverflow.Ellipsis,
92+ )
93+ Text(
94+ profile.baseUrl,
95+ style = MaterialTheme.typography.bodySmall,
96+ color = MaterialTheme.colorScheme.onSurfaceVariant,
97+ maxLines = 1,
98+ overflow = TextOverflow.Ellipsis,
99+ )
100+ }
101+ if (switchingId == profile.id) {
102+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
103+ }
104+ }
105+ },
106+ onClick = {
107+ showServerMenu = false
108+ vm.switchServer(profile)
109+ },
110+ )
111+ }
112+ }
113+ }
114+ }
115+ },
116+ actions = {
117+ IconButton(onClick = { vm.refresh() }) {
118+ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
119+ }
120+ IconButton(onClick = onOpenFiles) {
121+ Icon(Icons.Filled.Folder, contentDescription = "Files")
122+ }
123+ IconButton(onClick = onOpenSettings) {
124+ Icon(Icons.Filled.Settings, contentDescription = "Settings")
125+ }
126+ IconButton(onClick = onDisconnect) {
127+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Disconnect")
128+ }
129+ },
130+ )
131+ },
132+ floatingActionButton = {
133+ ExtendedFloatingActionButton(
134+ onClick = { vm.createSession(onCreated = onOpenSession) },
135+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
136+ text = { Text("New session") },
137+ )
138+ },
139+ ) { padding ->
140+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
141+ when {
142+ state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
143+ state.error != null -> Text(
144+ state.error!!,
145+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
146+ color = MaterialTheme.colorScheme.error,
147+ )
148+ state.sessions.isEmpty() -> Text(
149+ "No sessions yet.\nTap “New session” to start.",
150+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
151+ )
152+ else -> LazyColumn(
153+ modifier = Modifier.fillMaxSize(),
154+ contentPadding = PaddingValues(16.dp),
155+ verticalArrangement = Arrangement.spacedBy(12.dp),
156+ ) {
157+ items(state.sessions, key = { it.id }) { session ->
158+ Card(
159+ modifier = Modifier
160+ .fillMaxWidth()
161+ .clickable { onOpenSession(session.id) },
162+ ) {
163+ Row(
164+ modifier = Modifier.fillMaxWidth().padding(16.dp),
165+ verticalAlignment = Alignment.CenterVertically,
166+ ) {
167+ Text(
168+ session.displayTitle,
169+ style = MaterialTheme.typography.titleMedium,
170+ maxLines = 2,
171+ overflow = TextOverflow.Ellipsis,
172+ modifier = Modifier.weight(1f),
173+ )
174+ IconButton(onClick = { vm.deleteSession(session) }) {
175+ Icon(Icons.Filled.Delete, contentDescription = "Delete")
176+ }
177+ }
178+ }
179+ }
180+ }
181+ }
182+ }
183+ }
184+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+104
@@ -0,0 +1,104 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.data.model.Session
7+import soy.iko.opencode.di.AppContainer
8+import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharingStarted
10+import kotlinx.coroutines.flow.StateFlow
11+import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.stateIn
13+import kotlinx.coroutines.launch
14+
15+data class SessionListState(
16+ val sessions: List<Session> = emptyList(),
17+ val loading: Boolean = true,
18+ val error: String? = null,
19+)
20+
21+class SessionListViewModel(private val container: AppContainer) : ViewModel() {
22+
23+ val profiles: StateFlow<List<ServerProfile>> =
24+ container.profileStore.profiles.stateIn(
25+ scope = viewModelScope,
26+ started = SharingStarted.WhileSubscribed(5_000),
27+ initialValue = emptyList(),
28+ )
29+
30+ private val _serverLabel = MutableStateFlow(
31+ container.activeConnection.value?.profile?.displayLabel ?: "opencode"
32+ )
33+ val serverLabel: StateFlow<String> = _serverLabel.asStateFlow()
34+
35+ private val _switchingId = MutableStateFlow<String?>(null)
36+ val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
37+
38+ private val _state = MutableStateFlow(SessionListState())
39+ val state: StateFlow<SessionListState> = _state.asStateFlow()
40+
41+ private val activeProfileId: String?
42+ get() = container.activeConnection.value?.profile?.id
43+
44+ init { refresh() }
45+
46+ fun refresh() {
47+ val conn = container.activeConnection.value
48+ if (conn == null) {
49+ _state.value = SessionListState(loading = false, error = "Not connected")
50+ return
51+ }
52+ _serverLabel.value = conn.profile.displayLabel
53+ _state.value = _state.value.copy(loading = true, error = null)
54+ viewModelScope.launch {
55+ runCatching { conn.repository.listSessions() }
56+ .onSuccess { list ->
57+ _state.value = SessionListState(
58+ sessions = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 },
59+ loading = false,
60+ )
61+ }
62+ .onFailure { _state.value = SessionListState(loading = false, error = it.message ?: "Failed to load sessions") }
63+ }
64+ }
65+
66+ fun createSession(onCreated: (String) -> Unit) {
67+ val conn = container.activeConnection.value ?: return
68+ viewModelScope.launch {
69+ runCatching { conn.repository.createSession() }
70+ .onSuccess { onCreated(it.id); refresh() }
71+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to create session") }
72+ }
73+ }
74+
75+ fun deleteSession(session: Session) {
76+ val conn = container.activeConnection.value ?: return
77+ viewModelScope.launch {
78+ runCatching { conn.repository.deleteSession(session.id) }
79+ .onSuccess { refresh() }
80+ .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
81+ }
82+ }
83+
84+ /** Quick-switch to a different saved server without leaving the session list. */
85+ fun switchServer(profile: ServerProfile) {
86+ if (profile.id == activeProfileId) return
87+ if (_switchingId.value != null) return
88+ _switchingId.value = profile.id
89+ _state.value = _state.value.copy(loading = true, error = null)
90+ viewModelScope.launch {
91+ val result = runCatching {
92+ val conn = container.connect(profile)
93+ conn.api.ping()
94+ }
95+ _switchingId.value = null
96+ result.onSuccess { refresh() }
97+ .onFailure {
98+ container.disconnect()
99+ _state.value = SessionListState(loading = false, error = it.message ?: "Could not reach ${profile.baseUrl}")
100+ refresh()
101+ }
102+ }
103+ }
104+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+96
@@ -0,0 +1,96 @@
1+package soy.iko.opencode.ui.settings
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.padding
7+import androidx.compose.foundation.selection.selectable
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.automirrored.filled.ArrowBack
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.HorizontalDivider
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.IconButton
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.RadioButton
16+import androidx.compose.material3.Scaffold
17+import androidx.compose.material3.Text
18+import androidx.compose.material3.TopAppBar
19+import androidx.compose.runtime.Composable
20+import androidx.compose.runtime.getValue
21+import androidx.compose.runtime.rememberCoroutineScope
22+import androidx.compose.ui.Alignment
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.unit.dp
25+import androidx.lifecycle.compose.collectAsStateWithLifecycle
26+import kotlinx.coroutines.launch
27+import soy.iko.opencode.data.repo.ThemeMode
28+import soy.iko.opencode.di.AppContainer
29+
30+@OptIn(ExperimentalMaterial3Api::class)
31+@Composable
32+fun SettingsScreen(container: AppContainer, onBack: () -> Unit) {
33+ val scope = rememberCoroutineScope()
34+ val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
35+ val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
36+
37+ Scaffold(
38+ topBar = {
39+ TopAppBar(
40+ title = { Text("Settings") },
41+ navigationIcon = {
42+ IconButton(onClick = onBack) {
43+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
44+ }
45+ },
46+ )
47+ },
48+ ) { padding ->
49+ Column(modifier = Modifier.padding(padding).padding(16.dp)) {
50+ Text("Appearance", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
51+ ThemeMode.entries.forEach { mode ->
52+ Row(
53+ mode = mode,
54+ selected = themeMode == mode,
55+ onSelect = { scope.launch { container.settingsStore.setThemeMode(mode) } },
56+ )
57+ }
58+
59+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
60+
61+ Text("Connection", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary)
62+ if (activeProfile != null) {
63+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
64+ Text(
65+ activeProfile.baseUrl + if (activeProfile.hasAuth) " • Basic auth" else "",
66+ style = MaterialTheme.typography.bodySmall,
67+ color = MaterialTheme.colorScheme.onSurfaceVariant,
68+ )
69+ } else {
70+ Text(
71+ "Not connected.",
72+ modifier = Modifier.padding(top = 8.dp),
73+ color = MaterialTheme.colorScheme.onSurfaceVariant,
74+ )
75+ }
76+ }
77+ }
78+}
79+
80+@Composable
81+private fun Row(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
82+ androidx.compose.foundation.layout.Row(
83+ modifier = Modifier
84+ .fillMaxWidth()
85+ .selectable(selected = selected, onClick = onSelect)
86+ .padding(vertical = 8.dp),
87+ horizontalArrangement = Arrangement.spacedBy(8.dp),
88+ verticalAlignment = Alignment.CenterVertically,
89+ ) {
90+ RadioButton(selected = selected, onClick = onSelect)
91+ Text(
92+ mode.name.lowercase().replaceFirstChar { it.uppercase() },
93+ style = MaterialTheme.typography.bodyLarge,
94+ )
95+ }
96+}
app/src/main/java/soy/iko/opencode/ui/theme/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/Theme.kt+21−50
@@ -1,76 +1,47 @@
11package soy.iko.opencode.ui.theme
22
3−import android.os.Build
3+import android.app.Activity
44import androidx.compose.foundation.isSystemInDarkTheme
55import androidx.compose.material3.MaterialTheme
66import androidx.compose.material3.darkColorScheme
7−import androidx.compose.material3.dynamicDarkColorScheme
8−import androidx.compose.material3.dynamicLightColorScheme
97import androidx.compose.material3.lightColorScheme
108import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.SideEffect
1110import androidx.compose.ui.graphics.Color
12−import androidx.compose.ui.platform.LocalContext
11+import androidx.compose.ui.graphics.toArgb
12+import androidx.compose.ui.platform.LocalView
13+import androidx.core.view.WindowCompat
1314
1415private 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,
16+ primary = Color(0xFF7AA2F7),
17+ secondary = Color(0xFF9ECE6A),
18+ tertiary = Color(0xFFBB9AF7),
19+ background = Color(0xFF1A1B26),
20+ surface = Color(0xFF1F2335),
3421)
3522
3623private 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,
24+ primary = Color(0xFF34548A),
25+ secondary = Color(0xFF485E30),
26+ tertiary = Color(0xFF5A4A78),
5527)
5628
5729@Composable
5830fun OpencodeTheme(
5931 darkTheme: Boolean = isSystemInDarkTheme(),
60− dynamicColor: Boolean = false,
6132 content: @Composable () -> Unit,
6233) {
63− val context = LocalContext.current
64− val colorScheme = when {
65− dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
66− if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
67− darkTheme -> DarkColors
68− else -> LightColors
34+ val colorScheme = if (darkTheme) DarkColors else LightColors
35+ val view = LocalView.current
36+ if (!view.isInEditMode) {
37+ SideEffect {
38+ val window = (view.context as Activity).window
39+ window.statusBarColor = colorScheme.background.toArgb()
40+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
41+ }
6942 }
70−
7143 MaterialTheme(
7244 colorScheme = colorScheme,
73− typography = AppTypography,
7445 content = content,
7546 )
7647}
app/src/main/java/soy/iko/opencode/ui/theme/Type.ktdeleted−15
@@ -1,15 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.material3.Typography
4−import androidx.compose.ui.text.TextStyle
5−import androidx.compose.ui.text.font.FontFamily
6−import androidx.compose.ui.unit.sp
7−
8−private val base = Typography()
9−
10−val AppTypography = base.copy(
11− bodyLarge = base.bodyLarge.copy(lineHeight = 22.sp),
12− // Slightly monospaced feel for message bodies is applied inline where needed.
13−)
14−
15−val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/res/values-night/colors.xmldeleted−4
@@ -1,4 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<resources>
3− <color name="window_background">#0F1115</color>
4−</resources>
app/src/main/res/values-night/themes.xmldeleted−8
@@ -1,8 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<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>
7− </style>
8−</resources>
app/src/main/res/values/colors.xmldeleted−4
@@ -1,4 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<resources>
3− <color name="window_background">#F7F8FA</color>
4−</resources>
app/src/main/res/values/themes.xml+1−10
@@ -1,13 +1,4 @@
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>
12− </style>
3+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
134</resources>
app/src/main/res/xml/network_security_config.xmlnew file+13
@@ -0,0 +1,13 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<!--
3+ opencode serve speaks plain HTTP. Android blocks cleartext by default on API 28+,
4+ so we permit it. This is a developer tool talking to a self-hosted server on a
5+ trusted network; cleartext is permitted broadly here.
6+-->
7+<network-security-config>
8+ <base-config cleartextTrafficPermitted="true">
9+ <trust-anchors>
10+ <certificates src="system" />
11+ </trust-anchors>
12+ </base-config>
13+</network-security-config>
app/src/test/java/soy/iko/opencode/M5DecodingTest.ktnew file+107
@@ -0,0 +1,107 @@
1+package soy.iko.opencode
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.FileContent
5+import soy.iko.opencode.data.model.FileNode
6+import soy.iko.opencode.data.model.PermissionReplied
7+import soy.iko.opencode.data.model.PermissionUpdated
8+import soy.iko.opencode.data.model.ProvidersResponse
9+import soy.iko.opencode.data.model.defaultOption
10+import soy.iko.opencode.data.model.toOptions
11+import soy.iko.opencode.data.network.OpencodeJson
12+import kotlinx.serialization.builtins.ListSerializer
13+import kotlinx.serialization.builtins.serializer
14+import org.junit.Assert.assertEquals
15+import org.junit.Assert.assertNull
16+import org.junit.Assert.assertTrue
17+import org.junit.Test
18+
19+class M5DecodingTest {
20+
21+ private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
22+ OpencodeJson.decodeFromString(s, json)
23+
24+ @Test
25+ fun decodesPermissionUpdatedWithArrayPattern() {
26+ val json = """
27+ { "type": "permission.updated", "properties": {
28+ "id": "perm_1", "sessionID": "ses_1", "type": "bash",
29+ "pattern": ["rm -rf *", "git push"], "title": "Run bash",
30+ "metadata": { "cwd": "/tmp" }, "time": { "created": 123 } } }
31+ """.trimIndent()
32+ val event = decode(BusEvent.serializer(), json) as PermissionUpdated
33+ assertEquals("perm_1", event.properties.id)
34+ assertEquals("bash", event.properties.type)
35+ assertEquals("rm -rf *, git push", event.properties.patternText)
36+ }
37+
38+ @Test
39+ fun decodesPermissionUpdatedWithStringPattern() {
40+ val json = """
41+ { "type": "permission.updated", "properties": {
42+ "id": "perm_2", "sessionID": "ses_1", "type": "edit", "pattern": "src/Main.kt" } }
43+ """.trimIndent()
44+ val event = decode(BusEvent.serializer(), json) as PermissionUpdated
45+ assertEquals("src/Main.kt", event.properties.patternText)
46+ }
47+
48+ @Test
49+ fun decodesPermissionReplied() {
50+ val json = """{"type":"permission.replied","properties":{"sessionID":"s","permissionID":"perm_1","response":"once"}}"""
51+ val event = decode(BusEvent.serializer(), json) as PermissionReplied
52+ assertEquals("perm_1", event.properties.permissionID)
53+ assertEquals("once", event.properties.response)
54+ }
55+
56+ @Test
57+ fun decodesRichProvidersAndFlattens() {
58+ // Mirrors the real (richer) /config/providers shape; unknown fields must be ignored.
59+ val json = """
60+ {
61+ "providers": [
62+ { "id": "anthropic", "name": "Anthropic", "source": "env", "env": ["ANTHROPIC_API_KEY"],
63+ "options": {},
64+ "models": {
65+ "claude-sonnet": { "id": "claude-sonnet", "providerID": "anthropic", "name": "Claude Sonnet",
66+ "capabilities": { "reasoning": true }, "cost": { "input": 3, "output": 15 },
67+ "limit": { "context": 200000, "output": 8192 }, "status": "active" }
68+ } }
69+ ],
70+ "default": { "anthropic": "claude-sonnet" }
71+ }
72+ """.trimIndent()
73+ val resp = decode(ProvidersResponse.serializer(), json)
74+ val options = resp.toOptions()
75+ assertEquals(1, options.size)
76+ assertEquals("Claude Sonnet", options.single().modelLabel)
77+ assertEquals("claude-sonnet", resp.defaultOption(options)?.modelID)
78+ }
79+
80+ @Test
81+ fun decodesFileContentAndDirectoryListing() {
82+ val content = decode(FileContent.serializer(), """{"type":"text","content":"hello\nworld"}""")
83+ assertEquals("hello\nworld", content.content)
84+ assertTrue(!content.isBinary)
85+
86+ val nodes = decode(
87+ ListSerializer(FileNode.serializer()),
88+ """[{"name":"src","path":"src","type":"directory"},{"name":"a.kt","path":"src/a.kt","type":"file"}]""",
89+ )
90+ assertEquals(2, nodes.size)
91+ assertTrue(nodes[0].isDirectory)
92+ assertTrue(!nodes[1].isDirectory)
93+ }
94+
95+ @Test
96+ fun decodesFindFilesAsStringArray() {
97+ val paths = decode(ListSerializer(String.serializer()), """["src/a.kt","src/b.kt"]""")
98+ assertEquals(listOf("src/a.kt", "src/b.kt"), paths)
99+ }
100+
101+ @Test
102+ fun binaryFileContentIsFlagged() {
103+ val content = decode(FileContent.serializer(), """{"type":"binary","content":"AAAA","encoding":"base64","mimeType":"image/png"}""")
104+ assertTrue(content.isBinary)
105+ assertNull(content.diff)
106+ }
107+}
app/src/test/java/soy/iko/opencode/M6Test.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+import soy.iko.opencode.data.model.Agent
8+import soy.iko.opencode.data.model.Command
9+import soy.iko.opencode.data.network.OpencodeJson
10+import soy.iko.opencode.ui.components.DiffLine
11+import soy.iko.opencode.ui.components.looksLikeDiff
12+import soy.iko.opencode.ui.components.parseDiff
13+import kotlinx.serialization.builtins.ListSerializer
14+
15+class M6Test {
16+
17+ private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
18+ OpencodeJson.decodeFromString(s, json)
19+
20+ // --- Agent / Command decoding ---
21+
22+ @Test
23+ fun decodesAgentList() {
24+ val json = """
25+ [
26+ { "name": "build", "description": "Build and implement code", "mode": "primary",
27+ "builtIn": true, "tools": {}, "options": {} },
28+ { "name": "custom", "mode": "subagent", "builtIn": false }
29+ ]
30+ """.trimIndent()
31+ val agents = decode(ListSerializer(Agent.serializer()), json)
32+ assertEquals(2, agents.size)
33+ assertEquals("build", agents[0].name)
34+ assertTrue(agents[0].isPrimary)
35+ assertTrue(agents[0].builtIn)
36+ assertEquals("Build and implement code", agents[0].displayDescription)
37+ assertFalse(agents[1].isPrimary)
38+ assertEquals("Custom agent", agents[1].displayDescription)
39+ }
40+
41+ @Test
42+ fun decodesCommandList() {
43+ val json = """
44+ [
45+ { "name": "compact", "description": "Compact the session",
46+ "template": "Summarize the conversation so far", "subtask": false },
47+ { "name": "test", "agent": "build", "template": "Run the tests" }
48+ ]
49+ """.trimIndent()
50+ val commands = decode(ListSerializer(Command.serializer()), json)
51+ assertEquals(2, commands.size)
52+ assertEquals("compact", commands[0].name)
53+ assertEquals("Compact the session", commands[0].displayDescription)
54+ assertEquals("build", commands[1].agent)
55+ }
56+
57+ // --- Diff parsing ---
58+
59+ @Test
60+ fun parsesUnifiedDiff() {
61+ val diff = """
62+ --- a/src/Main.kt
63+ +++ b/src/Main.kt
64+ @@ -10,3 +10,4 @@
65+ fun main() {
66+ - println("old")
67+ + println("new")
68+ + // added
69+ }
70+ """.trimIndent()
71+ val lines = parseDiff(diff)
72+ assertTrue(lines[0] is DiffLine.FileHeader)
73+ assertTrue(lines[1] is DiffLine.FileHeader)
74+ assertTrue(lines[2] is DiffLine.Hunk)
75+ assertTrue(lines[3] is DiffLine.Context)
76+ assertTrue(lines[4] is DiffLine.Remove)
77+ assertTrue(lines[5] is DiffLine.Add)
78+ assertTrue(lines[6] is DiffLine.Add)
79+ assertTrue(lines[7] is DiffLine.Context)
80+ assertEquals(" println(\"old\")", (lines[4] as DiffLine.Remove).text)
81+ assertEquals(" println(\"new\")", (lines[5] as DiffLine.Add).text)
82+ }
83+
84+ @Test
85+ fun looksLikeDiffDetectsRealDiffs() {
86+ val realDiff = """
87+ @@ -1,2 +1,2 @@
88+ -old line
89+ +new line
90+ """.trimIndent()
91+ assertTrue(looksLikeDiff(realDiff))
92+
93+ val plainText = "This is just a regular message from the assistant."
94+ assertFalse(looksLikeDiff(plainText))
95+ }
96+
97+ @Test
98+ fun looksLikeDiffIgnoresStrayMarkers() {
99+ assertFalse(looksLikeDiff("not a diff at all"))
100+ assertFalse(looksLikeDiff("just some + text - here"))
101+ }
102+}
app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktnew file+88
@@ -0,0 +1,88 @@
1+package soy.iko.opencode
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.BusEvent
5+import soy.iko.opencode.data.model.MessagePartUpdated
6+import soy.iko.opencode.data.model.MessageWithParts
7+import soy.iko.opencode.data.model.Part
8+import soy.iko.opencode.data.model.SessionIdle
9+import soy.iko.opencode.data.model.TextPart
10+import soy.iko.opencode.data.model.ToolCompleted
11+import soy.iko.opencode.data.model.ToolPart
12+import soy.iko.opencode.data.model.UnknownEvent
13+import soy.iko.opencode.data.model.UnknownPart
14+import soy.iko.opencode.data.network.OpencodeJson
15+import org.junit.Assert.assertEquals
16+import org.junit.Assert.assertTrue
17+import org.junit.Test
18+
19+class OpencodeJsonTest {
20+
21+ private fun <T> decode(serializer: kotlinx.serialization.KSerializer<T>, json: String): T =
22+ OpencodeJson.decodeFromString(serializer, json)
23+
24+ @Test
25+ fun decodesPromptResponseWithPartsAndTokens() {
26+ val json = """
27+ {
28+ "info": {
29+ "role": "assistant", "id": "msg_1", "sessionID": "ses_1",
30+ "modelID": "claude-sonnet", "providerID": "anthropic",
31+ "cost": 0.012,
32+ "tokens": { "input": 10, "output": 20, "reasoning": 0, "cache": { "read": 1, "write": 2 } },
33+ "time": { "created": 100, "completed": 200 }
34+ },
35+ "parts": [
36+ { "type": "text", "id": "prt_1", "messageID": "msg_1", "sessionID": "ses_1", "text": "Hello" }
37+ ]
38+ }
39+ """.trimIndent()
40+
41+ val msg = decode(MessageWithParts.serializer(), json)
42+ val info = msg.info as AssistantMessage
43+ assertEquals("anthropic", info.providerID)
44+ assertEquals(20L, info.tokens?.output)
45+ assertTrue(info.isComplete)
46+ assertEquals("Hello", (msg.parts.single() as TextPart).text)
47+ }
48+
49+ @Test
50+ fun decodesToolPartStateMachine() {
51+ val json = """
52+ { "type": "tool", "id": "prt_2", "messageID": "msg_1", "callID": "c1", "tool": "bash",
53+ "state": { "status": "completed", "output": "done", "title": "ran bash" } }
54+ """.trimIndent()
55+ val part = decode(Part.serializer(), json) as ToolPart
56+ assertEquals("bash", part.tool)
57+ assertEquals("done", (part.state as ToolCompleted).output)
58+ }
59+
60+ @Test
61+ fun decodesPartUpdatedEvent() {
62+ val json = """
63+ { "type": "message.part.updated",
64+ "properties": { "part": { "type": "text", "id": "prt_1", "messageID": "msg_1", "text": "Hel" } } }
65+ """.trimIndent()
66+ val event = decode(BusEvent.serializer(), json) as MessagePartUpdated
67+ assertEquals("Hel", (event.properties.part as TextPart).text)
68+ }
69+
70+ @Test
71+ fun decodesSessionIdleEvent() {
72+ val event = decode(BusEvent.serializer(), """{"type":"session.idle","properties":{"sessionID":"ses_1"}}""")
73+ assertTrue(event is SessionIdle)
74+ assertEquals("ses_1", (event as SessionIdle).properties.sessionID)
75+ }
76+
77+ @Test
78+ fun unknownEventTypeFallsBackInsteadOfThrowing() {
79+ val event = decode(BusEvent.serializer(), """{"type":"some.future.event","properties":{"x":1}}""")
80+ assertEquals(UnknownEvent, event)
81+ }
82+
83+ @Test
84+ fun unknownPartTypeFallsBackInsteadOfThrowing() {
85+ val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
86+ assertTrue(part is UnknownPart)
87+ }
88+}
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−}
build.gradle.kts+1−1
@@ -1,6 +1,6 @@
11plugins {
22 alias(libs.plugins.android.application) apply false
33 alias(libs.plugins.kotlin.android) apply false
4− alias(libs.plugins.kotlin.compose) apply false
54 alias(libs.plugins.kotlin.serialization) apply false
5+ alias(libs.plugins.kotlin.compose) apply false
66}
flake.lock+3−3
@@ -20,11 +20,11 @@
2020 },
2121 "nixpkgs": {
2222 "locked": {
23− "lastModified": 1783224372,
24− "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
23+ "lastModified": 1782467914,
24+ "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
2525 "owner": "NixOS",
2626 "repo": "nixpkgs",
27− "rev": "d407951447dcd00442e97087bf374aad70c04cea",
27+ "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
2828 "type": "github"
2929 },
3030 "original": {
flake.nix+17−27
@@ -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 (Kotlin / Jetpack Compose client for the opencode serve API)";
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.
12+ # Versions kept in sync with the Gradle project (compileSdk / build-tools).
1613 platformVersion = "35";
1714 buildToolsVersion = "35.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 };
@@ -29,52 +26,45 @@
2926 buildToolsVersions = [ buildToolsVersion ];
3027 cmdLineToolsVersion = "latest";
3128
29+ # Flip these on if the host has /dev/kvm and you want an AVD.
3230 includeEmulator = false;
3331 includeSystemImages = false;
32+ systemImageTypes = [ "google_apis" ];
33+ abiVersions = [ "x86_64" ];
3434
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.
4035 includeNDK = false;
4136 };
4237
43− sdk = android.androidsdk;
44− sdkRoot = "${sdk}/libexec/android-sdk";
38+ sdk = android.androidsdk;
39+ sdkRoot = "${sdk}/libexec/android-sdk";
40+
41+ # Gradle's bundled aapt2 binary is dynamically linked against a loader
42+ # path that does not exist on NixOS, so it segfaults. Point Gradle and
43+ # Android Studio at the Nix-built aapt2 instead.
4544 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
4645 in {
4746 devShells.default = pkgs.mkShell {
4847 buildInputs = [
49− # JVM / Android toolchain
5048 pkgs.jdk17
5149 pkgs.gradle
5250 sdk
53−
54− # Rust toolchain for the Crux core (build, test, typegen codegen)
55− pkgs.rustc
56− pkgs.cargo
5751 ];
5852
5953 ANDROID_HOME = sdkRoot;
6054 ANDROID_SDK_ROOT = sdkRoot;
6155 JAVA_HOME = "${pkgs.jdk17}";
6256
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.
57+ # Override Gradle's bundled aapt2 (see note above).
6558 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
6659
6760 shellHook = ''
6861 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}')"
62+ echo " JDK: $(java -version 2>&1 | head -1)"
7263 echo " ANDROID_HOME: $ANDROID_HOME"
64+ echo " aapt2: ${aapt2Path}"
7365 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"
66+ echo "Build: ./gradlew assembleDebug"
67+ echo "Device: adb devices"
7868 '';
7969 };
8070 });
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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+1−6
@@ -1,10 +1,5 @@
1−org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
1+org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
22org.gradle.caching=true
3−org.gradle.configuration-cache=true
4−
5−# AndroidX
63android.useAndroidX=true
74android.nonTransitiveRClass=true
8−
9−# Kotlin
105kotlin.code.style=official
gradle/libs.versions.toml+37−25
@@ -1,44 +1,56 @@
11[versions]
22agp = "8.7.3"
33kotlin = "2.0.21"
4−coreKtx = "1.15.0"
5−lifecycle = "2.8.7"
4+ktor = "3.0.3"
5+kotlinxSerialization = "1.7.3"
6+kotlinxCoroutines = "1.9.0"
7+composeBom = "2024.12.01"
68activityCompose = "1.9.3"
7−composeBom = "2024.10.01"
8−coroutines = "1.9.0"
9−serialization = "1.7.3"
10−okhttp = "4.12.0"
9+lifecycle = "2.8.7"
10+navigationCompose = "2.8.5"
11+datastore = "1.1.1"
12+securityCrypto = "1.1.0-alpha06"
13+coil = "2.7.0"
14+markdownRenderer = "0.27.0"
15+coreKtx = "1.13.1"
1116junit = "4.13.2"
12−androidxJunit = "1.2.1"
1317
1418[libraries]
1519androidx-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" }
2020androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
21+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
22+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
23+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
24+
25+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
26+compose-ui = { group = "androidx.compose.ui", name = "ui" }
27+compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
28+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
29+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
30+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
31+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
2132
22−androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
23−androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
24−androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
25−androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
26−androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
27−androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
28−androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
33+ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
34+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
35+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
36+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
37+ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
38+ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
2939
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" }
40+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
41+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
42+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
3243
33−okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
34−okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
44+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
45+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
46+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
47+markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
48+markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
3549
3650junit = { 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" }
3951
4052[plugins]
4153android-application = { id = "com.android.application", version.ref = "agp" }
4254kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
43−kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
4455kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
56+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
settings.gradle.kts−3
@@ -21,7 +21,4 @@ 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")
shared/Cargo.lockdeleted−1358
@@ -1,1358 +0,0 @@
1−# This file is automatically @generated by Cargo.
2−# It is not intended for manual editing.
3−version = 4
4−
5−[[package]]
6−name = "allocator-api2"
7−version = "0.2.21"
8−source = "registry+https://github.com/rust-lang/crates.io-index"
9−checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10−
11−[[package]]
12−name = "anyhow"
13−version = "1.0.103"
14−source = "registry+https://github.com/rust-lang/crates.io-index"
15−checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
16−
17−[[package]]
18−name = "async-trait"
19−version = "0.1.89"
20−source = "registry+https://github.com/rust-lang/crates.io-index"
21−checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
22−dependencies = [
23− "proc-macro2",
24− "quote",
25− "syn 2.0.118",
26−]
27−
28−[[package]]
29−name = "autocfg"
30−version = "1.5.1"
31−source = "registry+https://github.com/rust-lang/crates.io-index"
32−checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
33−
34−[[package]]
35−name = "base64"
36−version = "0.22.1"
37−source = "registry+https://github.com/rust-lang/crates.io-index"
38−checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
39−
40−[[package]]
41−name = "bincode"
42−version = "1.3.3"
43−source = "registry+https://github.com/rust-lang/crates.io-index"
44−checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
45−dependencies = [
46− "serde",
47−]
48−
49−[[package]]
50−name = "bumpalo"
51−version = "3.20.3"
52−source = "registry+https://github.com/rust-lang/crates.io-index"
53−checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
54−
55−[[package]]
56−name = "bytes"
57−version = "1.12.1"
58−source = "registry+https://github.com/rust-lang/crates.io-index"
59−checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
60−
61−[[package]]
62−name = "cfg-if"
63−version = "1.0.4"
64−source = "registry+https://github.com/rust-lang/crates.io-index"
65−checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
66−
67−[[package]]
68−name = "const-fnv1a-hash"
69−version = "1.1.0"
70−source = "registry+https://github.com/rust-lang/crates.io-index"
71−checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
72−
73−[[package]]
74−name = "crossbeam-channel"
75−version = "0.5.16"
76−source = "registry+https://github.com/rust-lang/crates.io-index"
77−checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
78−dependencies = [
79− "crossbeam-utils",
80−]
81−
82−[[package]]
83−name = "crossbeam-utils"
84−version = "0.8.22"
85−source = "registry+https://github.com/rust-lang/crates.io-index"
86−checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
87−
88−[[package]]
89−name = "crux_core"
90−version = "0.19.0"
91−source = "registry+https://github.com/rust-lang/crates.io-index"
92−checksum = "159720bb44c1a09e0089459217c8ea6c71a78d868c5bf55d5b41f8781e92af7a"
93−dependencies = [
94− "anyhow",
95− "bincode",
96− "crossbeam-channel",
97− "crux_macros",
98− "facet",
99− "futures",
100− "include_dir 0.7.4",
101− "serde",
102− "serde-generate",
103− "serde-reflection",
104− "serde_json",
105− "slab",
106− "thiserror 2.0.18",
107−]
108−
109−[[package]]
110−name = "crux_http"
111−version = "0.19.0"
112−source = "registry+https://github.com/rust-lang/crates.io-index"
113−checksum = "cb5aec17a4618dc5834f201aaa28f0ea4cad5a5f593714e74a43ff95cf171313"
114−dependencies = [
115− "anyhow",
116− "async-trait",
117− "crux_core",
118− "derive_builder",
119− "encoding_rs",
120− "facet",
121− "facet-generate-attrs",
122− "futures-util",
123− "http",
124− "mime",
125− "pin-project-lite",
126− "serde",
127− "serde_bytes",
128− "serde_json",
129− "serde_qs",
130− "thiserror 2.0.18",
131− "url",
132− "web-sys",
133−]
134−
135−[[package]]
136−name = "crux_macros"
137−version = "0.10.0"
138−source = "registry+https://github.com/rust-lang/crates.io-index"
139−checksum = "952c0b238735c79e917b908c140c7c5f6f6921924ebee21171203fe5fb5fc3f3"
140−dependencies = [
141− "darling 0.23.0",
142− "heck 0.5.0",
143− "proc-macro-error",
144− "proc-macro2",
145− "quote",
146− "syn 2.0.118",
147−]
148−
149−[[package]]
150−name = "darling"
151−version = "0.20.11"
152−source = "registry+https://github.com/rust-lang/crates.io-index"
153−checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
154−dependencies = [
155− "darling_core 0.20.11",
156− "darling_macro 0.20.11",
157−]
158−
159−[[package]]
160−name = "darling"
161−version = "0.23.0"
162−source = "registry+https://github.com/rust-lang/crates.io-index"
163−checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
164−dependencies = [
165− "darling_core 0.23.0",
166− "darling_macro 0.23.0",
167−]
168−
169−[[package]]
170−name = "darling_core"
171−version = "0.20.11"
172−source = "registry+https://github.com/rust-lang/crates.io-index"
173−checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
174−dependencies = [
175− "fnv",
176− "ident_case",
177− "proc-macro2",
178− "quote",
179− "strsim",
180− "syn 2.0.118",
181−]
182−
183−[[package]]
184−name = "darling_core"
185−version = "0.23.0"
186−source = "registry+https://github.com/rust-lang/crates.io-index"
187−checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
188−dependencies = [
189− "ident_case",
190− "proc-macro2",
191− "quote",
192− "strsim",
193− "syn 2.0.118",
194−]
195−
196−[[package]]
197−name = "darling_macro"
198−version = "0.20.11"
199−source = "registry+https://github.com/rust-lang/crates.io-index"
200−checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
201−dependencies = [
202− "darling_core 0.20.11",
203− "quote",
204− "syn 2.0.118",
205−]
206−
207−[[package]]
208−name = "darling_macro"
209−version = "0.23.0"
210−source = "registry+https://github.com/rust-lang/crates.io-index"
211−checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
212−dependencies = [
213− "darling_core 0.23.0",
214− "quote",
215− "syn 2.0.118",
216−]
217−
218−[[package]]
219−name = "derive_builder"
220−version = "0.20.2"
221−source = "registry+https://github.com/rust-lang/crates.io-index"
222−checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
223−dependencies = [
224− "derive_builder_macro",
225−]
226−
227−[[package]]
228−name = "derive_builder_core"
229−version = "0.20.2"
230−source = "registry+https://github.com/rust-lang/crates.io-index"
231−checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
232−dependencies = [
233− "darling 0.20.11",
234− "proc-macro2",
235− "quote",
236− "syn 2.0.118",
237−]
238−
239−[[package]]
240−name = "derive_builder_macro"
241−version = "0.20.2"
242−source = "registry+https://github.com/rust-lang/crates.io-index"
243−checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
244−dependencies = [
245− "derive_builder_core",
246− "syn 2.0.118",
247−]
248−
249−[[package]]
250−name = "displaydoc"
251−version = "0.2.6"
252−source = "registry+https://github.com/rust-lang/crates.io-index"
253−checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
254−dependencies = [
255− "proc-macro2",
256− "quote",
257− "syn 2.0.118",
258−]
259−
260−[[package]]
261−name = "encoding_rs"
262−version = "0.8.35"
263−source = "registry+https://github.com/rust-lang/crates.io-index"
264−checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
265−dependencies = [
266− "cfg-if",
267−]
268−
269−[[package]]
270−name = "equivalent"
271−version = "1.0.2"
272−source = "registry+https://github.com/rust-lang/crates.io-index"
273−checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
274−
275−[[package]]
276−name = "facet"
277−version = "0.44.5"
278−source = "registry+https://github.com/rust-lang/crates.io-index"
279−checksum = "c78066af2cc259674a54fef24323f6081ae54a5f3a3fb53b995af7a867041c81"
280−dependencies = [
281− "autocfg",
282− "facet-core",
283− "facet-macros",
284−]
285−
286−[[package]]
287−name = "facet-core"
288−version = "0.44.4"
289−source = "registry+https://github.com/rust-lang/crates.io-index"
290−checksum = "04625a816bc27bb757b5f62ec17a228b90c08631f579ee0b36eccd6c426df207"
291−dependencies = [
292− "autocfg",
293− "const-fnv1a-hash",
294− "iddqd",
295− "impls",
296−]
297−
298−[[package]]
299−name = "facet-generate-attrs"
300−version = "0.17.0"
301−source = "registry+https://github.com/rust-lang/crates.io-index"
302−checksum = "3b430b495d59ffcf07dabe103a796d8d3ba809d12bb25a5960b605883835b3de"
303−dependencies = [
304− "facet",
305−]
306−
307−[[package]]
308−name = "facet-macro-parse"
309−version = "0.44.4"
310−source = "registry+https://github.com/rust-lang/crates.io-index"
311−checksum = "a288cd230608b4d7e89b307f85be2029f265e27dcdc565a2ae5358d0c4e53f57"
312−dependencies = [
313− "facet-macro-types",
314− "proc-macro2",
315− "quote",
316−]
317−
318−[[package]]
319−name = "facet-macro-types"
320−version = "0.44.4"
321−source = "registry+https://github.com/rust-lang/crates.io-index"
322−checksum = "9c805b7f1ad4bba14e1dba5c67ef810bf9eef2373364fe3916af1d08a5411296"
323−dependencies = [
324− "proc-macro2",
325− "quote",
326− "unsynn",
327−]
328−
329−[[package]]
330−name = "facet-macros"
331−version = "0.44.4"
332−source = "registry+https://github.com/rust-lang/crates.io-index"
333−checksum = "e0cc46a94a2725e82f2c0095bac16665057c24d7f4f11169a7a879dbcba81d8f"
334−dependencies = [
335− "facet-macros-impl",
336−]
337−
338−[[package]]
339−name = "facet-macros-impl"
340−version = "0.44.4"
341−source = "registry+https://github.com/rust-lang/crates.io-index"
342−checksum = "1af13fd5dfa728cc48418cf33cbcfc0d5cfdbaaf22a3d0f5229da4b1e5669fa5"
343−dependencies = [
344− "facet-macro-parse",
345− "facet-macro-types",
346− "proc-macro2",
347− "quote",
348− "strsim",
349− "unsynn",
350−]
351−
352−[[package]]
353−name = "fnv"
354−version = "1.0.7"
355−source = "registry+https://github.com/rust-lang/crates.io-index"
356−checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
357−
358−[[package]]
359−name = "foldhash"
360−version = "0.2.0"
361−source = "registry+https://github.com/rust-lang/crates.io-index"
362−checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
363−
364−[[package]]
365−name = "form_urlencoded"
366−version = "1.2.2"
367−source = "registry+https://github.com/rust-lang/crates.io-index"
368−checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
369−dependencies = [
370− "percent-encoding",
371−]
372−
373−[[package]]
374−name = "futures"
375−version = "0.3.32"
376−source = "registry+https://github.com/rust-lang/crates.io-index"
377−checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
378−dependencies = [
379− "futures-channel",
380− "futures-core",
381− "futures-executor",
382− "futures-io",
383− "futures-sink",
384− "futures-task",
385− "futures-util",
386−]
387−
388−[[package]]
389−name = "futures-channel"
390−version = "0.3.32"
391−source = "registry+https://github.com/rust-lang/crates.io-index"
392−checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
393−dependencies = [
394− "futures-core",
395− "futures-sink",
396−]
397−
398−[[package]]
399−name = "futures-core"
400−version = "0.3.32"
401−source = "registry+https://github.com/rust-lang/crates.io-index"
402−checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
403−
404−[[package]]
405−name = "futures-executor"
406−version = "0.3.32"
407−source = "registry+https://github.com/rust-lang/crates.io-index"
408−checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
409−dependencies = [
410− "futures-core",
411− "futures-task",
412− "futures-util",
413−]
414−
415−[[package]]
416−name = "futures-io"
417−version = "0.3.32"
418−source = "registry+https://github.com/rust-lang/crates.io-index"
419−checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
420−
421−[[package]]
422−name = "futures-macro"
423−version = "0.3.32"
424−source = "registry+https://github.com/rust-lang/crates.io-index"
425−checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
426−dependencies = [
427− "proc-macro2",
428− "quote",
429− "syn 2.0.118",
430−]
431−
432−[[package]]
433−name = "futures-sink"
434−version = "0.3.32"
435−source = "registry+https://github.com/rust-lang/crates.io-index"
436−checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
437−
438−[[package]]
439−name = "futures-task"
440−version = "0.3.32"
441−source = "registry+https://github.com/rust-lang/crates.io-index"
442−checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
443−
444−[[package]]
445−name = "futures-util"
446−version = "0.3.32"
447−source = "registry+https://github.com/rust-lang/crates.io-index"
448−checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
449−dependencies = [
450− "futures-channel",
451− "futures-core",
452− "futures-io",
453− "futures-macro",
454− "futures-sink",
455− "futures-task",
456− "memchr",
457− "pin-project-lite",
458− "slab",
459−]
460−
461−[[package]]
462−name = "getrandom"
463−version = "0.2.17"
464−source = "registry+https://github.com/rust-lang/crates.io-index"
465−checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
466−dependencies = [
467− "cfg-if",
468− "libc",
469− "wasi",
470−]
471−
472−[[package]]
473−name = "glob"
474−version = "0.3.3"
475−source = "registry+https://github.com/rust-lang/crates.io-index"
476−checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
477−
478−[[package]]
479−name = "hashbrown"
480−version = "0.16.1"
481−source = "registry+https://github.com/rust-lang/crates.io-index"
482−checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
483−dependencies = [
484− "allocator-api2",
485−]
486−
487−[[package]]
488−name = "heck"
489−version = "0.3.3"
490−source = "registry+https://github.com/rust-lang/crates.io-index"
491−checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
492−dependencies = [
493− "unicode-segmentation",
494−]
495−
496−[[package]]
497−name = "heck"
498−version = "0.5.0"
499−source = "registry+https://github.com/rust-lang/crates.io-index"
500−checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
501−
502−[[package]]
503−name = "http"
504−version = "1.4.2"
505−source = "registry+https://github.com/rust-lang/crates.io-index"
506−checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
507−dependencies = [
508− "bytes",
509− "itoa",
510−]
511−
512−[[package]]
513−name = "icu_collections"
514−version = "2.2.0"
515−source = "registry+https://github.com/rust-lang/crates.io-index"
516−checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
517−dependencies = [
518− "displaydoc",
519− "potential_utf",
520− "utf8_iter",
521− "yoke",
522− "zerofrom",
523− "zerovec",
524−]
525−
526−[[package]]
527−name = "icu_locale_core"
528−version = "2.2.0"
529−source = "registry+https://github.com/rust-lang/crates.io-index"
530−checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
531−dependencies = [
532− "displaydoc",
533− "litemap",
534− "tinystr",
535− "writeable",
536− "zerovec",
537−]
538−
539−[[package]]
540−name = "icu_normalizer"
541−version = "2.2.0"
542−source = "registry+https://github.com/rust-lang/crates.io-index"
543−checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
544−dependencies = [
545− "icu_collections",
546− "icu_normalizer_data",
547− "icu_properties",
548− "icu_provider",
549− "smallvec",
550− "zerovec",
551−]
552−
553−[[package]]
554−name = "icu_normalizer_data"
555−version = "2.2.0"
556−source = "registry+https://github.com/rust-lang/crates.io-index"
557−checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
558−
559−[[package]]
560−name = "icu_properties"
561−version = "2.2.0"
562−source = "registry+https://github.com/rust-lang/crates.io-index"
563−checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
564−dependencies = [
565− "icu_collections",
566− "icu_locale_core",
567− "icu_properties_data",
568− "icu_provider",
569− "zerotrie",
570− "zerovec",
571−]
572−
573−[[package]]
574−name = "icu_properties_data"
575−version = "2.2.0"
576−source = "registry+https://github.com/rust-lang/crates.io-index"
577−checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
578−
579−[[package]]
580−name = "icu_provider"
581−version = "2.2.0"
582−source = "registry+https://github.com/rust-lang/crates.io-index"
583−checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
584−dependencies = [
585− "displaydoc",
586− "icu_locale_core",
587− "writeable",
588− "yoke",
589− "zerofrom",
590− "zerotrie",
591− "zerovec",
592−]
593−
594−[[package]]
595−name = "iddqd"
596−version = "0.3.18"
597−source = "registry+https://github.com/rust-lang/crates.io-index"
598−checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca"
599−dependencies = [
600− "allocator-api2",
601− "equivalent",
602− "foldhash",
603− "hashbrown",
604− "rustc-hash",
605−]
606−
607−[[package]]
608−name = "ident_case"
609−version = "1.0.1"
610−source = "registry+https://github.com/rust-lang/crates.io-index"
611−checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
612−
613−[[package]]
614−name = "idna"
615−version = "1.1.0"
616−source = "registry+https://github.com/rust-lang/crates.io-index"
617−checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
618−dependencies = [
619− "idna_adapter",
620− "smallvec",
621− "utf8_iter",
622−]
623−
624−[[package]]
625−name = "idna_adapter"
626−version = "1.2.2"
627−source = "registry+https://github.com/rust-lang/crates.io-index"
628−checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
629−dependencies = [
630− "icu_normalizer",
631− "icu_properties",
632−]
633−
634−[[package]]
635−name = "impls"
636−version = "1.0.3"
637−source = "registry+https://github.com/rust-lang/crates.io-index"
638−checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
639−
640−[[package]]
641−name = "include_dir"
642−version = "0.6.2"
643−source = "registry+https://github.com/rust-lang/crates.io-index"
644−checksum = "24b56e147e6187d61e9d0f039f10e070d0c0a887e24fe0bb9ca3f29bfde62cab"
645−dependencies = [
646− "glob",
647− "include_dir_impl",
648− "proc-macro-hack",
649−]
650−
651−[[package]]
652−name = "include_dir"
653−version = "0.7.4"
654−source = "registry+https://github.com/rust-lang/crates.io-index"
655−checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
656−dependencies = [
657− "include_dir_macros",
658−]
659−
660−[[package]]
661−name = "include_dir_impl"
662−version = "0.6.2"
663−source = "registry+https://github.com/rust-lang/crates.io-index"
664−checksum = "0a0c890c85da4bab7bce4204c707396bbd3c6c8a681716a51c8814cfc2b682df"
665−dependencies = [
666− "anyhow",
667− "proc-macro-hack",
668− "proc-macro2",
669− "quote",
670− "syn 1.0.109",
671−]
672−
673−[[package]]
674−name = "include_dir_macros"
675−version = "0.7.4"
676−source = "registry+https://github.com/rust-lang/crates.io-index"
677−checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
678−dependencies = [
679− "proc-macro2",
680− "quote",
681−]
682−
683−[[package]]
684−name = "itoa"
685−version = "1.0.18"
686−source = "registry+https://github.com/rust-lang/crates.io-index"
687−checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
688−
689−[[package]]
690−name = "js-sys"
691−version = "0.3.103"
692−source = "registry+https://github.com/rust-lang/crates.io-index"
693−checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
694−dependencies = [
695− "cfg-if",
696− "futures-util",
697− "wasm-bindgen",
698−]
699−
700−[[package]]
701−name = "libc"
702−version = "0.2.186"
703−source = "registry+https://github.com/rust-lang/crates.io-index"
704−checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
705−
706−[[package]]
707−name = "litemap"
708−version = "0.8.2"
709−source = "registry+https://github.com/rust-lang/crates.io-index"
710−checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
711−
712−[[package]]
713−name = "memchr"
714−version = "2.8.3"
715−source = "registry+https://github.com/rust-lang/crates.io-index"
716−checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
717−
718−[[package]]
719−name = "mime"
720−version = "0.3.17"
721−source = "registry+https://github.com/rust-lang/crates.io-index"
722−checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
723−
724−[[package]]
725−name = "mutants"
726−version = "0.0.3"
727−source = "registry+https://github.com/rust-lang/crates.io-index"
728−checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
729−
730−[[package]]
731−name = "once_cell"
732−version = "1.21.4"
733−source = "registry+https://github.com/rust-lang/crates.io-index"
734−checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
735−
736−[[package]]
737−name = "percent-encoding"
738−version = "2.3.2"
739−source = "registry+https://github.com/rust-lang/crates.io-index"
740−checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
741−
742−[[package]]
743−name = "phf"
744−version = "0.10.1"
745−source = "registry+https://github.com/rust-lang/crates.io-index"
746−checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
747−dependencies = [
748− "phf_macros",
749− "phf_shared",
750− "proc-macro-hack",
751−]
752−
753−[[package]]
754−name = "phf_generator"
755−version = "0.10.0"
756−source = "registry+https://github.com/rust-lang/crates.io-index"
757−checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
758−dependencies = [
759− "phf_shared",
760− "rand",
761−]
762−
763−[[package]]
764−name = "phf_macros"
765−version = "0.10.0"
766−source = "registry+https://github.com/rust-lang/crates.io-index"
767−checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
768−dependencies = [
769− "phf_generator",
770− "phf_shared",
771− "proc-macro-hack",
772− "proc-macro2",
773− "quote",
774− "syn 1.0.109",
775−]
776−
777−[[package]]
778−name = "phf_shared"
779−version = "0.10.0"
780−source = "registry+https://github.com/rust-lang/crates.io-index"
781−checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
782−dependencies = [
783− "siphasher",
784−]
785−
786−[[package]]
787−name = "pin-project-lite"
788−version = "0.2.17"
789−source = "registry+https://github.com/rust-lang/crates.io-index"
790−checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
791−
792−[[package]]
793−name = "potential_utf"
794−version = "0.1.5"
795−source = "registry+https://github.com/rust-lang/crates.io-index"
796−checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
797−dependencies = [
798− "zerovec",
799−]
800−
801−[[package]]
802−name = "ppv-lite86"
803−version = "0.2.21"
804−source = "registry+https://github.com/rust-lang/crates.io-index"
805−checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
806−dependencies = [
807− "zerocopy",
808−]
809−
810−[[package]]
811−name = "proc-macro-error"
812−version = "1.0.4"
813−source = "registry+https://github.com/rust-lang/crates.io-index"
814−checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
815−dependencies = [
816− "proc-macro-error-attr",
817− "proc-macro2",
818− "quote",
819− "syn 1.0.109",
820− "version_check",
821−]
822−
823−[[package]]
824−name = "proc-macro-error-attr"
825−version = "1.0.4"
826−source = "registry+https://github.com/rust-lang/crates.io-index"
827−checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
828−dependencies = [
829− "proc-macro2",
830− "quote",
831− "version_check",
832−]
833−
834−[[package]]
835−name = "proc-macro-hack"
836−version = "0.5.20+deprecated"
837−source = "registry+https://github.com/rust-lang/crates.io-index"
838−checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068"
839−
840−[[package]]
841−name = "proc-macro2"
842−version = "1.0.106"
843−source = "registry+https://github.com/rust-lang/crates.io-index"
844−checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
845−dependencies = [
846− "unicode-ident",
847−]
848−
849−[[package]]
850−name = "quote"
851−version = "1.0.46"
852−source = "registry+https://github.com/rust-lang/crates.io-index"
853−checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
854−dependencies = [
855− "proc-macro2",
856−]
857−
858−[[package]]
859−name = "rand"
860−version = "0.8.6"
861−source = "registry+https://github.com/rust-lang/crates.io-index"
862−checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
863−dependencies = [
864− "libc",
865− "rand_chacha",
866− "rand_core",
867−]
868−
869−[[package]]
870−name = "rand_chacha"
871−version = "0.3.1"
872−source = "registry+https://github.com/rust-lang/crates.io-index"
873−checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
874−dependencies = [
875− "ppv-lite86",
876− "rand_core",
877−]
878−
879−[[package]]
880−name = "rand_core"
881−version = "0.6.4"
882−source = "registry+https://github.com/rust-lang/crates.io-index"
883−checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
884−dependencies = [
885− "getrandom",
886−]
887−
888−[[package]]
889−name = "rustc-hash"
890−version = "2.1.3"
891−source = "registry+https://github.com/rust-lang/crates.io-index"
892−checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
893−
894−[[package]]
895−name = "rustversion"
896−version = "1.0.23"
897−source = "registry+https://github.com/rust-lang/crates.io-index"
898−checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
899−
900−[[package]]
901−name = "ryu"
902−version = "1.0.23"
903−source = "registry+https://github.com/rust-lang/crates.io-index"
904−checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
905−
906−[[package]]
907−name = "serde"
908−version = "1.0.228"
909−source = "registry+https://github.com/rust-lang/crates.io-index"
910−checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
911−dependencies = [
912− "serde_core",
913− "serde_derive",
914−]
915−
916−[[package]]
917−name = "serde-generate"
918−version = "0.26.0"
919−source = "registry+https://github.com/rust-lang/crates.io-index"
920−checksum = "8309fc8d475cf5884e92a463b3a003d433684335be19c3f739af0451b027254b"
921−dependencies = [
922− "heck 0.3.3",
923− "include_dir 0.6.2",
924− "phf",
925− "serde",
926− "serde-reflection",
927− "textwrap",
928−]
929−
930−[[package]]
931−name = "serde-reflection"
932−version = "0.4.0"
933−source = "registry+https://github.com/rust-lang/crates.io-index"
934−checksum = "5b6798a64289ff550d8d79847467789a5fd30b42c9c406a4d6dc0bc9b567e55c"
935−dependencies = [
936− "once_cell",
937− "serde",
938− "thiserror 1.0.69",
939−]
940−
941−[[package]]
942−name = "serde_bytes"
943−version = "0.11.19"
944−source = "registry+https://github.com/rust-lang/crates.io-index"
945−checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
946−dependencies = [
947− "serde",
948− "serde_core",
949−]
950−
951−[[package]]
952−name = "serde_core"
953−version = "1.0.228"
954−source = "registry+https://github.com/rust-lang/crates.io-index"
955−checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
956−dependencies = [
957− "serde_derive",
958−]
959−
960−[[package]]
961−name = "serde_derive"
962−version = "1.0.228"
963−source = "registry+https://github.com/rust-lang/crates.io-index"
964−checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
965−dependencies = [
966− "proc-macro2",
967− "quote",
968− "syn 2.0.118",
969−]
970−
971−[[package]]
972−name = "serde_json"
973−version = "1.0.150"
974−source = "registry+https://github.com/rust-lang/crates.io-index"
975−checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
976−dependencies = [
977− "itoa",
978− "memchr",
979− "serde",
980− "serde_core",
981− "zmij",
982−]
983−
984−[[package]]
985−name = "serde_qs"
986−version = "1.1.2"
987−source = "registry+https://github.com/rust-lang/crates.io-index"
988−checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
989−dependencies = [
990− "itoa",
991− "percent-encoding",
992− "ryu",
993− "serde",
994−]
995−
996−[[package]]
997−name = "shared"
998−version = "0.1.0"
999−dependencies = [
1000− "anyhow",
1001− "base64",
1002− "crux_core",
1003− "crux_http",
1004− "serde",
1005− "serde_json",
1006−]
1007−
1008−[[package]]
1009−name = "siphasher"
1010−version = "0.3.11"
1011−source = "registry+https://github.com/rust-lang/crates.io-index"
1012−checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
1013−
1014−[[package]]
1015−name = "slab"
1016−version = "0.4.12"
1017−source = "registry+https://github.com/rust-lang/crates.io-index"
1018−checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1019−
1020−[[package]]
1021−name = "smallvec"
1022−version = "1.15.2"
1023−source = "registry+https://github.com/rust-lang/crates.io-index"
1024−checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1025−
1026−[[package]]
1027−name = "smawk"
1028−version = "0.3.3"
1029−source = "registry+https://github.com/rust-lang/crates.io-index"
1030−checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1031−
1032−[[package]]
1033−name = "stable_deref_trait"
1034−version = "1.2.1"
1035−source = "registry+https://github.com/rust-lang/crates.io-index"
1036−checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1037−
1038−[[package]]
1039−name = "strsim"
1040−version = "0.11.1"
1041−source = "registry+https://github.com/rust-lang/crates.io-index"
1042−checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1043−
1044−[[package]]
1045−name = "syn"
1046−version = "1.0.109"
1047−source = "registry+https://github.com/rust-lang/crates.io-index"
1048−checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1049−dependencies = [
1050− "proc-macro2",
1051− "quote",
1052− "unicode-ident",
1053−]
1054−
1055−[[package]]
1056−name = "syn"
1057−version = "2.0.118"
1058−source = "registry+https://github.com/rust-lang/crates.io-index"
1059−checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1060−dependencies = [
1061− "proc-macro2",
1062− "quote",
1063− "unicode-ident",
1064−]
1065−
1066−[[package]]
1067−name = "synstructure"
1068−version = "0.13.2"
1069−source = "registry+https://github.com/rust-lang/crates.io-index"
1070−checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1071−dependencies = [
1072− "proc-macro2",
1073− "quote",
1074− "syn 2.0.118",
1075−]
1076−
1077−[[package]]
1078−name = "textwrap"
1079−version = "0.13.4"
1080−source = "registry+https://github.com/rust-lang/crates.io-index"
1081−checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835"
1082−dependencies = [
1083− "smawk",
1084− "unicode-width",
1085−]
1086−
1087−[[package]]
1088−name = "thiserror"
1089−version = "1.0.69"
1090−source = "registry+https://github.com/rust-lang/crates.io-index"
1091−checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
1092−dependencies = [
1093− "thiserror-impl 1.0.69",
1094−]
1095−
1096−[[package]]
1097−name = "thiserror"
1098−version = "2.0.18"
1099−source = "registry+https://github.com/rust-lang/crates.io-index"
1100−checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1101−dependencies = [
1102− "thiserror-impl 2.0.18",
1103−]
1104−
1105−[[package]]
1106−name = "thiserror-impl"
1107−version = "1.0.69"
1108−source = "registry+https://github.com/rust-lang/crates.io-index"
1109−checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
1110−dependencies = [
1111− "proc-macro2",
1112− "quote",
1113− "syn 2.0.118",
1114−]
1115−
1116−[[package]]
1117−name = "thiserror-impl"
1118−version = "2.0.18"
1119−source = "registry+https://github.com/rust-lang/crates.io-index"
1120−checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1121−dependencies = [
1122− "proc-macro2",
1123− "quote",
1124− "syn 2.0.118",
1125−]
1126−
1127−[[package]]
1128−name = "tinystr"
1129−version = "0.8.3"
1130−source = "registry+https://github.com/rust-lang/crates.io-index"
1131−checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1132−dependencies = [
1133− "displaydoc",
1134− "zerovec",
1135−]
1136−
1137−[[package]]
1138−name = "unicode-ident"
1139−version = "1.0.24"
1140−source = "registry+https://github.com/rust-lang/crates.io-index"
1141−checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1142−
1143−[[package]]
1144−name = "unicode-segmentation"
1145−version = "1.13.3"
1146−source = "registry+https://github.com/rust-lang/crates.io-index"
1147−checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
1148−
1149−[[package]]
1150−name = "unicode-width"
1151−version = "0.1.14"
1152−source = "registry+https://github.com/rust-lang/crates.io-index"
1153−checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
1154−
1155−[[package]]
1156−name = "unsynn"
1157−version = "0.3.0"
1158−source = "registry+https://github.com/rust-lang/crates.io-index"
1159−checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
1160−dependencies = [
1161− "mutants",
1162− "proc-macro2",
1163− "rustc-hash",
1164−]
1165−
1166−[[package]]
1167−name = "url"
1168−version = "2.5.8"
1169−source = "registry+https://github.com/rust-lang/crates.io-index"
1170−checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
1171−dependencies = [
1172− "form_urlencoded",
1173− "idna",
1174− "percent-encoding",
1175− "serde",
1176−]
1177−
1178−[[package]]
1179−name = "utf8_iter"
1180−version = "1.0.4"
1181−source = "registry+https://github.com/rust-lang/crates.io-index"
1182−checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1183−
1184−[[package]]
1185−name = "version_check"
1186−version = "0.9.5"
1187−source = "registry+https://github.com/rust-lang/crates.io-index"
1188−checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1189−
1190−[[package]]
1191−name = "wasi"
1192−version = "0.11.1+wasi-snapshot-preview1"
1193−source = "registry+https://github.com/rust-lang/crates.io-index"
1194−checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1195−
1196−[[package]]
1197−name = "wasm-bindgen"
1198−version = "0.2.126"
1199−source = "registry+https://github.com/rust-lang/crates.io-index"
1200−checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
1201−dependencies = [
1202− "cfg-if",
1203− "once_cell",
1204− "rustversion",
1205− "wasm-bindgen-macro",
1206− "wasm-bindgen-shared",
1207−]
1208−
1209−[[package]]
1210−name = "wasm-bindgen-macro"
1211−version = "0.2.126"
1212−source = "registry+https://github.com/rust-lang/crates.io-index"
1213−checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
1214−dependencies = [
1215− "quote",
1216− "wasm-bindgen-macro-support",
1217−]
1218−
1219−[[package]]
1220−name = "wasm-bindgen-macro-support"
1221−version = "0.2.126"
1222−source = "registry+https://github.com/rust-lang/crates.io-index"
1223−checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
1224−dependencies = [
1225− "bumpalo",
1226− "proc-macro2",
1227− "quote",
1228− "syn 2.0.118",
1229− "wasm-bindgen-shared",
1230−]
1231−
1232−[[package]]
1233−name = "wasm-bindgen-shared"
1234−version = "0.2.126"
1235−source = "registry+https://github.com/rust-lang/crates.io-index"
1236−checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
1237−dependencies = [
1238− "unicode-ident",
1239−]
1240−
1241−[[package]]
1242−name = "web-sys"
1243−version = "0.3.103"
1244−source = "registry+https://github.com/rust-lang/crates.io-index"
1245−checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
1246−dependencies = [
1247− "js-sys",
1248− "wasm-bindgen",
1249−]
1250−
1251−[[package]]
1252−name = "writeable"
1253−version = "0.6.3"
1254−source = "registry+https://github.com/rust-lang/crates.io-index"
1255−checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
1256−
1257−[[package]]
1258−name = "yoke"
1259−version = "0.8.3"
1260−source = "registry+https://github.com/rust-lang/crates.io-index"
1261−checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
1262−dependencies = [
1263− "stable_deref_trait",
1264− "yoke-derive",
1265− "zerofrom",
1266−]
1267−
1268−[[package]]
1269−name = "yoke-derive"
1270−version = "0.8.2"
1271−source = "registry+https://github.com/rust-lang/crates.io-index"
1272−checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
1273−dependencies = [
1274− "proc-macro2",
1275− "quote",
1276− "syn 2.0.118",
1277− "synstructure",
1278−]
1279−
1280−[[package]]
1281−name = "zerocopy"
1282−version = "0.8.53"
1283−source = "registry+https://github.com/rust-lang/crates.io-index"
1284−checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
1285−dependencies = [
1286− "zerocopy-derive",
1287−]
1288−
1289−[[package]]
1290−name = "zerocopy-derive"
1291−version = "0.8.53"
1292−source = "registry+https://github.com/rust-lang/crates.io-index"
1293−checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
1294−dependencies = [
1295− "proc-macro2",
1296− "quote",
1297− "syn 2.0.118",
1298−]
1299−
1300−[[package]]
1301−name = "zerofrom"
1302−version = "0.1.8"
1303−source = "registry+https://github.com/rust-lang/crates.io-index"
1304−checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
1305−dependencies = [
1306− "zerofrom-derive",
1307−]
1308−
1309−[[package]]
1310−name = "zerofrom-derive"
1311−version = "0.1.7"
1312−source = "registry+https://github.com/rust-lang/crates.io-index"
1313−checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
1314−dependencies = [
1315− "proc-macro2",
1316− "quote",
1317− "syn 2.0.118",
1318− "synstructure",
1319−]
1320−
1321−[[package]]
1322−name = "zerotrie"
1323−version = "0.2.4"
1324−source = "registry+https://github.com/rust-lang/crates.io-index"
1325−checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
1326−dependencies = [
1327− "displaydoc",
1328− "yoke",
1329− "zerofrom",
1330−]
1331−
1332−[[package]]
1333−name = "zerovec"
1334−version = "0.11.6"
1335−source = "registry+https://github.com/rust-lang/crates.io-index"
1336−checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
1337−dependencies = [
1338− "yoke",
1339− "zerofrom",
1340− "zerovec-derive",
1341−]
1342−
1343−[[package]]
1344−name = "zerovec-derive"
1345−version = "0.11.3"
1346−source = "registry+https://github.com/rust-lang/crates.io-index"
1347−checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
1348−dependencies = [
1349− "proc-macro2",
1350− "quote",
1351− "syn 2.0.118",
1352−]
1353−
1354−[[package]]
1355−name = "zmij"
1356−version = "1.0.21"
1357−source = "registry+https://github.com/rust-lang/crates.io-index"
1358−checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
shared/Cargo.tomldeleted−31
@@ -1,31 +0,0 @@
1−[package]
2−name = "shared"
3−version = "0.1.0"
4−edition = "2021"
5−rust-version = "1.85"
6−license = "Apache-2.0"
7−description = "Crux core for the opencode Android client"
8−
9−[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"]
13−
14−[[bin]]
15−name = "codegen"
16−required-features = ["typegen"]
17−
18−[features]
19−typegen = ["crux_core/typegen", "dep:anyhow"]
20−
21−[dependencies]
22−crux_core = "0.19"
23−crux_http = "0.19"
24−serde = { version = "1", features = ["derive"] }
25−serde_json = "1"
26−base64 = "0.22"
27−
28−anyhow = { version = "1", optional = true }
29−
30−[dev-dependencies]
31−crux_core = { version = "0.19", features = ["testing"] }
shared/src/app.rsdeleted−1051
@@ -1,1051 +0,0 @@
1−//! The Crux `App`: model, events, effects, update logic and view.
2−//!
3−//! Streaming works like this: after a successful connect the shell opens the
4−//! `/event` SSE stream and forwards every decoded event as
5−//! [`Event::ServerEvent`]. The core parses those and mutates the chat
6−//! incrementally — creating the assistant message on `message.updated`, growing
7−//! its text on `message.part.updated`, and clearing the "generating" flag on
8−//! `session.idle`. User messages are shown optimistically the moment they're
9−//! sent, so the stream only ever *adds* assistant content.
10−
11−use base64::Engine;
12−use crux_core::{
13− macros::effect,
14− render::{render, RenderOperation},
15− App, Command,
16−};
17−use crux_http::command::{Http, RequestBuilder};
18−use crux_http::protocol::HttpRequest;
19−use serde::{Deserialize, Serialize};
20−
21−use crate::protocol::{self, ServerEvent, WireMessageEnvelope, WirePart};
22−
23−/// Result of an HTTP effect handed back to the core.
24−pub type HttpResult = Result<crux_http::Response<String>, crux_http::HttpError>;
25−
26−// ─── Events ──────────────────────────────────────────────────────────────────
27−
28−pub enum Event {
29− // Lifecycle / connect
30− Start,
31− ServerUrlChanged(String),
32− UsernameChanged(String),
33− PasswordChanged(String),
34− Connect,
35−
36− // Sessions
37− LoadSessions,
38− SelectSession(String),
39− CreateSession,
40− DeleteSession(String),
41−
42− // Chat
43− LoadMessages(String),
44− DraftChanged(String),
45− SendMessage(String),
46− CancelGeneration,
47−
48− // A raw event line from the shell's `/event` SSE subscription.
49− ServerEvent(String),
50−
51− // Navigation / misc
52− NavigateToSessions,
53− NavigateToConnect,
54− DismissError,
55−
56− // ── Internal: results of HTTP effects (never sent by the shell) ──────────
57− HealthProbed(HttpResult),
58− SessionsLoaded(HttpResult),
59− SessionCreated(HttpResult),
60− MessagesLoaded(String, HttpResult),
61− PromptSent(HttpResult),
62− SessionDeleted(HttpResult),
63− Aborted(HttpResult),
64−}
65−
66−// ─── Effects ─────────────────────────────────────────────────────────────────
67−
68−#[effect]
69−#[derive(Debug)]
70−pub enum Effect {
71− Render(RenderOperation),
72− Http(HttpRequest),
73−}
74−
75−// ─── Model ───────────────────────────────────────────────────────────────────
76−
77−#[derive(Default)]
78−pub struct Model {
79− server_url: String,
80− username: String,
81− password: String,
82− /// The server answered 401 to an unauthenticated probe — show credentials.
83− auth_required: bool,
84− /// Credentials were accepted; attach the auth header to every request.
85− authed: bool,
86− connected: bool,
87− loading: bool,
88− error: Option<String>,
89− screen: Screen,
90−
91− sessions: Vec<SessionView>,
92− current_session_id: Option<String>,
93− current_session_title: String,
94−
95− messages: Vec<MsgState>,
96− draft: String,
97− 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− }
182−}
183−
184−// ─── View model ──────────────────────────────────────────────────────────────
185−
186−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
187−pub enum Screen {
188− #[default]
189− Connect,
190− Sessions,
191− Chat,
192−}
193−
194−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
195−pub struct SessionView {
196− pub id: String,
197− pub title: String,
198−}
199−
200−#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
201−pub enum MessageStatus {
202− #[default]
203− Sent,
204− Pending,
205− Failed,
206−}
207−
208−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
209−pub struct ToolView {
210− pub name: String,
211− pub status: String,
212− pub title: Option<String>,
213−}
214−
215−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
216−pub struct MessageView {
217− pub id: String,
218− pub role: String,
219− pub text: String,
220− pub reasoning: Option<String>,
221− pub tools: Vec<ToolView>,
222− pub time: u64,
223− pub status: MessageStatus,
224− pub streaming: bool,
225−}
226−
227−#[derive(Serialize, Deserialize, Clone, Debug, Default)]
228−pub struct ViewModel {
229− pub screen: Screen,
230− pub server_url: String,
231− pub username: String,
232− pub password: String,
233− pub auth_required: bool,
234− pub connected: bool,
235− pub loading: bool,
236− pub error: Option<String>,
237− pub sessions: Vec<SessionView>,
238− pub current_session_id: Option<String>,
239− pub current_session_title: String,
240− pub messages: Vec<MessageView>,
241− pub draft: String,
242− pub generating: bool,
243−}
244−
245−// ─── App ─────────────────────────────────────────────────────────────────────
246−
247−#[derive(Default)]
248−pub struct OpencodeApp;
249−
250−impl OpencodeApp {
251− fn get(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
252− let url = format!("{}{path}", model.server_url);
253− self.with_auth(Http::get(&url).expect_string(), model)
254− }
255−
256− fn post_json(&self, model: &Model, path: &str, body: &serde_json::Value) -> RequestBuilder<Effect, Event, String> {
257− let url = format!("{}{path}", model.server_url);
258− let bytes = serde_json::to_vec(body).unwrap_or_default();
259− let req = Http::post(&url)
260− .expect_string()
261− .header("Content-Type", "application/json")
262− .body_bytes(bytes);
263− self.with_auth(req, model)
264− }
265−
266− fn delete(&self, model: &Model, path: &str) -> RequestBuilder<Effect, Event, String> {
267− let url = format!("{}{path}", model.server_url);
268− self.with_auth(Http::delete(&url).expect_string(), model)
269− }
270−
271− /// Attach an RFC 7617 Basic-Auth header when the model carries credentials.
272− fn with_auth<E>(&self, req: RequestBuilder<Effect, Event, E>, model: &Model) -> RequestBuilder<Effect, Event, E>
273− where
274− E: 'static,
275− {
276− if model.has_credentials() {
277− let creds = format!("{}:{}", model.username, model.password);
278− let encoded = base64::engine::general_purpose::STANDARD.encode(creds.as_bytes());
279− req.header("Authorization", format!("Basic {encoded}"))
280− } else {
281− req
282− }
283− }
284−}
285−
286−impl App for OpencodeApp {
287− type Event = Event;
288− type Model = Model;
289− type ViewModel = ViewModel;
290− type Effect = Effect;
291−
292− fn update(&self, event: Event, model: &mut Model) -> Command<Effect, Event> {
293− 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.
302− Event::ServerUrlChanged(url) => {
303− model.server_url = url;
304− if model.error.is_some() {
305− model.error = None;
306− }
307− render()
308− }
309− Event::UsernameChanged(u) => {
310− model.username = u;
311− if model.error.is_some() {
312− model.error = None;
313− }
314− render()
315− }
316− Event::PasswordChanged(p) => {
317− model.password = p;
318− if model.error.is_some() {
319− model.error = None;
320− }
321− render()
322− }
323−
324− Event::Connect => {
325− model.server_url = normalize_url(&model.server_url);
326− if model.server_url.is_empty() {
327− model.error = Some("Enter a server URL".to_string());
328− return render();
329− }
330− model.loading = true;
331− model.error = None;
332− model.auth_required = false;
333− render().and(self.get(model, "/global/health").build().then_send(Event::HealthProbed))
334− }
335−
336− Event::HealthProbed(result) => self.on_health_probed(model, result),
337−
338− // ── Sessions ────────────────────────────────────────────────────
339− Event::LoadSessions => {
340− model.loading = true;
341− render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
342− }
343−
344− Event::SessionsLoaded(result) => {
345− 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());
358− }
359− Err(e) => model.error = Some(e),
360− }
361− render()
362− }
363−
364− Event::SelectSession(id) => {
365− model.current_session_title = model
366− .sessions
367− .iter()
368− .find(|s| s.id == id)
369− .map(|s| s.title.clone())
370− .unwrap_or_default();
371− model.current_session_id = Some(id.clone());
372− model.messages.clear();
373− model.screen = Screen::Chat;
374− model.loading = true;
375− model.error = None;
376− render().and(Command::event(Event::LoadMessages(id)))
377− }
378−
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− )
386− }
387−
388− Event::SessionCreated(result) => {
389− 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− }
406− render()
407− }
408−
409− Event::DeleteSession(id) => {
410− model.sessions.retain(|s| s.id != id);
411− let mut cmd = self.delete(model, &format!("/session/{id}")).build().then_send(Event::SessionDeleted);
412− if model.is_current(&id) {
413− model.current_session_id = None;
414− model.messages.clear();
415− model.screen = Screen::Sessions;
416− }
417− cmd = render().and(cmd);
418− cmd
419− }
420−
421− Event::SessionDeleted(result) => {
422− if let Err(e) = ok_body(result) {
423− model.error = Some(e);
424− }
425− render()
426− }
427−
428− // ── Chat ────────────────────────────────────────────────────────
429− 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− )
435− }
436−
437− Event::MessagesLoaded(session_id, result) => {
438− if !model.is_current(&session_id) {
439− return render();
440− }
441− 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);
446− }
447− Err(e) => model.error = Some(e),
448− }
449− render()
450− }
451−
452− Event::DraftChanged(text) => {
453− model.draft = text;
454− render()
455− }
456−
457− Event::SendMessage(text) => {
458− let text = text.trim().to_string();
459− let Some(session_id) = model.current_session_id.clone() else {
460− return render();
461− };
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();
471− 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),
485− Err(e) => {
486− set_last_pending(model, MessageStatus::Failed);
487− model.generating = false;
488− model.error = Some(e);
489− }
490− }
491− render()
492− }
493−
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(),
503− }
504− }
505−
506− Event::Aborted(_) => render(),
507−
508− // ── SSE ─────────────────────────────────────────────────────────
509− Event::ServerEvent(json) => {
510− if let Some(event) = protocol::parse_event(&json) {
511− apply_server_event(model, event);
512− }
513− render()
514− }
515−
516− // ── Navigation / misc ───────────────────────────────────────────
517− Event::NavigateToSessions => {
518− model.screen = Screen::Sessions;
519− model.current_session_id = None;
520− model.messages.clear();
521− model.generating = false;
522− render().and(self.get(model, "/session").build().then_send(Event::SessionsLoaded))
523− }
524−
525− Event::NavigateToConnect => {
526− model.screen = Screen::Connect;
527− model.connected = false;
528− model.sessions.clear();
529− model.messages.clear();
530− model.current_session_id = None;
531− render()
532− }
533−
534− Event::DismissError => {
535− model.error = None;
536− render()
537− }
538− }
539− }
540−
541− fn view(&self, model: &Model) -> ViewModel {
542− ViewModel {
543− screen: model.screen.clone(),
544− server_url: model.server_url.clone(),
545− username: model.username.clone(),
546− password: model.password.clone(),
547− auth_required: model.auth_required,
548− connected: model.connected,
549− loading: model.loading,
550− error: model.error.clone(),
551− sessions: model.sessions.clone(),
552− current_session_id: model.current_session_id.clone(),
553− current_session_title: model.current_session_title.clone(),
554− messages: model.messages.iter().map(MsgState::to_view).collect(),
555− draft: model.draft.clone(),
556− generating: model.generating,
557− }
558− }
559−}
560−
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));
586− }
587− model.loading = false;
588− model.error = Some(format!("Server returned status {status}"));
589− render()
590− }
591− Err(e) => {
592− model.loading = false;
593− model.error = Some(format!("Connection failed: {e}"));
594− render()
595− }
596− }
597− }
598−}
599−
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;
617− }
618− } else {
619− let mut msg = MsgState::new(info.id.clone(), "assistant".to_string(), info.time.created);
620− msg.streaming = streaming;
621− model.messages.push(msg);
622− }
623− if let Some(err) = info.error {
624− model.error = Some(err.message());
625− model.generating = false;
626− }
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;
642− }
643− let value = pick_text(text, &delta, &msg.text_parts, &id);
644− MsgState::upsert_part(&mut msg.text_parts, &id, value);
645− }
646− WirePart::Reasoning { id, text, .. } => {
647− let value = pick_text(text, &delta, &msg.reasoning_parts, &id);
648− MsgState::upsert_part(&mut msg.reasoning_parts, &id, value);
649− }
650− WirePart::Tool { id, tool, state, .. } => {
651− let sv = protocol::tool_state_view(&state);
652− msg.upsert_tool(&id, ToolView { name: tool, status: sv.status, title: sv.title });
653− }
654− WirePart::Other => {}
655− }
656− }
657−
658− ServerEvent::MessagePartRemoved { properties } => {
659− if let Some(msg) = model.message_mut(&properties.message_id) {
660− msg.remove_part(&properties.part_id);
661− }
662− }
663−
664− ServerEvent::MessageRemoved { properties } => {
665− model.messages.retain(|m| m.id != properties.message_id);
666− }
667−
668− ServerEvent::SessionIdle { properties } => {
669− if model.is_current(&properties.session_id) {
670− model.generating = false;
671− for m in &mut model.messages {
672− m.streaming = false;
673− }
674− }
675− }
676−
677− ServerEvent::SessionError { properties } => {
678− let scoped = properties.session_id.as_deref().map_or(true, |s| model.is_current(s));
679− if scoped {
680− if let Some(err) = properties.error {
681− model.error = Some(err.message());
682− }
683− model.generating = false;
684− }
685− }
686−
687− ServerEvent::SessionCreated { properties } | ServerEvent::SessionUpdated { properties } => {
688− let s = properties.info;
689− let view = SessionView { id: s.id.clone(), title: display_title(&s.title) };
690− if let Some(existing) = model.sessions.iter_mut().find(|e| e.id == view.id) {
691− existing.title = view.title.clone();
692− } else {
693− model.sessions.insert(0, view);
694− }
695− if model.is_current(&s.id) {
696− model.current_session_title = display_title(&s.title);
697− }
698− }
699−
700− ServerEvent::SessionDeleted { properties } => {
701− let id = properties.session_id.or(properties.info.map(|i| i.id));
702− if let Some(id) = id {
703− model.sessions.retain(|s| s.id != id);
704− if model.is_current(&id) {
705− model.current_session_id = None;
706− model.messages.clear();
707− model.screen = Screen::Sessions;
708− }
709− }
710− }
711−
712− ServerEvent::Other => {}
713− }
714−}
715−
716−// ─── Helpers ─────────────────────────────────────────────────────────────────
717−
718−/// `part.text` is the full current text when present; otherwise fall back to
719−/// accumulating the incremental `delta` onto whatever we already have.
720−fn pick_text(text: String, delta: &Option<String>, existing: &[(String, String)], part_id: &str) -> String {
721− if !text.is_empty() {
722− return text;
723− }
724− let prior = existing.iter().find(|(pid, _)| pid == part_id).map(|(_, t)| t.clone()).unwrap_or_default();
725− match delta {
726− Some(d) => format!("{prior}{d}"),
727− None => prior,
728− }
729−}
730−
731−fn message_from_wire(env: &WireMessageEnvelope) -> MsgState {
732− let info = &env.info;
733− let mut msg = MsgState::new(info.id.clone(), info.role.clone(), info.time.created);
734− msg.streaming = info.role == "assistant" && info.time.completed.is_none() && info.error.is_none();
735− for part in &env.parts {
736− match part {
737− WirePart::Text { id, text, synthetic, .. } => {
738− if !synthetic {
739− MsgState::upsert_part(&mut msg.text_parts, id, text.clone());
740− }
741− }
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 => {}
750− }
751− }
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− }
759−}
760−
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}")
772− }
773−}
774−
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− }
782−}
783−
784−/// Reduce an [`HttpResult`] to `Ok(body)` for 2xx, or `Err(message)` otherwise.
785−fn ok_body(result: HttpResult) -> Result<String, String> {
786− match result {
787− Ok(mut response) => {
788− let status = response.status().as_u16();
789− if (200..300).contains(&status) {
790− Ok(response.take_body().unwrap_or_default())
791− } else {
792− Err(format!("Server returned status {status}"))
793− }
794− }
795− Err(e) => Err(format!("Request failed: {e}")),
796− }
797−}
798−
799−// ─── Tests ───────────────────────────────────────────────────────────────────
800−
801−#[cfg(test)]
802−mod tests {
803− use super::*;
804−
805− fn app() -> OpencodeApp {
806− OpencodeApp::default()
807− }
808−
809− /// Put the model into a connected Chat view for the given session.
810− fn in_chat(session_id: &str) -> Model {
811− let mut model = Model::default();
812− model.server_url = "http://localhost:4096".to_string();
813− model.connected = true;
814− model.screen = Screen::Chat;
815− model.current_session_id = Some(session_id.to_string());
816− model
817− }
818−
819− fn feed(app: &OpencodeApp, model: &mut Model, json: serde_json::Value) {
820− let _ = app.update(Event::ServerEvent(json.to_string()), model);
821− }
822−
823− #[test]
824− fn server_url_is_kept_raw_while_typing() {
825− let app = app();
826− let mut model = Model::default();
827− // Mid-scheme input must survive verbatim — normalizing per keystroke
828− // would eat the "://" the user is typing and make the field unusable.
829− let _ = app.update(Event::ServerUrlChanged("http:/".into()), &mut model);
830− assert_eq!(app.view(&model).server_url, "http:/");
831− }
832−
833− #[test]
834− fn connect_normalizes_scheme_less_url() {
835− let app = app();
836− 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");
843− }
844−
845− #[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");
852− }
853−
854− #[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"));
860− }
861−
862− #[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);
876− }
877−
878− #[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);
885− }
886−
887− #[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− }));
918−
919− let view = app.view(&model);
920− assert_eq!(view.messages[0].role, "assistant");
921− assert_eq!(view.messages[0].text, "Hello, world");
922− }
923−
924− #[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");
942− }
943−
944− #[test]
945− fn user_message_from_sse_is_not_duplicated() {
946− let app = app();
947− let mut model = in_chat("ses_1");
948− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
949− // Server echoes the user message on the stream — must be ignored.
950− feed(&app, &mut model, serde_json::json!({
951− "type": "message.updated",
952− "properties": { "info": { "id": "msg_user", "role": "user", "sessionID": "ses_1", "time": {"created": 5}}}
953− }));
954− let users = app.view(&model).messages.iter().filter(|m| m.role == "user").count();
955− assert_eq!(users, 1);
956− }
957−
958− #[test]
959− fn session_idle_clears_generating() {
960− let app = app();
961− let mut model = in_chat("ses_1");
962− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
963− assert!(app.view(&model).generating);
964− feed(&app, &mut model, serde_json::json!({
965− "type": "session.idle", "properties": { "sessionID": "ses_1" }
966− }));
967− assert!(!app.view(&model).generating);
968− }
969−
970− #[test]
971− fn idle_for_other_session_is_ignored() {
972− let app = app();
973− let mut model = in_chat("ses_1");
974− let _ = app.update(Event::SendMessage("hi".into()), &mut model);
975− feed(&app, &mut model, serde_json::json!({
976− "type": "session.idle", "properties": { "sessionID": "ses_other" }
977− }));
978− assert!(app.view(&model).generating);
979− }
980−
981− #[test]
982− fn reasoning_and_tool_parts_are_surfaced() {
983− let app = app();
984− let mut model = in_chat("ses_1");
985− feed(&app, &mut model, serde_json::json!({
986− "type": "message.updated",
987− "properties": { "info": { "id": "m", "role": "assistant", "sessionID": "ses_1", "time": {"created": 1}}}
988− }));
989− feed(&app, &mut model, serde_json::json!({
990− "type": "message.part.updated",
991− "properties": { "part": { "id": "r", "messageID": "m", "type": "reasoning", "text": "thinking..." }}
992− }));
993− feed(&app, &mut model, serde_json::json!({
994− "type": "message.part.updated",
995− "properties": { "part": {
996− "id": "t", "messageID": "m", "type": "tool", "tool": "bash",
997− "state": { "status": "completed", "title": "ls -la" }
998− }}
999− }));
1000− let msg = &app.view(&model).messages[0];
1001− assert_eq!(msg.reasoning.as_deref(), Some("thinking..."));
1002− assert_eq!(msg.tools.len(), 1);
1003− assert_eq!(msg.tools[0].name, "bash");
1004− assert_eq!(msg.tools[0].status, "completed");
1005− assert_eq!(msg.tools[0].title.as_deref(), Some("ls -la"));
1006− }
1007−
1008− #[test]
1009− fn session_created_event_updates_list() {
1010− let app = app();
1011− let mut model = in_chat("ses_1");
1012− feed(&app, &mut model, serde_json::json!({
1013− "type": "session.created",
1014− "properties": { "info": { "id": "ses_new", "title": "Fresh" }}
1015− }));
1016− assert!(app.view(&model).sessions.iter().any(|s| s.id == "ses_new" && s.title == "Fresh"));
1017− }
1018−
1019− #[test]
1020− fn unknown_event_types_are_ignored() {
1021− let app = app();
1022− let mut model = in_chat("ses_1");
1023− feed(&app, &mut model, serde_json::json!({
1024− "type": "pty.created", "properties": { "whatever": true }
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());
1029− }
1030−
1031− #[test]
1032− fn messages_loaded_parses_info_and_parts() {
1033− let app = app();
1034− let mut model = in_chat("ses_1");
1035− let body = serde_json::json!([
1036− { "info": { "id": "m1", "role": "user", "sessionID": "ses_1", "time": {"created": 1} },
1037− "parts": [{ "id": "p1", "type": "text", "text": "Question?" }] },
1038− { "info": { "id": "m2", "role": "assistant", "sessionID": "ses_1", "time": {"created": 2, "completed": 3} },
1039− "parts": [{ "id": "p2", "type": "text", "text": "Answer." }] }
1040− ]).to_string();
1041− let result: HttpResult = Ok(crux_http::testing::ResponseBuilder::ok().body(body).build());
1042− let _ = app.update(Event::MessagesLoaded("ses_1".into(), result), &mut model);
1043−
1044− let view = app.view(&model);
1045− assert_eq!(view.messages.len(), 2);
1046− assert_eq!(view.messages[0].text, "Question?");
1047− assert_eq!(view.messages[1].text, "Answer.");
1048− assert!(!view.messages[1].streaming);
1049− assert!(!view.generating);
1050− }
1051−}
shared/src/bin/codegen.rsdeleted−35
@@ -1,35 +0,0 @@
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−
12−use std::path::PathBuf;
13−
14−use anyhow::Result;
15−use crux_core::typegen::TypeGen;
16−use shared::{MessageStatus, MessageView, Screen, SessionView, ToolView, ViewModel};
17−
18−fn main() -> Result<()> {
19− let mut gen = TypeGen::new();
20−
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−
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)?;
32−
33− println!("Generated shared JVM types into {}", out.display());
34− Ok(())
35−}
shared/src/lib.rsdeleted−17
@@ -1,17 +0,0 @@
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.
13−
14−mod app;
15−mod protocol;
16−
17−pub 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−}