git.iko.soy/opencode-android
on master help sign in
.direnv/bin/nix-direnv-reloaddeleted−19
since splitsnapshots
unifiedsplit
@@ -1,19 +0,0 @@
1−#!/usr/bin/env bash
2−set -e
3−if [[ ! -d "/home/iko/opencode-android" ]]; then
4− echo "Cannot find source directory; Did you move it?"
5− echo "(Looking for "/home/iko/opencode-android")"
6− echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
7− exit 1
8−fi
9−
10−# rebuild the cache forcefully
11−_nix_direnv_force_reload=1 direnv exec "/home/iko/opencode-android" true
12−
13−# Update the mtime for .envrc.
14−# This will cause direnv to reload again - but without re-building.
15−touch "/home/iko/opencode-android/.envrc"
16−
17−# Also update the timestamp of whatever profile_rc we have.
18−# This makes sure that we know we are up to date.
19−touch -r "/home/iko/opencode-android/.envrc" "/home/iko/opencode-android/.direnv"/*.rc
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfadeleted−1
@@ -1 +0,0 @@
1−/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcdeleted−2250
@@ -1,2250 +0,0 @@
1−unset shellHook
2−PATH=${PATH:-}
3−nix_saved_PATH="$PATH"
4−XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
5−nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
6−ANDROID_HOME='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
7−export ANDROID_HOME
8−ANDROID_SDK_ROOT='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
9−export ANDROID_SDK_ROOT
10−AR='ar'
11−export AR
12−AS='as'
13−export AS
14−BASH='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
15−CC='gcc'
16−export CC
17−CLASSPATH=''
18−export CLASSPATH
19−CONFIG_SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
20−export CONFIG_SHELL
21−CXX='g++'
22−export CXX
23−GRADLE_OPTS='-Dorg.gradle.project.android.aapt2FromMavenOverride=/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2'
24−export GRADLE_OPTS
25−HOSTTYPE='x86_64'
26−HOST_PATH='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
27−export HOST_PATH
28−IFS='
29−'
30−IN_NIX_SHELL='impure'
31−export IN_NIX_SHELL
32−JAVA_HOME='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2'
33−export JAVA_HOME
34−LD='ld'
35−export LD
36−LINENO='80'
37−MACHTYPE='x86_64-pc-linux-gnu'
38−NIX_BINTOOLS='/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46'
39−export NIX_BINTOOLS
40−NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
41−export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
42−NIX_BUILD_CORES='4'
43−export NIX_BUILD_CORES
44−NIX_CC='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
45−export NIX_CC
46−NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
47−export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
48−NIX_CFLAGS_COMPILE=' -frandom-seed=n53brq0w7p -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include'
49−export NIX_CFLAGS_COMPILE
50−NIX_ENFORCE_NO_NATIVE='1'
51−export NIX_ENFORCE_NO_NATIVE
52−NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
53−export NIX_HARDENING_ENABLE
54−NIX_LDFLAGS='-rpath /home/iko/opencode-android/outputs/out/lib '
55−export NIX_LDFLAGS
56−NIX_NO_SELF_RPATH='1'
57−NIX_STORE='/nix/store'
58−export NIX_STORE
59−NM='nm'
60−export NM
61−OBJCOPY='objcopy'
62−export OBJCOPY
63−OBJDUMP='objdump'
64−export OBJDUMP
65−OLDPWD=''
66−export OLDPWD
67−OPTERR='1'
68−OSTYPE='linux-gnu'
69−PATH='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/bin:/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0/bin:/nix/store/sq0nrnfwhkc5ljvklnrk5ps4358g4nbj-gcc-15.2.0/bin:/nix/store/521dd0054ifhzmjfmpx9mz0hr7wh7sig-glibc-2.42-67-bin/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46/bin:/nix/store/39lyf5g2iz4jd2r6q4g5whk9vw09zglj-binutils-2.46/bin:/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
70−export PATH
71−PS4='+ '
72−RANLIB='ranlib'
73−export RANLIB
74−READELF='readelf'
75−export READELF
76−SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
77−export SHELL
78−SIZE='size'
79−export SIZE
80−SOURCE_DATE_EPOCH='315532800'
81−export SOURCE_DATE_EPOCH
82−STRINGS='strings'
83−export STRINGS
84−STRIP='strip'
85−export STRIP
86−XDG_DATA_DIRS='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/share'
87−export XDG_DATA_DIRS
88−__structuredAttrs=''
89−export __structuredAttrs
90−_substituteStream_has_warned_replace_deprecation='false'
91−buildInputs='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2 /nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4 /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk'
92−export buildInputs
93−buildPhase='{ echo "------------------------------------------------------------";
94− echo " WARNING: the existence of this path is not guaranteed.";
95− echo " It is an internal implementation detail for pkgs.mkShell.";
96− echo "------------------------------------------------------------";
97− echo;
98− # Record all build inputs as runtime dependencies
99− export;
100−} >> "$out"
101−'
102−export buildPhase
103−builder='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
104−export builder
105−checkPhase='gradleCheckPhase'
106−cmakeFlags=''
107−export cmakeFlags
108−configureFlags=''
109−export configureFlags
110−defaultBuildInputs=''
111−defaultNativeBuildInputs='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2 /nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
112−depsBuildBuild=''
113−export depsBuildBuild
114−depsBuildBuildPropagated=''
115−export depsBuildBuildPropagated
116−depsBuildTarget=''
117−export depsBuildTarget
118−depsBuildTargetPropagated=''
119−export depsBuildTargetPropagated
120−depsHostHost=''
121−export depsHostHost
122−depsHostHostPropagated=''
123−export depsHostHostPropagated
124−depsTargetTarget=''
125−export depsTargetTarget
126−depsTargetTargetPropagated=''
127−export depsTargetTargetPropagated
128−doCheck=''
129−export doCheck
130−doInstallCheck=''
131−export doInstallCheck
132−dontAddDisableDepTrack='1'
133−export dontAddDisableDepTrack
134−declare -a envBuildBuildHooks=()
135−declare -a envBuildHostHooks=()
136−declare -a envBuildTargetHooks=()
137−declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
138−declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
139−declare -a envTargetTargetHooks=('addPkgToClassPath' )
140−declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
141−initialPath='/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11 /nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0 /nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12 /nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9 /nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12 /nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0 /nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35 /nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14 /nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin /nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1 /nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9 /nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8 /nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin /nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47'
142−mesonFlags=''
143−export mesonFlags
144−name='nix-shell-env'
145−export name
146−nativeBuildInputs=''
147−export nativeBuildInputs
148−out='/home/iko/opencode-android/outputs/out'
149−export out
150−outputBin='out'
151−outputDev='out'
152−outputDevdoc='REMOVE'
153−outputDevman='out'
154−outputDoc='out'
155−outputInclude='out'
156−outputInfo='out'
157−outputLib='out'
158−outputMan='out'
159−outputs='out'
160−export outputs
161−patches=''
162−export patches
163−phases='buildPhase'
164−export phases
165−pkg='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
166−declare -a pkgsBuildBuild=()
167−declare -a pkgsBuildHost=('/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2' '/nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0' '/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46' )
168−declare -a pkgsBuildTarget=()
169−declare -a pkgsHostHost=()
170−declare -a pkgsHostTarget=('/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2' '/nix/store/2dsv152lhz5rxl24fkb6sis3q165swv6-set-java-classpath-hook' '/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4' '/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk' )
171−declare -a pkgsTargetTarget=()
172−declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
173−declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
174−declare -a preConfigureHooks=('mitmCacheConfigureHook' '_multioutConfig' 'gradleConfigureHook' )
175−preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
176−declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
177−preferLocalBuild='1'
178−export preferLocalBuild
179−prefix='/home/iko/opencode-android/outputs/out'
180−declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
181−propagatedBuildInputs=''
182−export propagatedBuildInputs
183−declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
184−propagatedNativeBuildInputs=''
185−export propagatedNativeBuildInputs
186−declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
187−shell='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
188−export shell
189−shellHook='echo "opencode-android dev shell"
190−echo " JDK: $(java -version 2>&1 | head -1)"
191−echo " ANDROID_HOME: $ANDROID_HOME"
192−echo " aapt2: /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2"
193−echo ""
194−echo "Build: ./gradlew assembleDebug"
195−echo "Device: adb devices"
196−'
197−export shellHook
198−stdenv='/nix/store/xknwpg5yjxxsffmwdmpys269543wpdy5-stdenv-linux'
199−export stdenv
200−strictDeps=''
201−export strictDeps
202−system='x86_64-linux'
203−export system
204−declare -a unpackCmdHooks=('_defaultUnpack' )
205−_activatePkgs ()
206−{
207−
208− local hostOffset targetOffset;
209− local pkg;
210− for hostOffset in "${allPlatOffsets[@]}";
211− do
212− local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
213− for targetOffset in "${allPlatOffsets[@]}";
214− do
215− (( hostOffset <= targetOffset )) || continue;
216− local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
217− local pkgsSlice="${!pkgsRef}[@]";
218− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
219− do
220− activatePackage "$pkg" "$hostOffset" "$targetOffset";
221− done;
222− done;
223− done
224−}
225−_addRpathPrefix ()
226−{
227−
228− if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
229− export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
230− fi
231−}
232−_addToEnv ()
233−{
234−
235− local depHostOffset depTargetOffset;
236− local pkg;
237− for depHostOffset in "${allPlatOffsets[@]}";
238− do
239− local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
240− local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
241− for depTargetOffset in "${allPlatOffsets[@]}";
242− do
243− (( depHostOffset <= depTargetOffset )) || continue;
244− local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
245− if [[ -z "${strictDeps-}" ]]; then
246− local visitedPkgs="";
247− for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
248− do
249− if [[ "$visitedPkgs" = *"$pkg"* ]]; then
250− continue;
251− fi;
252− runHook "${!hookRef}" "$pkg";
253− visitedPkgs+=" $pkg";
254− done;
255− else
256− local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
257− local pkgsSlice="${!pkgsRef}[@]";
258− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
259− do
260− runHook "${!hookRef}" "$pkg";
261− done;
262− fi;
263− done;
264− done
265−}
266−_allFlags ()
267−{
268−
269− export system pname name version;
270− while IFS='' read -r varName; do
271− nixTalkativeLog "@${varName}@ -> ${!varName}";
272− args+=("--subst-var" "$varName");
273− done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
274−}
275−_assignFirst ()
276−{
277−
278− local varName="$1";
279− local _var;
280− local REMOVE=REMOVE;
281− shift;
282− for _var in "$@";
283− do
284− if [ -n "${!_var-}" ]; then
285− eval "${varName}"="${_var}";
286− return;
287− fi;
288− done;
289− echo;
290− echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
291− echo " The following variables were all unset or empty:";
292− echo " $*";
293− if [ -z "${out:-}" ]; then
294− echo ' If you do not want an "out" output in your derivation, make sure to define';
295− echo ' the other specific required outputs. This can be achieved by picking one';
296− echo " of the above as an output.";
297− echo ' You do not have to remove "out" if you want to have a different default';
298− echo ' output, because the first output is taken as a default.';
299− echo;
300− fi;
301− return 1
302−}
303−_callImplicitHook ()
304−{
305−
306− local def="$1";
307− local hookName="$2";
308− if declare -F "$hookName" > /dev/null; then
309− nixTalkativeLog "calling implicit '$hookName' function hook";
310− "$hookName";
311− else
312− if type -p "$hookName" > /dev/null; then
313− nixTalkativeLog "sourcing implicit '$hookName' script hook";
314− source "$hookName";
315− else
316− if [ -n "${!hookName:-}" ]; then
317− nixTalkativeLog "evaling implicit '$hookName' string hook";
318− eval "${!hookName}";
319− else
320− return "$def";
321− fi;
322− fi;
323− fi
324−}
325−_defaultUnpack ()
326−{
327−
328− local fn="$1";
329− local destination;
330− if [ -d "$fn" ]; then
331− destination="$(stripHash "$fn")";
332− if [ -e "$destination" ]; then
333− echo "Cannot copy $fn to $destination: destination already exists!";
334− echo "Did you specify two \"srcs\" with the same \"name\"?";
335− return 1;
336− fi;
337− cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
338− else
339− case "$fn" in
340− *.tar.xz | *.tar.lzma | *.txz)
341− ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
342− true ) | tar xf - --mode=+w --warning=no-timestamp
343− ;;
344− *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
345− tar xf "$fn" --mode=+w --warning=no-timestamp
346− ;;
347− *)
348− return 1
349− ;;
350− esac;
351− fi
352−}
353−_doStrip ()
354−{
355−
356− local -ra flags=(dontStripHost dontStripTarget);
357− local -ra debugDirs=(stripDebugList stripDebugListTarget);
358− local -ra allDirs=(stripAllList stripAllListTarget);
359− local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
360− local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
361− stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
362− stripDebugListTarget=${stripDebugListTarget[*]:-};
363− stripAllList=${stripAllList[*]:-};
364− stripAllListTarget=${stripAllListTarget[*]:-};
365− local i;
366− for i in ${!stripCmds[@]};
367− do
368− local -n flag="${flags[$i]}";
369− local -n debugDirList="${debugDirs[$i]}";
370− local -n allDirList="${allDirs[$i]}";
371− local -n stripCmd="${stripCmds[$i]}";
372− local -n ranlibCmd="${ranlibCmds[$i]}";
373− if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
374− continue;
375− fi;
376− stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
377− stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
378− done
379−}
380−_eval ()
381−{
382−
383− if declare -F "$1" > /dev/null 2>&1; then
384− "$@";
385− else
386− eval "$1";
387− fi
388−}
389−_logHook ()
390−{
391−
392− if [[ -z ${NIX_LOG_FD-} ]]; then
393− return;
394− fi;
395− local hookKind="$1";
396− local hookExpr="$2";
397− shift 2;
398− if declare -F "$hookExpr" > /dev/null 2>&1; then
399− nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
400− else
401− if type -p "$hookExpr" > /dev/null; then
402− nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
403− else
404− if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
405− local exprToOutput;
406− if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
407− exprToOutput="$hookExpr";
408− else
409− local hookExprLine;
410− while IFS= read -r hookExprLine; do
411− hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
412− if [[ -n "$hookExprLine" ]]; then
413− exprToOutput+="$hookExprLine\\n ";
414− fi;
415− done <<< "$hookExpr";
416− exprToOutput="${exprToOutput%%\\n }";
417− fi;
418− nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
419− fi;
420− fi;
421− fi
422−}
423−_makeSymlinksRelative ()
424−{
425−
426− local prefixes;
427− prefixes=();
428− for output in $(getAllOutputNames);
429− do
430− [ ! -e "${!output}" ] && continue;
431− prefixes+=("${!output}");
432− done;
433− find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
434− output="$1"
435− link="$2"
436−
437− linkTarget=$(readlink "$link")
438−
439− # only touch links that point inside the same output tree
440− [[ $linkTarget == "$output"/* ]] || exit 0
441−
442− if [ ! -e "$linkTarget" ]; then
443− echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
444− fi
445−
446− echo "making symlink relative: $link"
447− ln -snrf "$linkTarget" "$link"
448− ' _
449−}
450−_moveLib64 ()
451−{
452−
453− if [ "${dontMoveLib64-}" = 1 ]; then
454− return;
455− fi;
456− if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
457− return;
458− fi;
459− echo "moving $prefix/lib64/* to $prefix/lib";
460− mkdir -p $prefix/lib;
461− shopt -s dotglob;
462− for i in $prefix/lib64/*;
463− do
464− mv --no-clobber "$i" $prefix/lib;
465− done;
466− shopt -u dotglob;
467− rmdir $prefix/lib64;
468− ln -s lib $prefix/lib64
469−}
470−_moveSbin ()
471−{
472−
473− if [ "${dontMoveSbin-}" = 1 ]; then
474− return;
475− fi;
476− if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
477− return;
478− fi;
479− echo "moving $prefix/sbin/* to $prefix/bin";
480− mkdir -p $prefix/bin;
481− shopt -s dotglob;
482− for i in $prefix/sbin/*;
483− do
484− mv "$i" $prefix/bin;
485− done;
486− shopt -u dotglob;
487− rmdir $prefix/sbin;
488− ln -s bin $prefix/sbin
489−}
490−_moveSystemdUserUnits ()
491−{
492−
493− if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
494− return;
495− fi;
496− if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
497− return;
498− fi;
499− local source="$prefix/lib/systemd/user";
500− local target="$prefix/share/systemd/user";
501− echo "moving $source/* to $target";
502− mkdir -p "$target";
503− ( shopt -s dotglob;
504− for i in "$source"/*;
505− do
506− mv "$i" "$target";
507− done );
508− rmdir "$source";
509− ln -s "$target" "$source"
510−}
511−_moveToShare ()
512−{
513−
514− if [ -n "$__structuredAttrs" ]; then
515− if [ -z "${forceShare-}" ]; then
516− forceShare=(man doc info);
517− fi;
518− else
519− forceShare=(${forceShare:-man doc info});
520− fi;
521− if [[ -z "$out" ]]; then
522− return;
523− fi;
524− for d in "${forceShare[@]}";
525− do
526− if [ -d "$out/$d" ]; then
527− if [ -d "$out/share/$d" ]; then
528− echo "both $d/ and share/$d/ exist!";
529− else
530− echo "moving $out/$d to $out/share/$d";
531− mkdir -p $out/share;
532− mv $out/$d $out/share/;
533− fi;
534− fi;
535− done
536−}
537−_multioutConfig ()
538−{
539−
540− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
541− return;
542− fi;
543− if [ -z "${shareDocName:-}" ]; then
544− local confScript="${configureScript:-}";
545− if [ -z "$confScript" ] && [ -x ./configure ]; then
546− confScript=./configure;
547− fi;
548− if [ -f "$confScript" ]; then
549− local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
550− fi;
551− if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
552− shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
553− fi;
554− fi;
555− prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
556− prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
557−}
558−_multioutDevs ()
559−{
560−
561− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
562− return;
563− fi;
564− moveToOutput include "${!outputInclude}";
565− moveToOutput lib/pkgconfig "${!outputDev}";
566− moveToOutput share/pkgconfig "${!outputDev}";
567− moveToOutput lib/cmake "${!outputDev}";
568− moveToOutput share/aclocal "${!outputDev}";
569− for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
570− do
571− echo "Patching '$f' includedir to output ${!outputInclude}";
572− sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
573− done
574−}
575−_multioutDocs ()
576−{
577−
578− local REMOVE=REMOVE;
579− moveToOutput share/info "${!outputInfo}";
580− moveToOutput share/doc "${!outputDoc}";
581− moveToOutput share/gtk-doc "${!outputDevdoc}";
582− moveToOutput share/devhelp/books "${!outputDevdoc}";
583− moveToOutput share/man "${!outputMan}";
584− moveToOutput share/man/man3 "${!outputDevman}"
585−}
586−_multioutPropagateDev ()
587−{
588−
589− if [ "$(getAllOutputNames)" = "out" ]; then
590− return;
591− fi;
592− local outputFirst;
593− for outputFirst in $(getAllOutputNames);
594− do
595− break;
596− done;
597− local propagaterOutput="$outputDev";
598− if [ -z "$propagaterOutput" ]; then
599− propagaterOutput="$outputFirst";
600− fi;
601− if [ -z "${propagatedBuildOutputs+1}" ]; then
602− local po_dirty="$outputBin $outputInclude $outputLib";
603− set +o pipefail;
604− propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
605− set -o pipefail;
606− fi;
607− if [ -z "$propagatedBuildOutputs" ]; then
608− return;
609− fi;
610− mkdir -p "${!propagaterOutput}"/nix-support;
611− for output in $propagatedBuildOutputs;
612− do
613− echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
614− done
615−}
616−_nixLogWithLevel ()
617−{
618−
619− [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
620− local logLevel;
621− case "${1:?}" in
622− 0)
623− logLevel=ERROR
624− ;;
625− 1)
626− logLevel=WARN
627− ;;
628− 2)
629− logLevel=NOTICE
630− ;;
631− 3)
632− logLevel=INFO
633− ;;
634− 4)
635− logLevel=TALKATIVE
636− ;;
637− 5)
638− logLevel=CHATTY
639− ;;
640− 6)
641− logLevel=DEBUG
642− ;;
643− 7)
644− logLevel=VOMIT
645− ;;
646− *)
647− echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
648− return 1
649− ;;
650− esac;
651− local callerName="${FUNCNAME[2]}";
652− if [[ $callerName == "_callImplicitHook" ]]; then
653− callerName="${hookName:?}";
654− fi;
655− printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
656−}
657−_overrideFirst ()
658−{
659−
660− if [ -z "${!1-}" ]; then
661− _assignFirst "$@";
662− fi
663−}
664−_pruneLibtoolFiles ()
665−{
666−
667− if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
668− return;
669− fi;
670− find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
671−}
672−_updateSourceDateEpochFromSourceRoot ()
673−{
674−
675− if [ -n "$sourceRoot" ]; then
676− updateSourceDateEpoch "$sourceRoot";
677− fi
678−}
679−activatePackage ()
680−{
681−
682− local pkg="$1";
683− local -r hostOffset="$2";
684− local -r targetOffset="$3";
685− (( hostOffset <= targetOffset )) || exit 1;
686− if [ -f "$pkg" ]; then
687− nixTalkativeLog "sourcing setup hook '$pkg'";
688− source "$pkg";
689− fi;
690− if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
691− addToSearchPath _PATH "$pkg/bin";
692− fi;
693− if (( hostOffset <= -1 )); then
694− addToSearchPath _XDG_DATA_DIRS "$pkg/share";
695− fi;
696− if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
697− addToSearchPath _HOST_PATH "$pkg/bin";
698− fi;
699− if [[ -f "$pkg/nix-support/setup-hook" ]]; then
700− nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
701− source "$pkg/nix-support/setup-hook";
702− fi
703−}
704−addEnvHooks ()
705−{
706−
707− local depHostOffset="$1";
708− shift;
709− local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
710− local pkgHookVar;
711− for pkgHookVar in "${!pkgHookVarsSlice}";
712− do
713− eval "${pkgHookVar}s"'+=("$@")';
714− done
715−}
716−addPkgToClassPath ()
717−{
718−
719− local jar;
720− for jar in $1/share/java/*.jar;
721− do
722− export CLASSPATH=''${CLASSPATH-}''${CLASSPATH:+:}''${jar};
723− done
724−}
725−addToSearchPath ()
726−{
727−
728− addToSearchPathWithCustomDelimiter ":" "$@"
729−}
730−addToSearchPathWithCustomDelimiter ()
731−{
732−
733− local delimiter="$1";
734− local varName="$2";
735− local dir="$3";
736− if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
737− export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
738− fi
739−}
740−appendToVar ()
741−{
742−
743− local -n nameref="$1";
744− local useArray type;
745− if [ -n "$__structuredAttrs" ]; then
746− useArray=true;
747− else
748− useArray=false;
749− fi;
750− if type=$(declare -p "$1" 2> /dev/null); then
751− case "${type#* }" in
752− -A*)
753− echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
754− return 1
755− ;;
756− -a*)
757− useArray=true
758− ;;
759− *)
760− useArray=false
761− ;;
762− esac;
763− fi;
764− shift;
765− if $useArray; then
766− nameref=(${nameref+"${nameref[@]}"} "$@");
767− else
768− nameref="${nameref-} $*";
769− fi
770−}
771−auditTmpdir ()
772−{
773−
774− local dir="$1";
775− [ -e "$dir" ] || return 0;
776− echo "checking for references to $TMPDIR/ in $dir...";
777− local tmpdir elf_fifo script_fifo;
778− tmpdir="$(mktemp -d)";
779− elf_fifo="$tmpdir/elf";
780− script_fifo="$tmpdir/script";
781− mkfifo "$elf_fifo" "$script_fifo";
782− ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
783− if isELF "$file"; then
784− printf '%s\0' "$file" 1>&3;
785− else
786− if isScript "$file"; then
787− filename=${file##*/};
788− dir=${file%/*};
789− if [ -e "$dir/.$filename-wrapped" ]; then
790− printf '%s\0' "$file" 1>&4;
791− fi;
792− fi;
793− fi;
794− done;
795− exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
796− if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
797− echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
798− exit 1
799− fi
800− ' _ < "$elf_fifo" ) & local pid_elf=$!;
801− local pid_script;
802− ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
803− if grep -q -F "$TMPDIR/" "$1"; then
804− echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
805− exit 1
806− fi
807− ' _ < "$script_fifo" ) & local pid_script=$!;
808− wait "$pid_elf" || {
809− echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
810− exit 1
811− };
812− wait "$pid_script" || {
813− echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
814− exit 1
815− };
816− rm -r "$tmpdir"
817−}
818−bintoolsWrapper_addLDVars ()
819−{
820−
821− local role_post;
822− getHostRoleEnvHook;
823− if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
824− export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
825− fi;
826− if [[ -d "$1/lib" ]]; then
827− local -a glob=($1/lib/lib*);
828− if [ "${#glob[*]}" -gt 0 ]; then
829− export NIX_LDFLAGS${role_post}+=" -L$1/lib";
830− fi;
831− fi
832−}
833−buildPhase ()
834−{
835−
836− runHook preBuild;
837− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
838− echo "no Makefile or custom buildPhase, doing nothing";
839− else
840− foundMakefile=1;
841− local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
842− concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
843− echoCmd 'build flags' "${flagsArray[@]}";
844− make ${makefile:+-f $makefile} "${flagsArray[@]}";
845− unset flagsArray;
846− fi;
847− runHook postBuild
848−}
849−ccWrapper_addCVars ()
850−{
851−
852− local role_post;
853− getHostRoleEnvHook;
854− local found=;
855− if [ -d "$1/include" ]; then
856− export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
857− found=1;
858− fi;
859− if [ -d "$1/Library/Frameworks" ]; then
860− export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
861− found=1;
862− fi;
863− if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
864− local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
865− export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
866− fi
867−}
868−checkPhase ()
869−{
870−
871− runHook preCheck;
872− if [[ -z "${foundMakefile:-}" ]]; then
873− echo "no Makefile or custom checkPhase, doing nothing";
874− runHook postCheck;
875− return;
876− fi;
877− if [[ -z "${checkTarget:-}" ]]; then
878− if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
879− checkTarget="check";
880− else
881− if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
882− checkTarget="test";
883− fi;
884− fi;
885− fi;
886− if [[ -z "${checkTarget:-}" ]]; then
887− echo "no check/test target in ${makefile:-Makefile}, doing nothing";
888− else
889− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
890− concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
891− echoCmd 'check flags' "${flagsArray[@]}";
892− make ${makefile:+-f $makefile} "${flagsArray[@]}";
893− unset flagsArray;
894− fi;
895− runHook postCheck
896−}
897−compressManPages ()
898−{
899−
900− local dir="$1";
901− if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
902− return;
903− fi;
904− echo "gzipping man pages under $dir/share/man/";
905− find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
906− find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
907− local target;
908− target="$(readlink -f "$f")";
909− if [ -f "$target".gz ]; then
910− ln -sf "$target".gz "$f".gz && rm "$f";
911− fi;
912− done
913−}
914−concatStringsSep ()
915−{
916−
917− local sep="$1";
918− local name="$2";
919− local type oldifs;
920− if type=$(declare -p "$name" 2> /dev/null); then
921− local -n nameref="$name";
922− case "${type#* }" in
923− -A*)
924− echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
925− return 1
926− ;;
927− -a*)
928− local IFS="$(printf '\036')"
929− ;;
930− *)
931− local IFS=" "
932− ;;
933− esac;
934− local ifs_separated="${nameref[*]}";
935− echo -n "${ifs_separated//"$IFS"/"$sep"}";
936− fi
937−}
938−concatTo ()
939−{
940−
941− local -;
942− set -o noglob;
943− local -n targetref="$1";
944− shift;
945− local arg default name type;
946− for arg in "$@";
947− do
948− IFS="=" read -r name default <<< "$arg";
949− local -n nameref="$name";
950− if [[ -z "${nameref[*]}" && -n "$default" ]]; then
951− targetref+=("$default");
952− else
953− if type=$(declare -p "$name" 2> /dev/null); then
954− case "${type#* }" in
955− -A*)
956− echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
957− return 1
958− ;;
959− -a*)
960− targetref+=("${nameref[@]}")
961− ;;
962− *)
963− if [[ "$name" = *"Array" ]]; then
964− nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
965− targetref+=(${nameref+"${nameref[@]}"});
966− else
967− targetref+=(${nameref-});
968− fi
969− ;;
970− esac;
971− fi;
972− fi;
973− done
974−}
975−configurePhase ()
976−{
977−
978− runHook preConfigure;
979− : "${configureScript=}";
980− if [[ -z "$configureScript" && -x ./configure ]]; then
981− configureScript=./configure;
982− fi;
983− if [ -z "${dontFixLibtool:-}" ]; then
984− export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
985− local i;
986− find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
987− echo "fixing libtool script $i";
988− fixLibtool "$i";
989− done;
990− CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
991− find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
992− rm -f "$CONFIGURE_MTIME_REFERENCE";
993− fi;
994− if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
995− local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
996− if [ "${prefixKeyOrDefault: -1}" = " " ]; then
997− prependToVar configureFlags "$prefix";
998− prependToVar configureFlags "${prefixKeyOrDefault::-1}";
999− else
1000− prependToVar configureFlags "$prefixKeyOrDefault$prefix";
1001− fi;
1002− fi;
1003− if [[ -f "$configureScript" ]]; then
1004− if [ -z "${dontAddDisableDepTrack:-}" ]; then
1005− if grep -q dependency-tracking "$configureScript"; then
1006− prependToVar configureFlags --disable-dependency-tracking;
1007− fi;
1008− fi;
1009− if [ -z "${dontDisableStatic:-}" ]; then
1010− if grep -q enable-static "$configureScript"; then
1011− prependToVar configureFlags --disable-static;
1012− fi;
1013− fi;
1014− if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
1015− patchShebangs --build "$configureScript";
1016− fi;
1017− fi;
1018− if [ -n "$configureScript" ]; then
1019− local -a flagsArray;
1020− concatTo flagsArray configureFlags configureFlagsArray;
1021− echoCmd 'configure flags' "${flagsArray[@]}";
1022− $configureScript "${flagsArray[@]}";
1023− unset flagsArray;
1024− else
1025− echo "no configure script, doing nothing";
1026− fi;
1027− runHook postConfigure
1028−}
1029−consumeEntire ()
1030−{
1031−
1032− if IFS='' read -r -d '' "$1"; then
1033− echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
1034− return 1;
1035− fi
1036−}
1037−definePhases ()
1038−{
1039−
1040− if [ -z "${phases[*]:-}" ]; then
1041− phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
1042− fi
1043−}
1044−distPhase ()
1045−{
1046−
1047− runHook preDist;
1048− local flagsArray=();
1049− concatTo flagsArray distFlags distFlagsArray distTarget=dist;
1050− echo 'dist flags: %q' "${flagsArray[@]}";
1051− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1052− if [ "${dontCopyDist:-0}" != 1 ]; then
1053− mkdir -p "$out/tarballs";
1054− cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
1055− fi;
1056− runHook postDist
1057−}
1058−dumpVars ()
1059−{
1060−
1061− if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
1062− local old_umask;
1063− old_umask=$(umask);
1064− umask 0077;
1065− export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
1066− umask "$old_umask";
1067− fi
1068−}
1069−echoCmd ()
1070−{
1071−
1072− printf "%s:" "$1";
1073− shift;
1074− printf ' %q' "$@";
1075− echo
1076−}
1077−exitHandler ()
1078−{
1079−
1080− exitCode="$?";
1081− set +e;
1082− if [ -n "${showBuildStats:-}" ]; then
1083− read -r -d '' -a buildTimes < <(times);
1084− echo "build times:";
1085− echo "user time for the shell ${buildTimes[0]}";
1086− echo "system time for the shell ${buildTimes[1]}";
1087− echo "user time for all child processes ${buildTimes[2]}";
1088− echo "system time for all child processes ${buildTimes[3]}";
1089− fi;
1090− if (( "$exitCode" != 0 )); then
1091− runHook failureHook;
1092− if [ -n "${succeedOnFailure:-}" ]; then
1093− echo "build failed with exit code $exitCode (ignored)";
1094− mkdir -p "$out/nix-support";
1095− printf "%s" "$exitCode" > "$out/nix-support/failed";
1096− exit 0;
1097− fi;
1098− else
1099− runHook exitHook;
1100− fi;
1101− return "$exitCode"
1102−}
1103−findInputs ()
1104−{
1105−
1106− local -r pkg="$1";
1107− local -r hostOffset="$2";
1108− local -r targetOffset="$3";
1109− (( hostOffset <= targetOffset )) || exit 1;
1110− local varVar="${pkgAccumVarVars[hostOffset + 1]}";
1111− local varRef="$varVar[$((targetOffset - hostOffset))]";
1112− local var="${!varRef}";
1113− unset -v varVar varRef;
1114− local varSlice="$var[*]";
1115− case " ${!varSlice-} " in
1116− *" $pkg "*)
1117− return 0
1118− ;;
1119− esac;
1120− unset -v varSlice;
1121− eval "$var"'+=("$pkg")';
1122− if ! [ -e "$pkg" ]; then
1123− echo "build input $pkg does not exist" 1>&2;
1124− exit 1;
1125− fi;
1126− function mapOffset ()
1127− {
1128− local -r inputOffset="$1";
1129− local -n outputOffset="$2";
1130− if (( inputOffset <= 0 )); then
1131− outputOffset=$((inputOffset + hostOffset));
1132− else
1133− outputOffset=$((inputOffset - 1 + targetOffset));
1134− fi
1135− };
1136− local relHostOffset;
1137− for relHostOffset in "${allPlatOffsets[@]}";
1138− do
1139− local files="${propagatedDepFilesVars[relHostOffset + 1]}";
1140− local hostOffsetNext;
1141− mapOffset "$relHostOffset" hostOffsetNext;
1142− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1143− local relTargetOffset;
1144− for relTargetOffset in "${allPlatOffsets[@]}";
1145− do
1146− (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
1147− local fileRef="${files}[$relTargetOffset - $relHostOffset]";
1148− local file="${!fileRef}";
1149− unset -v fileRef;
1150− local targetOffsetNext;
1151− mapOffset "$relTargetOffset" targetOffsetNext;
1152− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1153− [[ -f "$pkg/nix-support/$file" ]] || continue;
1154− local pkgNext;
1155− read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
1156− for pkgNext in $pkgNext;
1157− do
1158− findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
1159− done;
1160− done;
1161− done
1162−}
1163−fixLibtool ()
1164−{
1165−
1166− local search_path;
1167− for flag in $NIX_LDFLAGS;
1168− do
1169− case $flag in
1170− -L*)
1171− search_path+=" ${flag#-L}"
1172− ;;
1173− esac;
1174− done;
1175− sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
1176−}
1177−fixupPhase ()
1178−{
1179−
1180− local output;
1181− for output in $(getAllOutputNames);
1182− do
1183− if [ -e "${!output}" ]; then
1184− chmod -R u+w,u-s,g-s "${!output}";
1185− fi;
1186− done;
1187− runHook preFixup;
1188− local output;
1189− for output in $(getAllOutputNames);
1190− do
1191− prefix="${!output}" runHook fixupOutput;
1192− done;
1193− recordPropagatedDependencies;
1194− if [ -n "${setupHook:-}" ]; then
1195− mkdir -p "${!outputDev}/nix-support";
1196− substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
1197− fi;
1198− if [ -n "${setupHooks:-}" ]; then
1199− mkdir -p "${!outputDev}/nix-support";
1200− local hook;
1201− for hook in ${setupHooks[@]};
1202− do
1203− local content;
1204− consumeEntire content < "$hook";
1205− substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
1206− unset -v content;
1207− done;
1208− unset -v hook;
1209− fi;
1210− if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
1211− mkdir -p "${!outputBin}/nix-support";
1212− printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
1213− fi;
1214− runHook postFixup
1215−}
1216−genericBuild ()
1217−{
1218−
1219− export GZIP_NO_TIMESTAMPS=1;
1220− if [ -f "${buildCommandPath:-}" ]; then
1221− source "$buildCommandPath";
1222− return;
1223− fi;
1224− if [ -n "${buildCommand:-}" ]; then
1225− eval "$buildCommand";
1226− return;
1227− fi;
1228− definePhases;
1229− for curPhase in ${phases[*]};
1230− do
1231− runPhase "$curPhase";
1232− done
1233−}
1234−getAllOutputNames ()
1235−{
1236−
1237− if [ -n "$__structuredAttrs" ]; then
1238− echo "${!outputs[*]}";
1239− else
1240− echo "$outputs";
1241− fi
1242−}
1243−getHostRole ()
1244−{
1245−
1246− getRole "$hostOffset"
1247−}
1248−getHostRoleEnvHook ()
1249−{
1250−
1251− getRole "$depHostOffset"
1252−}
1253−getRole ()
1254−{
1255−
1256− case $1 in
1257− -1)
1258− role_post='_FOR_BUILD'
1259− ;;
1260− 0)
1261− role_post=''
1262− ;;
1263− 1)
1264− role_post='_FOR_TARGET'
1265− ;;
1266− *)
1267− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1268− return 1
1269− ;;
1270− esac
1271−}
1272−getTargetRole ()
1273−{
1274−
1275− getRole "$targetOffset"
1276−}
1277−getTargetRoleEnvHook ()
1278−{
1279−
1280− getRole "$depTargetOffset"
1281−}
1282−getTargetRoleWrapper ()
1283−{
1284−
1285− case $targetOffset in
1286− -1)
1287− export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
1288− ;;
1289− 0)
1290− export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
1291− ;;
1292− 1)
1293− export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
1294− ;;
1295− *)
1296− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1297− return 1
1298− ;;
1299− esac
1300−}
1301−gradle ()
1302−{
1303−
1304− local flagsArray=();
1305− concatTo flagsArray gradleFlags gradleFlagsArray;
1306− command gradle "${flagsArray[@]}" "$@"
1307−}
1308−gradleBuildPhase ()
1309−{
1310−
1311− runHook preBuild;
1312− gradle ${enableParallelBuilding:+--parallel} ${gradleBuildTask:-assemble};
1313− runHook postBuild
1314−}
1315−gradleCheckPhase ()
1316−{
1317−
1318− runHook preCheck;
1319− gradle ${enableParallelChecking:+--parallel} ${gradleCheckTask:-test};
1320− runHook postCheck
1321−}
1322−gradleConfigureHook ()
1323−{
1324−
1325− if [ -z "${GRADLE_USER_HOME-}" ]; then
1326− GRADLE_USER_HOME="$(mktemp -d)";
1327− fi;
1328− export GRADLE_USER_HOME;
1329− export TERM=dumb;
1330− gradleFlagsArray+=(--no-daemon -Dorg.gradle.console=plain --init-script "${gradleInitScript:-/nix/store/qyybvyq09wv36br9lfd8jac92fgwy8f2-init-build.gradle}");
1331− if [ -n "${MITM_CACHE_CA-}" ]; then
1332− if [ -z "${MITM_CACHE_KEYSTORE-}" ]; then
1333− MITM_CACHE_KEYSTORE="$MITM_CACHE_CERT_DIR/keystore";
1334− MITM_CACHE_KS_PWD="$(head -c10 /dev/random | base32)";
1335− echo y | /nix/store/vs2j17dr25ph3h1k4w1ngxnn8y5br1sa-openjdk-21.0.12+2/bin/keytool -importcert -file "$MITM_CACHE_CA" -alias alias -keystore "$MITM_CACHE_KEYSTORE" -storepass "$MITM_CACHE_KS_PWD";
1336− fi;
1337− gradleFlagsArray+=(-Dhttp.proxyHost="$MITM_CACHE_HOST" -Dhttp.proxyPort="$MITM_CACHE_PORT");
1338− gradleFlagsArray+=(-Dhttps.proxyHost="$MITM_CACHE_HOST" -Dhttps.proxyPort="$MITM_CACHE_PORT");
1339− gradleFlagsArray+=(-Djavax.net.ssl.trustStore="$MITM_CACHE_KEYSTORE" -Djavax.net.ssl.trustStorePassword="$MITM_CACHE_KS_PWD");
1340− else
1341− gradleFlagsArray+=(--offline);
1342− fi;
1343− if ! [[ -v enableParallelBuilding ]]; then
1344− enableParallelBuilding=1;
1345− fi;
1346− if ! [[ -v enableParallelChecking ]]; then
1347− enableParallelChecking=1;
1348− fi;
1349− if ! [[ -v enableParallelUpdating ]]; then
1350− enableParallelUpdating=1;
1351− fi
1352−}
1353−gradleUpdateScript ()
1354−{
1355−
1356− runHook preBuild;
1357− runHook preGradleUpdate;
1358− gradle ${enableParallelUpdating:+--parallel} ${gradleUpdateTask:-nixDownloadDeps};
1359− runHook postGradleUpdate
1360−}
1361−installCheckPhase ()
1362−{
1363−
1364− runHook preInstallCheck;
1365− if [[ -z "${foundMakefile:-}" ]]; then
1366− echo "no Makefile or custom installCheckPhase, doing nothing";
1367− else
1368− if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
1369− echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
1370− else
1371− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1372− concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
1373− echoCmd 'installcheck flags' "${flagsArray[@]}";
1374− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1375− unset flagsArray;
1376− fi;
1377− fi;
1378− runHook postInstallCheck
1379−}
1380−installPhase ()
1381−{
1382−
1383− runHook preInstall;
1384− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
1385− echo "no Makefile or custom installPhase, doing nothing";
1386− runHook postInstall;
1387− return;
1388− else
1389− foundMakefile=1;
1390− fi;
1391− if [ -n "$prefix" ]; then
1392− mkdir -p "$prefix";
1393− fi;
1394− local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1395− concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
1396− echoCmd 'install flags' "${flagsArray[@]}";
1397− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1398− unset flagsArray;
1399− runHook postInstall
1400−}
1401−isELF ()
1402−{
1403−
1404− local fn="$1";
1405− local fd;
1406− local magic;
1407− exec {fd}< "$fn";
1408− LANG=C read -r -n 4 -u "$fd" magic;
1409− exec {fd}>&-;
1410− if [ "$magic" = 'ELF' ]; then
1411− return 0;
1412− else
1413− return 1;
1414− fi
1415−}
1416−isMachO ()
1417−{
1418−
1419− local fn="$1";
1420− local fd;
1421− local magic;
1422− exec {fd}< "$fn";
1423− LANG=C read -r -n 4 -u "$fd" magic;
1424− exec {fd}>&-;
1425− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
1426− return 0;
1427− else
1428− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
1429− return 0;
1430− else
1431− if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
1432− return 0;
1433− else
1434− return 1;
1435− fi;
1436− fi;
1437− fi
1438−}
1439−isScript ()
1440−{
1441−
1442− local fn="$1";
1443− local fd;
1444− local magic;
1445− exec {fd}< "$fn";
1446− LANG=C read -r -n 2 -u "$fd" magic;
1447− exec {fd}>&-;
1448− if [[ "$magic" =~ \#! ]]; then
1449− return 0;
1450− else
1451− return 1;
1452− fi
1453−}
1454−mapOffset ()
1455−{
1456−
1457− local -r inputOffset="$1";
1458− local -n outputOffset="$2";
1459− if (( inputOffset <= 0 )); then
1460− outputOffset=$((inputOffset + hostOffset));
1461− else
1462− outputOffset=$((inputOffset - 1 + targetOffset));
1463− fi
1464−}
1465−mitmCacheConfigureHook ()
1466−{
1467−
1468− if [ -d "$mitmCache" ] && [ -z "$MITM_CACHE_CERT_DIR" ]; then
1469− MITM_CACHE_CERT_DIR="$(mktemp -d)";
1470− pushd "$MITM_CACHE_CERT_DIR";
1471− MITM_CACHE_CA="$MITM_CACHE_CERT_DIR/ca.cer";
1472− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl genrsa -out ca.key 2048;
1473− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 -out ca.cer -subj "/C=AL/ST=a/L=a/O=a/OU=a/CN=example.org";
1474− MITM_CACHE_HOST="127.0.0.1";
1475− MITM_CACHE_PORT="${mitmCachePort:-$(/nix/store/ibdb2kp2gkqqbicwxp663s39sd5824zg-python3.13-ephemeral-port-reserve-1.1.4/bin/ephemeral-port-reserve "$MITM_CACHE_HOST")}";
1476− MITM_CACHE_ADDRESS="$MITM_CACHE_HOST:$MITM_CACHE_PORT";
1477− export http_proxy="$MITM_CACHE_ADDRESS";
1478− export https_proxy="$MITM_CACHE_ADDRESS";
1479− export SSL_CERT_FILE="$MITM_CACHE_CA";
1480− export NIX_SSL_CERT_FILE="$MITM_CACHE_CA";
1481− mitm-cache -l"$MITM_CACHE_ADDRESS" replay "$mitmCache" > /dev/null 2> /dev/null & popd;
1482− fi
1483−}
1484−moveToOutput ()
1485−{
1486−
1487− local patt="$1";
1488− local dstOut="$2";
1489− local output;
1490− for output in $(getAllOutputNames);
1491− do
1492− if [ "${!output}" = "$dstOut" ]; then
1493− continue;
1494− fi;
1495− local srcPath;
1496− for srcPath in "${!output}"/$patt;
1497− do
1498− if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
1499− continue;
1500− fi;
1501− if [ "$dstOut" = REMOVE ]; then
1502− echo "Removing $srcPath";
1503− rm -r "$srcPath";
1504− else
1505− local dstPath="$dstOut${srcPath#${!output}}";
1506− echo "Moving $srcPath to $dstPath";
1507− if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
1508− rmdir "$srcPath" --ignore-fail-on-non-empty;
1509− if [ -d "$srcPath" ]; then
1510− mv -t "$dstPath" "$srcPath"/*;
1511− rmdir "$srcPath";
1512− fi;
1513− else
1514− mkdir -p "$(readlink -m "$dstPath/..")";
1515− mv "$srcPath" "$dstPath";
1516− fi;
1517− fi;
1518− local srcParent="$(readlink -m "$srcPath/..")";
1519− if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
1520− echo "Removing empty $srcParent/ and (possibly) its parents";
1521− rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
1522− fi;
1523− done;
1524− done
1525−}
1526−nixChattyLog ()
1527−{
1528−
1529− _nixLogWithLevel 5 "$*"
1530−}
1531−nixDebugLog ()
1532−{
1533−
1534− _nixLogWithLevel 6 "$*"
1535−}
1536−nixErrorLog ()
1537−{
1538−
1539− _nixLogWithLevel 0 "$*"
1540−}
1541−nixInfoLog ()
1542−{
1543−
1544− _nixLogWithLevel 3 "$*"
1545−}
1546−nixLog ()
1547−{
1548−
1549− [[ -z ${NIX_LOG_FD-} ]] && return 0;
1550− local callerName="${FUNCNAME[1]}";
1551− if [[ $callerName == "_callImplicitHook" ]]; then
1552− callerName="${hookName:?}";
1553− fi;
1554− printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
1555−}
1556−nixNoticeLog ()
1557−{
1558−
1559− _nixLogWithLevel 2 "$*"
1560−}
1561−nixTalkativeLog ()
1562−{
1563−
1564− _nixLogWithLevel 4 "$*"
1565−}
1566−nixVomitLog ()
1567−{
1568−
1569− _nixLogWithLevel 7 "$*"
1570−}
1571−nixWarnLog ()
1572−{
1573−
1574− _nixLogWithLevel 1 "$*"
1575−}
1576−noBrokenSymlinks ()
1577−{
1578−
1579− local -r output="${1:?}";
1580− local path;
1581− local pathParent;
1582− local symlinkTarget;
1583− local -i numDanglingSymlinks=0;
1584− local -i numReflexiveSymlinks=0;
1585− local -i numUnreadableSymlinks=0;
1586− if [[ ! -e $output ]]; then
1587− nixWarnLog "skipping non-existent output $output";
1588− return 0;
1589− fi;
1590− nixInfoLog "running on $output";
1591− while IFS= read -r -d '' path; do
1592− pathParent="$(dirname "$path")";
1593− if ! symlinkTarget="$(readlink "$path")"; then
1594− nixErrorLog "the symlink $path is unreadable";
1595− numUnreadableSymlinks+=1;
1596− continue;
1597− fi;
1598− if [[ $symlinkTarget == /* ]]; then
1599− nixInfoLog "symlink $path points to absolute target $symlinkTarget";
1600− else
1601− nixInfoLog "symlink $path points to relative target $symlinkTarget";
1602− symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
1603− fi;
1604− if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
1605− nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
1606− numDanglingSymlinks+=1;
1607− continue;
1608− fi;
1609− if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
1610− nixInfoLog "symlink $path points outside the Nix store; ignoring";
1611− continue;
1612− fi;
1613− if [[ $path == "$symlinkTarget" ]]; then
1614− nixErrorLog "the symlink $path is reflexive";
1615− numReflexiveSymlinks+=1;
1616− else
1617− if [[ ! -e $symlinkTarget ]]; then
1618− nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
1619− numDanglingSymlinks+=1;
1620− else
1621− nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
1622− fi;
1623− fi;
1624− done < <(find "$output" -type l -print0);
1625− if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
1626− nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
1627− exit 1;
1628− fi;
1629− return 0
1630−}
1631−noBrokenSymlinksInAllOutputs ()
1632−{
1633−
1634− if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
1635− for output in $(getAllOutputNames);
1636− do
1637− noBrokenSymlinks "${!output}";
1638− done;
1639− fi
1640−}
1641−patchELF ()
1642−{
1643−
1644− local dir="$1";
1645− [ -e "$dir" ] || return 0;
1646− echo "shrinking RPATHs of ELF executables and libraries in $dir";
1647− local i;
1648− while IFS= read -r -d '' i; do
1649− if [[ "$i" =~ .build-id ]]; then
1650− continue;
1651− fi;
1652− if ! isELF "$i"; then
1653− continue;
1654− fi;
1655− echo "shrinking $i";
1656− patchelf --shrink-rpath "$i" || true;
1657− done < <(find "$dir" -type f -print0)
1658−}
1659−patchPhase ()
1660−{
1661−
1662− runHook prePatch;
1663− local -a patchesArray;
1664− concatTo patchesArray patches;
1665− local -a flagsArray;
1666− concatTo flagsArray patchFlags=-p1;
1667− for i in "${patchesArray[@]}";
1668− do
1669− echo "applying patch $i";
1670− local uncompress=cat;
1671− case "$i" in
1672− *.gz)
1673− uncompress="gzip -d"
1674− ;;
1675− *.bz2)
1676− uncompress="bzip2 -d"
1677− ;;
1678− *.xz)
1679− uncompress="xz -d"
1680− ;;
1681− *.lzma)
1682− uncompress="lzma -d"
1683− ;;
1684− esac;
1685− $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
1686− done;
1687− runHook postPatch
1688−}
1689−patchShebangs ()
1690−{
1691−
1692− local pathName;
1693− local update=false;
1694− while [[ $# -gt 0 ]]; do
1695− case "$1" in
1696− --host)
1697− pathName=HOST_PATH;
1698− shift
1699− ;;
1700− --build)
1701− pathName=PATH;
1702− shift
1703− ;;
1704− --update)
1705− update=true;
1706− shift
1707− ;;
1708− --)
1709− shift;
1710− break
1711− ;;
1712− -* | --*)
1713− echo "Unknown option $1 supplied to patchShebangs" 1>&2;
1714− return 1
1715− ;;
1716− *)
1717− break
1718− ;;
1719− esac;
1720− done;
1721− echo "patching script interpreter paths in $@";
1722− local f;
1723− local oldPath;
1724− local newPath;
1725− local arg0;
1726− local args;
1727− local oldInterpreterLine;
1728− local newInterpreterLine;
1729− if [[ $# -eq 0 ]]; then
1730− echo "No arguments supplied to patchShebangs" 1>&2;
1731− return 0;
1732− fi;
1733− local f;
1734− while IFS= read -r -d '' f; do
1735− isScript "$f" || continue;
1736− read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
1737− read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
1738− if [[ -z "${pathName:-}" ]]; then
1739− if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
1740− pathName=HOST_PATH;
1741− else
1742− pathName=PATH;
1743− fi;
1744− fi;
1745− if [[ "$oldPath" == *"/bin/env" ]]; then
1746− if [[ $arg0 == "-S" ]]; then
1747− arg0=${args%% *};
1748− [[ "$args" == *" "* ]] && args=${args#* } || args=;
1749− newPath="$(PATH="${!pathName}" type -P "env" || true)";
1750− args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
1751− else
1752− if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
1753− echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
1754− exit 1;
1755− else
1756− newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
1757− fi;
1758− fi;
1759− else
1760− if [[ -z $oldPath ]]; then
1761− oldPath="/bin/sh";
1762− fi;
1763− newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
1764− args="$arg0 $args";
1765− fi;
1766− newInterpreterLine="$newPath $args";
1767− newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
1768− if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
1769− if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
1770− echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
1771− escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
1772− timestamp=$(stat --printf "%y" "$f");
1773− tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
1774− sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
1775− local restoreReadOnly;
1776− if [[ ! -w "$f" ]]; then
1777− chmod +w "$f";
1778− restoreReadOnly=true;
1779− fi;
1780− cat "$tmpFile" > "$f";
1781− rm "$tmpFile";
1782− if [[ -n "${restoreReadOnly:-}" ]]; then
1783− chmod -w "$f";
1784− fi;
1785− touch --date "$timestamp" "$f";
1786− fi;
1787− fi;
1788− done < <(find "$@" -type f -perm -0100 -print0)
1789−}
1790−patchShebangsAuto ()
1791−{
1792−
1793− if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
1794− if [[ "$output" != out && "$output" = "$outputDev" ]]; then
1795− patchShebangs --build "$prefix";
1796− else
1797− patchShebangs --host "$prefix";
1798− fi;
1799− fi
1800−}
1801−prependToVar ()
1802−{
1803−
1804− local -n nameref="$1";
1805− local useArray type;
1806− if [ -n "$__structuredAttrs" ]; then
1807− useArray=true;
1808− else
1809− useArray=false;
1810− fi;
1811− if type=$(declare -p "$1" 2> /dev/null); then
1812− case "${type#* }" in
1813− -A*)
1814− echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
1815− return 1
1816− ;;
1817− -a*)
1818− useArray=true
1819− ;;
1820− *)
1821− useArray=false
1822− ;;
1823− esac;
1824− fi;
1825− shift;
1826− if $useArray; then
1827− nameref=("$@" ${nameref+"${nameref[@]}"});
1828− else
1829− nameref="$* ${nameref-}";
1830− fi
1831−}
1832−printLines ()
1833−{
1834−
1835− (( "$#" > 0 )) || return 0;
1836− printf '%s\n' "$@"
1837−}
1838−printPhases ()
1839−{
1840−
1841− definePhases;
1842− local phase;
1843− for phase in ${phases[*]};
1844− do
1845− printf '%s\n' "$phase";
1846− done
1847−}
1848−printWords ()
1849−{
1850−
1851− (( "$#" > 0 )) || return 0;
1852− printf '%s ' "$@"
1853−}
1854−recordPropagatedDependencies ()
1855−{
1856−
1857− declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
1858− declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
1859− local propagatedInputsIndex;
1860− for propagatedInputsIndex in "${!flatVars[@]}";
1861− do
1862− local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
1863− local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
1864− [[ -n "${!propagatedInputsSlice}" ]] || continue;
1865− mkdir -p "${!outputDev}/nix-support";
1866− printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
1867− done
1868−}
1869−runHook ()
1870−{
1871−
1872− local hookName="$1";
1873− shift;
1874− local hooksSlice="${hookName%Hook}Hooks[@]";
1875− local hook;
1876− for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1877− do
1878− _logHook "$hookName" "$hook" "$@";
1879− _eval "$hook" "$@";
1880− done;
1881− return 0
1882−}
1883−runOneHook ()
1884−{
1885−
1886− local hookName="$1";
1887− shift;
1888− local hooksSlice="${hookName%Hook}Hooks[@]";
1889− local hook ret=1;
1890− for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1891− do
1892− _logHook "$hookName" "$hook" "$@";
1893− if _eval "$hook" "$@"; then
1894− ret=0;
1895− break;
1896− fi;
1897− done;
1898− return "$ret"
1899−}
1900−runPhase ()
1901−{
1902−
1903− local curPhase="$*";
1904− if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
1905− return;
1906− fi;
1907− if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
1908− return;
1909− fi;
1910− if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
1911− return;
1912− fi;
1913− if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
1914− return;
1915− fi;
1916− if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
1917− return;
1918− fi;
1919− if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
1920− return;
1921− fi;
1922− if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
1923− return;
1924− fi;
1925− if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
1926− return;
1927− fi;
1928− if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
1929− return;
1930− fi;
1931− showPhaseHeader "$curPhase";
1932− dumpVars;
1933− local startTime endTime;
1934− startTime=$(date +"%s");
1935− eval "${!curPhase:-$curPhase}";
1936− endTime=$(date +"%s");
1937− showPhaseFooter "$curPhase" "$startTime" "$endTime";
1938− if [ "$curPhase" = unpackPhase ]; then
1939− [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
1940− cd -- "${sourceRoot:-.}";
1941− fi
1942−}
1943−showPhaseFooter ()
1944−{
1945−
1946− local phase="$1";
1947− local startTime="$2";
1948− local endTime="$3";
1949− local delta=$(( endTime - startTime ));
1950− (( delta < 30 )) && return;
1951− local H=$((delta/3600));
1952− local M=$((delta%3600/60));
1953− local S=$((delta%60));
1954− echo -n "$phase completed in ";
1955− (( H > 0 )) && echo -n "$H hours ";
1956− (( M > 0 )) && echo -n "$M minutes ";
1957− echo "$S seconds"
1958−}
1959−showPhaseHeader ()
1960−{
1961−
1962− local phase="$1";
1963− echo "Running phase: $phase";
1964− if [[ -z ${NIX_LOG_FD-} ]]; then
1965− return;
1966− fi;
1967− printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
1968−}
1969−stripDirs ()
1970−{
1971−
1972− local cmd="$1";
1973− local ranlibCmd="$2";
1974− local paths="$3";
1975− local stripFlags="$4";
1976− local excludeFlags=();
1977− local pathsNew=;
1978− [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
1979− [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
1980− local pattern;
1981− if [ -n "${stripExclude:-}" ]; then
1982− for pattern in "${stripExclude[@]}";
1983− do
1984− excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
1985− done;
1986− fi;
1987− local p;
1988− for p in ${paths};
1989− do
1990− if [ -e "$prefix/$p" ]; then
1991− pathsNew="${pathsNew} $prefix/$p";
1992− fi;
1993− done;
1994− paths=${pathsNew};
1995− if [ -n "${paths}" ]; then
1996− echo "stripping (with command $cmd and flags $stripFlags) in $paths";
1997− local striperr;
1998− striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
1999− find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
2000− [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
2001− rm "$striperr";
2002− find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
2003− fi
2004−}
2005−stripHash ()
2006−{
2007−
2008− local strippedName casematchOpt=0;
2009− strippedName="$(basename -- "$1")";
2010− shopt -q nocasematch && casematchOpt=1;
2011− shopt -u nocasematch;
2012− if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
2013− echo "${strippedName:33}";
2014− else
2015− echo "$strippedName";
2016− fi;
2017− if (( casematchOpt )); then
2018− shopt -s nocasematch;
2019− fi
2020−}
2021−substitute ()
2022−{
2023−
2024− local input="$1";
2025− local output="$2";
2026− shift 2;
2027− if [ ! -f "$input" ]; then
2028− echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
2029− return 1;
2030− fi;
2031− local content;
2032− consumeEntire content < "$input";
2033− if [ -e "$output" ]; then
2034− chmod +w "$output";
2035− fi;
2036− substituteStream content "file '$input'" "$@" > "$output"
2037−}
2038−substituteAll ()
2039−{
2040−
2041− local input="$1";
2042− local output="$2";
2043− local -a args=();
2044− _allFlags;
2045− substitute "$input" "$output" "${args[@]}"
2046−}
2047−substituteAllInPlace ()
2048−{
2049−
2050− local fileName="$1";
2051− shift;
2052− substituteAll "$fileName" "$fileName" "$@"
2053−}
2054−substituteAllStream ()
2055−{
2056−
2057− local -a args=();
2058− _allFlags;
2059− substituteStream "$1" "$2" "${args[@]}"
2060−}
2061−substituteInPlace ()
2062−{
2063−
2064− local -a fileNames=();
2065− for arg in "$@";
2066− do
2067− if [[ "$arg" = "--"* ]]; then
2068− break;
2069− fi;
2070− fileNames+=("$arg");
2071− shift;
2072− done;
2073− if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
2074− echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
2075− return 1;
2076− fi;
2077− for file in "${fileNames[@]}";
2078− do
2079− substitute "$file" "$file" "$@";
2080− done
2081−}
2082−substituteStream ()
2083−{
2084−
2085− local var=$1;
2086− local description=$2;
2087− shift 2;
2088− while (( "$#" )); do
2089− local replace_mode="$1";
2090− case "$1" in
2091− --replace)
2092− if ! "$_substituteStream_has_warned_replace_deprecation"; then
2093− echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
2094− _substituteStream_has_warned_replace_deprecation=true;
2095− fi;
2096− replace_mode='--replace-warn'
2097− ;&
2098− --replace-quiet | --replace-warn | --replace-fail)
2099− pattern="$2";
2100− replacement="$3";
2101− shift 3;
2102− if ! [[ "${!var}" == *"$pattern"* ]]; then
2103− if [ "$replace_mode" == --replace-warn ]; then
2104− printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2105− else
2106− if [ "$replace_mode" == --replace-fail ]; then
2107− printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2108− return 1;
2109− fi;
2110− fi;
2111− fi;
2112− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2113− ;;
2114− --subst-var)
2115− local varName="$2";
2116− shift 2;
2117− if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
2118− echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
2119− return 1;
2120− fi;
2121− if [ -z ${!varName+x} ]; then
2122− echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
2123− return 1;
2124− fi;
2125− pattern="@$varName@";
2126− replacement="${!varName}";
2127− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2128− ;;
2129− --subst-var-by)
2130− pattern="@$2@";
2131− replacement="$3";
2132− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
2133− shift 3
2134− ;;
2135− *)
2136− echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
2137− return 1
2138− ;;
2139− esac;
2140− done;
2141− printf "%s" "${!var}"
2142−}
2143−unpackFile ()
2144−{
2145−
2146− curSrc="$1";
2147− echo "unpacking source archive $curSrc";
2148− if ! runOneHook unpackCmd "$curSrc"; then
2149− echo "do not know how to unpack source archive $curSrc";
2150− exit 1;
2151− fi
2152−}
2153−unpackPhase ()
2154−{
2155−
2156− runHook preUnpack;
2157− if [ -z "${srcs:-}" ]; then
2158− if [ -z "${src:-}" ]; then
2159− echo 'variable $src or $srcs should point to the source';
2160− exit 1;
2161− fi;
2162− srcs="$src";
2163− fi;
2164− local -a srcsArray;
2165− concatTo srcsArray srcs;
2166− local dirsBefore="";
2167− for i in *;
2168− do
2169− if [ -d "$i" ]; then
2170− dirsBefore="$dirsBefore $i ";
2171− fi;
2172− done;
2173− for i in "${srcsArray[@]}";
2174− do
2175− unpackFile "$i";
2176− done;
2177− : "${sourceRoot=}";
2178− if [ -n "${setSourceRoot:-}" ]; then
2179− runOneHook setSourceRoot;
2180− else
2181− if [ -z "$sourceRoot" ]; then
2182− for i in *;
2183− do
2184− if [ -d "$i" ]; then
2185− case $dirsBefore in
2186− *\ $i\ *)
2187−
2188− ;;
2189− *)
2190− if [ -n "$sourceRoot" ]; then
2191− echo "unpacker produced multiple directories";
2192− exit 1;
2193− fi;
2194− sourceRoot="$i"
2195− ;;
2196− esac;
2197− fi;
2198− done;
2199− fi;
2200− fi;
2201− if [ -z "$sourceRoot" ]; then
2202− echo "unpacker appears to have produced no directories";
2203− exit 1;
2204− fi;
2205− echo "source root is $sourceRoot";
2206− if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
2207− chmod -R u+w -- "$sourceRoot";
2208− fi;
2209− runHook postUnpack
2210−}
2211−updateAutotoolsGnuConfigScriptsPhase ()
2212−{
2213−
2214− if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
2215− return;
2216− fi;
2217− for script in config.sub config.guess;
2218− do
2219− for f in $(find . -type f -name "$script");
2220− do
2221− echo "Updating Autotools / GNU config script to a newer upstream version: $f";
2222− cp -f "/nix/store/4i7gvqwiq5mbl5pxh43frzq2nibxvwb5-gnu-config-2024-01-01/$script" "$f";
2223− done;
2224− done
2225−}
2226−updateSourceDateEpoch ()
2227−{
2228−
2229− local path="$1";
2230− [[ $path == -* ]] && path="./$path";
2231− local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
2232− local time="${res[0]//\.[0-9]*/}";
2233− local newestFile="${res[1]}";
2234− if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
2235− echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
2236− export SOURCE_DATE_EPOCH="$time";
2237− local now="$(date +%s)";
2238− if [ "$time" -gt $((now - 60)) ]; then
2239− echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
2240− fi;
2241− fi
2242−}
2243−PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
2244−XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
2245−export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
2246−export TMP="$NIX_BUILD_TOP"
2247−export TMPDIR="$NIX_BUILD_TOP"
2248−export TEMP="$NIX_BUILD_TOP"
2249−export TEMPDIR="$NIX_BUILD_TOP"
2250−eval "${shellHook:-}"
.github/workflows/build.yml+18−16
@@ -5,8 +5,26 @@ on:
5 branches: [master]
5 branches: [master]
6 pull_request:
6 pull_request:
7
7
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+
8jobs:
14jobs:
15+ # The Crux core is plain Rust — build and unit-test it natively (the Android
16+ # shell mirrors this logic; see README).
17+ core:
18+ name: Crux core
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - name: Test the Crux core
23+ working-directory: shared
24+ run: cargo test
25+
9 build:
26 build:
27+ name: Android app
10 runs-on: ubuntu-latest
28 runs-on: ubuntu-latest
11
29
12 steps:
30 steps:
@@ -24,31 +42,15 @@ jobs:
24 - name: Run unit tests
42 - name: Run unit tests
25 run: ./gradlew testDebugUnitTest --no-daemon
43 run: ./gradlew testDebugUnitTest --no-daemon
26
44
27− - name: Static analysis (detekt)
28− run: ./gradlew detekt --no-daemon
29−
30 - name: Lint
45 - name: Lint
31 run: ./gradlew lintDebug --no-daemon
46 run: ./gradlew lintDebug --no-daemon
32
47
33 - name: Build debug APK
48 - name: Build debug APK
34 run: ./gradlew assembleDebug --no-daemon
49 run: ./gradlew assembleDebug --no-daemon
35
50
36− - name: Build release APK
37− run: ./gradlew assembleRelease --no-daemon
38−
39 - name: Upload debug APK
51 - name: Upload debug APK
40 uses: actions/upload-artifact@v4
52 uses: actions/upload-artifact@v4
41 with:
53 with:
42 name: opencode-debug.apk
54 name: opencode-debug.apk
43 path: app/build/outputs/apk/debug/*.apk
55 path: app/build/outputs/apk/debug/*.apk
44 if-no-files-found: error
56 if-no-files-found: error
45−
46− # The release output dir is `release/` when unsigned but `release-signed/` (or a
47− # variant name) once a signing config is applied. Match both so CI keeps working
48− # once the OPENCODE_STORE_* secrets are wired up.
49− - name: Upload release APK
50− uses: actions/upload-artifact@v4
51− with:
52− name: opencode-release.apk
53− path: app/build/outputs/apk/**/*.apk
54− if-no-files-found: error
.github/workflows/release.ymlnew file+113
@@ -0,0 +1,113 @@
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+20
@@ -1,6 +1,8 @@
1# Gradle
1# Gradle
2.gradle/
2.gradle/
3build/
3build/
4+*.apk
5+*.aab
4
6
5# Kotlin
7# Kotlin
6.kotlin/
8.kotlin/
@@ -29,3 +31,21 @@ Thumbs.db
29
31
30# Nix
32# Nix
31result
33result
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.mdnew file+40
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+138−159
@@ -1,174 +1,153 @@
1# opencode-android
1# opencode-android
2
2
3−A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4−
4+`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5−Application id: `soy.iko.opencode`
5+and **Jetpack Compose**.
6−
6+
7−## Status
7+Connect to a running `opencode serve`, browse and create sessions, and chat with
8−
8+the agent — assistant replies (text, reasoning, and tool calls) stream in live
9−Milestones M0–M12 complete:
9+over the server's SSE event stream.
10−
10+
11−- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11+<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
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.
25−- **M7:** **UX polish pass.** Draft retention (failed sends restore your text with a Retry action);
26− destructive-action confirmations (delete session / remove server); the app bar now shows the real
27− session title; smart auto-scroll that won't fight a reader who scrolled up, with a jump-to-latest
28− FAB; long-press to copy assistant markdown, plus copy buttons on tool output and diffs; expandable
29− tool output (Show more / Show less) instead of a hard truncation; **session list** with relative
30− timestamps, last-message previews, and a search filter; tappable **file-browser breadcrumbs**;
31− a "connected" marker on the active server; permission dialog with clear action hierarchy;
32− dynamic "Thinking…" → "Thoughts" reasoning label; back-press guard while an agent is running;
33− polished empty states; richer **Settings** (Dynamic Color / Material You toggle, version info,
34− manage-servers link); haptic feedback; and a file viewer with line numbers, copy, and share.
35− The session list also supports **renaming** a session (`PATCH /session/:id`).
36−- **M8:** **Enter-to-send** (hardware-keyboard Enter sends, Shift+Enter for newline);
37− **per-session draft persistence** — typed text survives back-navigation and process
38− death; **message timestamps** (relative time on every bubble); **VCS status in the file
39− browser** — `GET /file/status` powers added/modified/deleted badges with `+N/−N` line
40− counts on tracked files; **connectivity-aware SSE reconnect** — a `ConnectivityManager`
41− callback triggers an immediate reconnect (skipping backoff) when the device regains
42− network; cleaner **token/cost formatting** (`1,234 in · 5,678 out`, precision-aware cost);
43− and **bounded preview loading** (concurrency-capped, cancelled on refresh) so the session
44− list never floods the server with parallel requests.
45−
46−- **M9:** **Live session list** — `session.updated` / `session.deleted` SSE events now
47− drive incremental list updates (new sessions, renames, deletions appear without a
48− manual refresh); **pull-to-refresh** on the session list; **keep-screen-on** while an
49− agent is running so long tasks aren't interrupted by screen-off; **chat loading
50− state** — a spinner replaces the empty-conversation flash before the first message
51− load; **reconnect button** when the connection is lost mid-chat; **live session
52− title** in the chat header (updates when the agent renames the session);
53− **connection-aware run indicator** — the spinner resets if the SSE stream drops
54− mid-run instead of spinning forever; **retry-on-transient-failure** for all read
55− endpoints (sessions, messages, files, providers); and **per-code-block copy
56− buttons** in rendered markdown.
57−
58−- **M10:** **Reliability & UX hardening pass.** Fixed a latent crash where a diff in a
59− tool output nested a second `LazyColumn` inside the message list (now a bounded
60− column); **inline image rendering** for `file` parts — attachments are decoded
61− (data URIs) or fetched with Basic auth via the already-present Coil dependency;
62− **connection banner on the session list** so a dropped SSE stream is visible before
63− the list goes stale; **unread activity badges** — background sessions that receive a
64− reply get a dot until opened; **conflated message stream** so per-token snapshots no
65− longer back-pressure the event reducer on long replies; **typed error reporting** —
66− network/timeout/HTTP-status failures are classified by concrete exception type (with
67− a unit-tested classifier) instead of string-matching class names; **swipe-to-delete**
68− on session cards; **conversation export/share** (Markdown via the system share
69− sheet); and an **auto-incrementing `versionCode`** derived from the git commit count
70− so each release is strictly newer.
71−
72−- **M11:** **Reliability, a11y & tooling pass.**
73− - **Completion notifications** — when a background session finishes a run it posts a
74− system notification; tapping it opens the conversation. POST_NOTIFICATIONS is
75− requested on Android 13+ and respected if denied.
76− - **Foreground SSE service** — while an agent is running a low-priority foreground
77− service holds process priority so Doze can't choke the long-lived `/event` stream
78− mid-run when the app is backgrounded.
79− - **Crash reporting + Diagnostics** — an uncaught-exception handler writes crash
80− reports (stack trace + device/app metadata) to app-private storage, surfaced in a
81− new Settings → Diagnostics screen where they can be viewed, shared, or cleared. No
82− hosted backend required.
83− - **Adaptive large-screen layout** — on wide screens (tablets / unfolded foldables,
84− ≥ 840dp) the session list and the open conversation show side by side; compact
85− widths keep the single-pane back stack.
86− - **Deep linking** — `opencode://session/{id}` opens a conversation from any app, and
87− notification taps / share-in reuse the same open-session path.
88− - **Accessibility** — every clickable row is announced as a button to TalkBack
89− (`role` semantics); all icon buttons carry content descriptions.
90− - **Crash bug fix** — `StateFlow.value` was read inside composition (server switch
91− wouldn't recompose the image context); now collected as state.
92− - **App icon** — an adaptive launcher icon was added (lint flagged its absence).
93− - **Tooling/CI:** Android **Lint** and **detekt** now run in CI (detekt with a
94− baseline so only new findings block); a **Renovate** config keeps dependencies
95− current (grouped, weekly, AndroidX majors held for review); the deprecated
96− `kotlinOptions` DSL was replaced; and the release-artifact upload matches the
97− signed-output path. Instrumented tests (androidTest) cover the crash logger and
98− app bootstrap.
99−
100−- **M12:** **Reliability, a11y & test-coverage pass.**
101− - **Diagnostics disk I/O off the main thread** — crash report reads in the
102− Diagnostics screen were moved from synchronous `remember` blocks (composition
103− / main thread) into coroutines with `Dispatchers.IO`, eliminating potential
104− jank on large reports.
105− - **Orphaned coroutine scope fix** — `DraftStore` no longer creates an
106− unscoped `CoroutineScope` that leaks; it now uses the app-scoped
107− `CoroutineScope` from `AppContainer`.
108− - **Predictive back** — `android:enableOnBackInvokedCallback="true"` added to
109− the manifest so the Android 14+ predictive back gesture integrates properly
110− with the app's `BackHandler` usage.
111− - **Crash report filename uniqueness** — crash report timestamps now include
112− milliseconds so two crashes within the same second don't overwrite each other.
113− - **Permission response retry** — `POST /session/:id/permissions/:permissionID`
114− now uses the same retry-with-backoff as other REST calls, so a transient
115− network failure no longer leaves a tool run stuck without automatic retry.
116− - **Accessibility: space-prefix text patterns** — six `" " + text` patterns
117− that screen readers would announce with extra spaces were replaced with
118− `Modifier.padding(start = ...)` on the Text composables.
119− - **Accessibility: unread badge** — the unread activity dot on session cards
120− now carries a `contentDescription` so TalkBack announces it.
121− - **Test coverage** — added `OpencodeApiClientTest` (3 tests covering the
122− permission-response retry path) and `DraftStoreTest` (instrumented, covering
123− set/get/remove/blank-clears round-trip).
124
12
125## Architecture
13## Architecture
126
14
127−- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
15+The app follows the Crux pattern: **all application logic lives in a pure,
128−- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
16+platform-agnostic core; the platform is a thin shell** that renders the core's
129− (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
17+view model, sends it events, and performs the side effects it requests.
130− discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
18+
131− can't break decoding.
19+```
132−- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
20+┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
133−- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
21+│ Crux core — the single source of truth │
134− `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
22+│ • Model / Event / Effect / ViewModel │
135− Compose collects with `collectAsStateWithLifecycle`.
23+│ • update(): connect+auth, sessions, chat, SSE stream application │
136−- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
24+│ • crux_http for HTTP effects; unit-tested with `cargo test` │
137−
25+└───────────────────────────────────────────────────────────────────────┘
138−Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
26+ ▲ mirrored, behaviour-for-behaviour
139−(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
27+┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
140−
28+│ Jetpack Compose shell │
141−## Build
29+│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
142−
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`.
46+
47+The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48+can be swapped for the compiled `.so` behind the same seam. The crate already
49+builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50+type-generation.
51+
52+## Project layout
53+
143−```bash
54+```
144−nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
55+flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
145−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
56+shared/ Crux core (Rust)
146−./gradlew assembleRelease # R8-minified + resource-shrunk release APK
57+ src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
147−./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
58+ src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
148−./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
59+ src/bin/codegen.rs Type-generation entry point (feature = "typegen")
149−./gradlew lintDebug # Android Lint
60+generated/ Crux-generated FFI-shared JVM types (from `codegen`)
150−./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
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
151```
66```
152
67
153−## Install & run
68+## Getting started
154
69
155−```bash
70+### Dev shell
156−adb install -r app/build/outputs/apk/debug/app-debug.apk
71+
157−adb shell am start -n soy.iko.opencode/.MainActivity
72+Everything you need is provided by the Nix flake:
158−adb logcat --pid=$(adb shell pidof soy.iko.opencode)
73+
74+```sh
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
159```
76```
160
77
161−## Connect to a server
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79+build-tools 35.0.0) on your `PATH`.
80+
81+### Build & test
82+
83+```sh
84+# Rust core — logic + streaming unit tests
85+cd shared && cargo test
162
86
163−On the dev host:
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
164
89
165−```bash
90+# Android unit tests + lint
166−opencode serve --hostname 0.0.0.0 --port 4096
91+./gradlew testDebugUnitTest lintDebug
167```
92```
168
93
169−In the app, add a server with base URL `http://<host-lan-ip>:4096`. If the host is reachable only
94+### Run against a server
170−over USB, use `adb reverse tcp:4096 tcp:4096` and point the app at `http://127.0.0.1:4096`.
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
171
152
172−**Auth** is optional: opencode has no auth by default. If the server runs with
153+Apache-2.0.
173−`OPENCODE_SERVER_PASSWORD=...`, enter username `opencode` (the default) and that password — the app
174−sends HTTP Basic auth. The app permits cleartext HTTP (opencode serves plain `http://`).
app/build.gradle.kts+44−93
@@ -1,21 +1,21 @@
1plugins {
1plugins {
2 alias(libs.plugins.android.application)
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.serialization)
5 alias(libs.plugins.kotlin.compose)
4 alias(libs.plugins.kotlin.compose)
6− alias(libs.plugins.detekt)
5+ alias(libs.plugins.kotlin.serialization)
7}
6}
8
7
9−/**
8+// Version is overridable from the release workflow via
10− * Monotonically increasing versionCode: the git commit count on HEAD, so each release
9+// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
11− * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
10+val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
12− * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
11+val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
13− */
12+
14−val gitCommitCount: Int = runCatching {
13+// A release keystore is supplied by CI (decoded from a secret) via env vars.
15− providers.exec {
14+// When it's absent — local builds, or CI before signing is configured — the
16− commandLine("git", "rev-list", "--count", "HEAD")
15+// release build falls back to the debug signing config so the APK is still
17− }.standardOutput.asText.get().trim().toInt()
16+// installable, rather than producing an uninstallable unsigned APK.
18−}.getOrDefault(1)
17+val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19
19
20android {
20android {
21 namespace = "soy.iko.opencode"
21 namespace = "soy.iko.opencode"
@@ -25,37 +25,35 @@ android {
25 applicationId = "soy.iko.opencode"
25 applicationId = "soy.iko.opencode"
26 minSdk = 26
26 minSdk = 26
27 targetSdk = 35
27 targetSdk = 35
28− // Monotonically increasing across builds: derived from the git commit count so
28+ versionCode = appVersionCode
29− // each release ships a strictly higher versionCode without manual bookkeeping
30− // (and never triggers a "downgrade" install block). Falls back to 1 when the
31− // .git history isn't available (e.g. a plain source export).
32− versionCode = gitCommitCount
33− versionName = "0.1.0"
29+ versionName = appVersionName
30+
34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
35 }
32 }
36
33
37− // Release builds are signed when keystore credentials are supplied via env vars
34+ signingConfigs {
38− // (so CI can publish an installable APK). When the env vars are absent, the
35+ if (hasReleaseKeystore) {
39− // release build falls back to unsigned — same behavior as before — so local
40− // builds keep working without any keystore setup.
41− val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
42− signingConfigs.create("release") {
36+ create("release") {
43− storeFile = file(path)
37+ storeFile = file(releaseStorePath!!)
44− storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
38+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
45− keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
39+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
46− keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
40+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41+ }
47 }
42 }
48 }
43 }
49
44
50 buildTypes {
45 buildTypes {
51 release {
46 release {
52 isMinifyEnabled = true
47 isMinifyEnabled = true
53− isShrinkResources = true
54 proguardFiles(
48 proguardFiles(
55 getDefaultProguardFile("proguard-android-optimize.txt"),
49 getDefaultProguardFile("proguard-android-optimize.txt"),
56 "proguard-rules.pro",
50 "proguard-rules.pro",
57 )
51 )
58− releaseSigning?.let { signingConfig = it }
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
59 }
57 }
60 }
58 }
61
59
@@ -64,89 +62,42 @@ android {
64 targetCompatibility = JavaVersion.VERSION_17
62 targetCompatibility = JavaVersion.VERSION_17
65 }
63 }
66
64
67− // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
65+ kotlinOptions {
68− // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
66+ jvmTarget = "17"
69− kotlin {
70− compilerOptions {
71− jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72− }
73 }
67 }
74
68
75 buildFeatures {
69 buildFeatures {
76 compose = true
70 compose = true
77 }
71 }
78
72
79− lint {
80− // Fail CI on real errors. Warnings are reported but don't break the build so
81− // minor/style issues don't gate development.
82− abortOnError = true
83− // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84− disable += setOf("GradleDependency", "AndroidGradlePluginVersion")
85− }
86−
87 packaging {
73 packaging {
88 resources {
74 resources {
89 excludes += "/META-INF/{AL2.0,LGPL2.1}"
75 excludes += "/META-INF/{AL2.0,LGPL2.1}"
90 }
76 }
91 }
77 }
92−
93− testOptions {
94− unitTests {
95− // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
96− // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
97− // against Android APIs don't need Robolectric.
98− isReturnDefaultValues = true
99− isIncludeAndroidResources = true
100− }
101− }
102}
78}
103
79
104dependencies {
80dependencies {
105 implementation(libs.androidx.core.ktx)
81 implementation(libs.androidx.core.ktx)
106− implementation(libs.androidx.core.splashscreen)
82+ implementation(libs.androidx.lifecycle.runtime.ktx)
107− implementation(libs.androidx.activity.compose)
108− implementation(libs.androidx.lifecycle.viewmodel.compose)
109 implementation(libs.androidx.lifecycle.runtime.compose)
83 implementation(libs.androidx.lifecycle.runtime.compose)
110− implementation(libs.androidx.navigation.compose)
84+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
111−
112− implementation(platform(libs.compose.bom))
113− implementation(libs.compose.ui)
114− implementation(libs.compose.ui.graphics)
85+ implementation(libs.androidx.lifecycle.viewmodel.compose)
115− implementation(libs.compose.ui.tooling.preview)
116− implementation(libs.compose.material3)
117− implementation(libs.compose.material.icons.extended)
86+ implementation(libs.androidx.activity.compose)
118− debugImplementation(libs.compose.ui.tooling)
119
87
120− implementation(libs.ktor.client.core)
88+ implementation(platform(libs.androidx.compose.bom))
121− implementation(libs.ktor.client.okhttp)
89+ implementation(libs.androidx.compose.ui)
122− implementation(libs.ktor.client.content.negotiation)
90+ implementation(libs.androidx.compose.ui.graphics)
123− implementation(libs.ktor.serialization.kotlinx.json)
91+ implementation(libs.androidx.compose.ui.tooling.preview)
124− implementation(libs.ktor.client.auth)
92+ implementation(libs.androidx.compose.material3)
125− implementation(libs.ktor.client.logging)
93+ implementation(libs.androidx.compose.material.icons.extended)
94+ debugImplementation(libs.androidx.compose.ui.tooling)
126
95
127− implementation(libs.kotlinx.serialization.json)
128 implementation(libs.kotlinx.coroutines.android)
96 implementation(libs.kotlinx.coroutines.android)
129−
130− implementation(libs.androidx.datastore.preferences)
131− implementation(libs.androidx.security.crypto)
97+ implementation(libs.kotlinx.serialization.json)
132− implementation(libs.coil.compose)
133− implementation(libs.markdown.renderer)
98+ implementation(libs.okhttp)
134− implementation(libs.markdown.renderer.m3)
99+ implementation(libs.okhttp.sse)
135
100
136 testImplementation(libs.junit)
101 testImplementation(libs.junit)
137 testImplementation(libs.kotlinx.coroutines.test)
102 testImplementation(libs.kotlinx.coroutines.test)
138− testImplementation(libs.ktor.client.mock)
139−
140− androidTestImplementation(libs.androidx.test.ext.junit)
141− androidTestImplementation(libs.androidx.test.runner)
142− androidTestImplementation(libs.androidx.test.core)
143− androidTestImplementation(libs.kotlinx.coroutines.test)
144−}
145−
146−detekt {
147− buildUponDefaultConfig = true
148− // Carry forward the current baseline so CI only fails on *new* findings;
149− // existing issues are grandfathered rather than blocking the migration.
150− baseline = file("detekt-baseline.xml")
151− config.setFrom("$rootDir/config/detekt/detekt.yml")
152}
103}
app/detekt-baseline.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" ?>
2−<SmellBaseline>
3− <ManuallySuppressedIssues/>
4− <CurrentIssues>
5− <ID>ComplexCondition:ChatScreen.kt$event.type == KeyEventType.KeyDown &amp;&amp; event.key == Key.Enter &amp;&amp; !event.isShiftPressed &amp;&amp; enabled &amp;&amp; value.isNotBlank()</ID>
6− <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, )</ID>
8− <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
9− <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10− <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
11− <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
12− <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
13− <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
14− </CurrentIssues>
15−</SmellBaseline>
app/proguard-rules.pro+13−27
@@ -1,30 +1,16 @@
1−# --- kotlinx.serialization ---
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−# The plugin generates serializers; keep them and the $$serializer companion members.
3−-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
2+-keepattributes *Annotation*, InnerClasses
4-dontnote kotlinx.serialization.**
3-dontnote kotlinx.serialization.**
5−-keepclassmembers class soy.iko.opencode.** {
6− *** Companion;
7−}
8−-keepclasseswithmembers class soy.iko.opencode.** {
9− kotlinx.serialization.KSerializer serializer(...);
10−}
11−# Keep @Serializable classes themselves so their generated serializers can reference them.
12−-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13−-keepclassmembers class soy.iko.opencode.** {
14− *** Companion;
15−}
16−# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
18
4
19−# --- Ktor / OkHttp (HTTP client + SSE) ---
5+-keepclassmembers class **$$serializer { *; }
20−-dontwarn io.ktor.**
6+-keepclasseswithmembers class * {
21−-dontwarn okhttp3.**
7+ @kotlinx.serialization.Serializable <methods>;
22−-dontwarn okio.**
8+}
23−-dontwarn org.slf4j.**
9+-if @kotlinx.serialization.Serializable class **
24−-keep class io.ktor.** { *; }
10+-keep, allowobfuscation, allowshrinking class <1>
25−-keep class okhttp3.** { *; }
26−-keep class okio.** { *; }
27
11
28−# --- androidx.security.crypto pulls in Tink, which references compile-only
12+# OkHttp / okio — platform-specific bits referenced reflectively.
29−# com.google.errorprone annotations. They aren't on the runtime classpath.
13+-dontwarn okhttp3.internal.platform.**
30−-dontwarn com.google.errorprone.annotations.**
14+-dontwarn org.conscrypt.**
15+-dontwarn org.bouncycastle.**
16+-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertNotNull
6−import org.junit.Assert.assertTrue
7−import org.junit.Test
8−import org.junit.runner.RunWith
9−
10−/**
11− * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12− * are initialized on startup, and the notification channels are created.
13− */
14−@RunWith(AndroidJUnit4::class)
15−class ApplicationBootstrapTest {
16−
17− @Test
18− fun containerIsInitialized() {
19− val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20− assertNotNull(app.container)
21− assertNotNull(app.container.settingsStore)
22− }
23−
24− @Test
25− fun notificationChannelsExist() {
26− if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27− val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28− // Touching the app instance runs Application.onCreate, which creates the channels.
29− ApplicationProvider.getApplicationContext<OpencodeApp>()
30− val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31− as android.app.NotificationManager
32− val ids = nm.notificationChannels.map { it.id }
33− assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34− assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35− }
36−}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktdeleted−67
@@ -1,67 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertNotNull
7−import org.junit.Assert.assertNull
8−import org.junit.Assert.assertTrue
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−/**
13− * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14− * real app-private filesystem: a simulated crash is written, then read back, listed, and
15− * deleted through the public API.
16− */
17−@RunWith(AndroidJUnit4::class)
18−class CrashLoggerTest {
19−
20− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21− private val logger = CrashLogger.get(context)
22−
23− @Test
24− fun writeReadDeleteRoundTrip() {
25− logger.clearAll()
26−
27− // Simulate an uncaught exception by invoking the report path directly.
28− val report = writeFakeReport()
29−
30− logger.refresh()
31− assertEquals(1, logger.reportCount())
32−
33− val content = logger.readReport(report)
34− assertNotNull(content)
35− assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36− assertTrue("report should contain device metadata", content.contains("Android:"))
37−
38− logger.deleteReport(report)
39− assertEquals(0, logger.reportCount())
40− assertNull(logger.readReport(report))
41− }
42−
43− @Test
44− fun clearAllRemovesEveryReport() {
45− writeFakeReport("a")
46− writeFakeReport("b")
47− logger.refresh()
48− assertTrue(logger.reportCount() >= 2)
49−
50− logger.clearAll()
51− assertEquals(0, logger.reportCount())
52− }
53−
54− private fun writeFakeReport(tag: String = "test"): String {
55− val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56− val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57− java.io.File(dir, name).writeText(
58− """
59− opencode-android crash report
60− Android: test
61− java.lang.IllegalStateException: boom $tag
62− at Crasher.crash(Crasher.kt:1)
63− """.trimIndent(),
64− )
65− return name
66− }
67−}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktdeleted−49
@@ -1,49 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import kotlinx.coroutines.CoroutineScope
6−import kotlinx.coroutines.Dispatchers
7−import kotlinx.coroutines.SupervisorJob
8−import org.junit.Assert.assertEquals
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−@RunWith(AndroidJUnit4::class)
13−class DraftStoreTest {
14−
15− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16−
17− @Test
18− fun setGetRemoveRoundTrip() {
19− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20− val store = DraftStore(context, scope)
21−
22− // Use a unique session ID so it doesn't collide with other tests.
23− val sid = "test-${System.nanoTime()}"
24−
25− store.set(sid, "hello world")
26− assertEquals("hello world", store.get(sid))
27−
28− // Overwrite
29− store.set(sid, "updated")
30− assertEquals("updated", store.get(sid))
31−
32− // Blank clears the entry
33− store.set(sid, " ")
34− assertEquals("", store.get(sid))
35−
36− // Explicit remove
37− store.set(sid, "text")
38− store.remove(sid)
39− assertEquals("", store.get(sid))
40−
41− scope.cancel()
42− }
43−
44− @Test
45− fun getReturnsEmptyForUnknownSession() {
46− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47− assertEquals("", store.get("never-set-${System.nanoTime()}"))
48− }
49−}
app/src/main/AndroidManifest.xml+7−35
@@ -1,54 +1,26 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3− xmlns:tools="http://schemas.android.com/tools">
4
3
5 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.INTERNET" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7− <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8− <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9− <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10
6
11 <application
7 <application
12− android:name=".OpencodeApp"
13− android:allowBackup="false"
8+ android:allowBackup="true"
14− android:dataExtractionRules="@xml/data_extraction_rules"
9+ android:icon="@android:drawable/sym_def_app_icon"
15− android:fullBackupContent="false"
16− android:icon="@mipmap/ic_launcher"
17 android:label="@string/app_name"
10 android:label="@string/app_name"
18− android:networkSecurityConfig="@xml/network_security_config"
19 android:supportsRtl="true"
11 android:supportsRtl="true"
20− android:theme="@style/Theme.OpencodeAndroid">
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
21
14
22 <activity
15 <activity
23 android:name=".MainActivity"
16 android:name=".MainActivity"
24 android:exported="true"
17 android:exported="true"
25− android:launchMode="singleTop"
18+ android:windowSoftInputMode="adjustResize"
26− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
27− android:enableOnBackInvokedCallback="true"
28− tools:targetApi="33"
29− android:theme="@style/Theme.OpencodeAndroid.Splash">
19+ android:theme="@style/Theme.OpencodeAndroid">
30 <intent-filter>
20 <intent-filter>
31 <action android:name="android.intent.action.MAIN" />
21 <action android:name="android.intent.action.MAIN" />
32 <category android:name="android.intent.category.LAUNCHER" />
22 <category android:name="android.intent.category.LAUNCHER" />
33 </intent-filter>
23 </intent-filter>
34− <intent-filter>
35− <action android:name="android.intent.action.SEND" />
36− <category android:name="android.intent.category.DEFAULT" />
37− <data android:mimeType="text/plain" />
38− </intent-filter>
39− <!-- Deep link to a session: opencode://session/{sessionId} -->
40− <intent-filter>
41− <action android:name="android.intent.action.VIEW" />
42− <category android:name="android.intent.category.DEFAULT" />
43− <category android:name="android.intent.category.BROWSABLE" />
44− <data android:scheme="opencode" android:host="session" />
45− </intent-filter>
46 </activity>
24 </activity>
47−
48− <service
49− android:name=".notification.RunForegroundService"
50− android:exported="false"
51− android:foregroundServiceType="dataSync" />
52 </application>
25 </application>
53−
54</manifest>
26</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import androidx.core.content.edit
6+import androidx.lifecycle.AndroidViewModel
7+import androidx.lifecycle.viewModelScope
8+import kotlinx.coroutines.flow.distinctUntilChangedBy
9+import kotlinx.coroutines.launch
10+import soy.iko.opencode.core.Event
11+import soy.iko.opencode.core.OpencodeCore
12+
13+/**
14+ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15+ * connection) survives configuration changes. It also persists the last server
16+ * URL / username so reconnecting is one tap.
17+ */
18+class CoreViewModel(app: Application) : AndroidViewModel(app) {
19+ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20+
21+ val core = OpencodeCore(viewModelScope)
22+
23+ init {
24+ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25+ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26+ // The password is stored in plain text in SharedPreferences — a deliberate
27+ // convenience trade-off for a local dev tool so reconnecting is one tap.
28+ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29+
30+ // On a fresh process after a configuration change, resume the SSE stream if
31+ // the core is already connected (no-op otherwise — it guards on `connected`).
32+ core.resumeStreamingIfConnected()
33+
34+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35+ // aren't already connected, kick off the connection attempt on launch. The core
36+ // handles an empty/invalid URL, so this only fires when there's something to try.
37+ core.view.value.let { restored ->
38+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39+ core.dispatch(Event.Connect)
40+ }
41+ }
42+
43+ // Persist connection details once a connection succeeds.
44+ viewModelScope.launch {
45+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46+ if (state.connected) {
47+ prefs.edit {
48+ putString(KEY_URL, state.serverUrl)
49+ putString(KEY_USER, state.username)
50+ putString(KEY_PASSWORD, state.password)
51+ }
52+ }
53+ }
54+ }
55+ }
56+
57+ override fun onCleared() {
58+ core.shutdown()
59+ }
60+
61+ private companion object {
62+ const val KEY_URL = "serverUrl"
63+ const val KEY_USER = "username"
64+ const val KEY_PASSWORD = "password"
65+ }
66+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+13−89
@@ -1,107 +1,31 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3−import android.Manifest
4−import android.content.Intent
5−import android.content.pm.PackageManager
6−import android.os.Build
7import android.os.Bundle
3import android.os.Bundle
8import androidx.activity.ComponentActivity
4import androidx.activity.ComponentActivity
5+import androidx.activity.SystemBarStyle
9import androidx.activity.compose.setContent
6import androidx.activity.compose.setContent
10import androidx.activity.enableEdgeToEdge
7import androidx.activity.enableEdgeToEdge
11−import androidx.activity.result.contract.ActivityResultContracts
12−import androidx.compose.foundation.isSystemInDarkTheme
13import androidx.compose.runtime.getValue
8import androidx.compose.runtime.getValue
14−import androidx.core.content.ContextCompat
15−import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
16import androidx.lifecycle.compose.collectAsStateWithLifecycle
9import androidx.lifecycle.compose.collectAsStateWithLifecycle
17−import soy.iko.opencode.data.repo.ThemeMode
10+import androidx.lifecycle.viewmodel.compose.viewModel
18−import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
11+import soy.iko.opencode.ui.OpencodeApp
19import soy.iko.opencode.ui.theme.OpencodeTheme
12import soy.iko.opencode.ui.theme.OpencodeTheme
20
13
21class MainActivity : ComponentActivity() {
14class MainActivity : ComponentActivity() {
22−
23− private val requestNotificationPermission = registerForActivityResult(
24− ActivityResultContracts.RequestPermission(),
25− ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
26−
27− private var shareIntentHandled = false
28−
29 override fun onCreate(savedInstanceState: Bundle?) {
15 override fun onCreate(savedInstanceState: Bundle?) {
30− installSplashScreen()
16+ enableEdgeToEdge(
17+ statusBarStyle = SystemBarStyle.auto(
18+ lightScrim = 0xFF0F1115.toInt(),
19+ darkScrim = 0xFF0F1115.toInt(),
20+ ),
21+ )
31 super.onCreate(savedInstanceState)
22 super.onCreate(savedInstanceState)
32− enableEdgeToEdge()
33− savedInstanceState?.let { shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false) }
34− val container = (application as OpencodeApp).container
35− handleIntent(intent)
36− if (savedInstanceState == null) maybeRequestNotificationPermission()
37 setContent {
23 setContent {
38− val themeMode by container.settingsStore.themeMode
39− .collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
40− val dynamicColor by container.settingsStore.dynamicColor
41− .collectAsStateWithLifecycle(initialValue = true)
42− val dark = when (themeMode) {
43− ThemeMode.SYSTEM -> isSystemInDarkTheme()
44− ThemeMode.LIGHT -> false
45− ThemeMode.DARK -> true
46− }
47− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
24+ OpencodeTheme {
48− OpencodeAppUi(container = container)
25+ val vm: CoreViewModel = viewModel()
49− }
26+ val state by vm.core.view.collectAsStateWithLifecycle()
50− }
27+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
51− }
52−
53− override fun onNewIntent(intent: Intent) {
54− super.onNewIntent(intent)
55− setIntent(intent)
56− shareIntentHandled = false
57− handleIntent(intent)
58− }
59−
60− /** Capture text shared from another app so it can be prefilled into a session draft,
61− * and session ids from notification taps / deep links so we can open them. */
62− private fun handleIntent(intent: Intent?) {
63− val container = (application as OpencodeApp).container
64− if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
65− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
66− ?.takeIf { it.isNotBlank() }
67− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
68− if (text != null) {
69− container.setPendingShare(text)
70− shareIntentHandled = true
71 }
28 }
72 }
29 }
73− // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
74− val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
75− ?.data
76− ?.takeIf { it.host == "session" }
77− ?.lastPathSegment
78− val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
79− // Validate: session ids are opaque server-generated identifiers. Reject anything
80− // with path separators or other traversal/control characters so a malicious deep
81− // link can't inject path components into the REST URL path.
82− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let { container.requestOpenSession(it) }
83− }
84−
85− companion object {
86− /** Intent extra carrying a session id to open (notifications / deep links). */
87− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
88−
89− /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
90− private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
91−
92− private const val KEY_SHARE_HANDLED = "shareIntentHandled"
93− }
94−
95− override fun onSaveInstanceState(outState: Bundle) {
96− super.onSaveInstanceState(outState)
97− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
98− }
99−
100− /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
101− private fun maybeRequestNotificationPermission() {
102− if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
103− val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
104− PackageManager.PERMISSION_GRANTED
105− if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
106 }
30 }
107}
31}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−21
@@ -1,21 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import soy.iko.opencode.data.repo.CrashLogger
5−import soy.iko.opencode.di.AppContainer
6−
7−class OpencodeApp : Application() {
8− lateinit var container: AppContainer
9− private set
10−
11− override fun onCreate() {
12− super.onCreate()
13− CrashLogger.get(this).install()
14− container = AppContainer(this)
15− }
16−
17− override fun onTerminate() {
18− super.onTerminate()
19− container.shutdown()
20− }
21−}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktnew file+75
@@ -0,0 +1,75 @@
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.ktnew file+668
@@ -0,0 +1,668 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
5+import kotlinx.coroutines.delay
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import java.util.Base64
11+
12+/**
13+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14+ *
15+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16+ * observable [view]. Effects are executed here in the shell: REST calls through
17+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18+ * server-sent event is fed back in and applied incrementally, so assistant
19+ * replies stream in token-by-token.
20+ *
21+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22+ * network I/O hops to a background thread inside the clients.
23+ */
24+class OpencodeCore(
25+ private val scope: CoroutineScope,
26+ private val http: HttpClient = HttpClient(),
27+) {
28+ // ── Model ────────────────────────────────────────────────────────────────
29+ private var serverUrl = ""
30+ private var username = ""
31+ private var password = ""
32+ private var authRequired = false
33+ private var connected = false
34+ private var loading = false
35+ private var error: String? = null
36+ private var screen = Screen.Connect
37+ private val sessions = mutableListOf<SessionView>()
38+ private var currentSessionId: String? = null
39+ private var currentSessionTitle = ""
40+ private val messages = mutableListOf<MsgState>()
41+ private var draft = ""
42+ private var generating = false
43+ private var sseConnected = true
44+ private var localSeq = 0L
45+
46+ private val _view = MutableStateFlow(snapshot())
47+ val view: StateFlow<UiState> = _view.asStateFlow()
48+
49+ private var sse: SseClient? = null
50+ private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
53+
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
77+
78+ // ── Event entry point ──────────────────────────────────────────────────────
79+ fun dispatch(event: Event) {
80+ when (event) {
81+ // Store the field verbatim while typing; normalizing here would fight
82+ // the user's edits (e.g. eat the "://" as they type it). The URL is
83+ // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89+
90+ Event.Connect -> {
91+ serverUrl = normalizeUrl(serverUrl)
92+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
98+ loading = true; error = null; emit()
99+ connectJob?.cancel()
100+ connectJob = scope.launch { probeHealth() }
101+ }
102+
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
107+ loadSessionsJob = scope.launch { loadSessions() }
108+ }
109+
110+ is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
116+ currentSessionId = event.id
117+ messages.clear()
118+ removedMessageIds.clear()
119+ screen = Screen.Chat
120+ loading = true
121+ generating = false
122+ error = null
123+ emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
130+ }
131+
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
136+ createSessionJob = scope.launch { createSession() }
137+ }
138+
139+ is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
141+ val id = event.id
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
150+ sessions.removeAll { it.id == id }
151+ if (isCurrent(id)) {
152+ currentSessionId = null
153+ messages.clear()
154+ screen = Screen.Sessions
155+ }
156+ } else {
157+ error = "Failed to delete session"
158+ }
159+ loading = false
160+ },
161+ onFailure = {
162+ error = "Failed to delete session"
163+ loading = false
164+ },
165+ )
166+ emit()
167+ }
168+ }
169+
170+ is Event.DraftChanged -> { draft = event.text; emit() }
171+
172+ is Event.SendMessage -> sendMessage(event.text)
173+
174+ Event.CancelGeneration -> {
175+ generating = false
176+ messages.forEach { it.streaming = false }
177+ emit()
178+ val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
181+ abortJob?.cancel()
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183+ }
184+
185+ Event.NavigateToSessions -> {
186+ screen = Screen.Sessions
187+ currentSessionId = null
188+ messages.clear()
189+ removedMessageIds.clear()
190+ draft = ""
191+ generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
195+ loading = true
196+ emit()
197+ connectJob?.cancel()
198+ abortJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
204+ loadSessionsJob = scope.launch { loadSessions() }
205+ }
206+
207+ Event.NavigateToConnect -> {
208+ screen = Screen.Connect
209+ connected = false
210+ loading = false
211+ generating = false
212+ sessions.clear()
213+ messages.clear()
214+ removedMessageIds.clear()
215+ currentSessionId = null
216+ draft = ""
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
219+ sseConnected = true
220+ connectJob?.cancel()
221+ abortJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
226+ closeSse()
227+ emit()
228+ }
229+
230+ Event.DismissError -> { error = null; emit() }
231+ }
232+ }
233+
234+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235+ fun resumeStreamingIfConnected() {
236+ if (connected && sse == null) startSse()
237+ }
238+
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
248+ abortJob?.cancel()
249+ closeSse()
250+ }
251+
252+ // ── Effects ────────────────────────────────────────────────────────────────
253+ private fun sendMessage(raw: String) {
254+ val text = raw.trim()
255+ val session = currentSessionId ?: return
256+ if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
260+ localSeq += 1
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262+ status = MessageStatus.Pending
263+ textParts["local"] = text
264+ }
265+ messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
269+ draft = ""
270+ generating = true
271+ error = null
272+ emit()
273+
274+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
276+ sendMessageJob = scope.launch {
277+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278+ result.fold(
279+ onSuccess = { resp ->
280+ if (resp.code in 200..299) {
281+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282+ } else {
283+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284+ generating = false
285+ messages.forEach { it.streaming = false }
286+ draft = savedDraft
287+ error = "Server returned status ${resp.code}"
288+ }
289+ },
290+ onFailure = { e ->
291+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292+ generating = false
293+ messages.forEach { it.streaming = false }
294+ draft = savedDraft
295+ error = "Request failed: ${e.message}"
296+ },
297+ )
298+ emit()
299+ }
300+ }
301+
302+ private suspend fun probeHealth() {
303+ val hasCreds = hasCredentials()
304+ http.get("$serverUrl/global/health", authHeader()).fold(
305+ onSuccess = { resp ->
306+ when {
307+ resp.code == 401 -> {
308+ loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
312+ connected = false
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
315+ authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
317+ emit()
318+ }
319+ resp.code in 200..299 -> {
320+ connected = true
321+ authRequired = false
322+ error = null
323+ screen = Screen.Sessions
324+ loading = true
325+ emit()
326+ startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
330+ loadSessionsJob = scope.launch { loadSessions() }
331+ }
332+ else -> {
333+ loading = false
334+ connected = false
335+ error = "Server returned status ${resp.code}"
336+ emit()
337+ }
338+ }
339+ },
340+ onFailure = { e ->
341+ loading = false
342+ connected = false
343+ error = "Connection failed: ${e.message}"
344+ emit()
345+ },
346+ )
347+ }
348+
349+ private suspend fun loadSessions() {
350+ http.get("$serverUrl/session", authHeader()).fold(
351+ onSuccess = { resp ->
352+ if (resp.code in 200..299) {
353+ sessions.clear()
354+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
358+ } else {
359+ error = "Server returned status ${resp.code}"
360+ }
361+ },
362+ onFailure = { e -> error = "Request failed: ${e.message}" },
363+ )
364+ loading = false
365+ emit()
366+ }
367+
368+ private suspend fun createSession() {
369+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370+ onSuccess = { resp ->
371+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372+ if (session != null) {
373+ if (sessions.none { it.id == session.id }) {
374+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
379+ }
380+ emit()
381+ // SelectSession takes over the loading state (loads messages).
382+ dispatch(Event.SelectSession(session.id))
383+ return
384+ }
385+ error = "Server returned status ${resp.code}"
386+ },
387+ onFailure = { e -> error = "Request failed: ${e.message}" },
388+ )
389+ loading = false
390+ emit()
391+ }
392+
393+ private suspend fun loadMessages(sessionId: String) {
394+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395+ onSuccess = { resp ->
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397+ loading = false
398+ if (resp.code in 200..299) {
399+ messages.clear()
400+ removedMessageIds.clear()
401+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402+ generating = messages.any { it.streaming }
403+ } else {
404+ error = "Server returned status ${resp.code}"
405+ }
406+ emit()
407+ },
408+ onFailure = { e ->
409+ if (!isCurrent(sessionId)) { loading = false; return }
410+ loading = false
411+ generating = false
412+ error = "Request failed: ${e.message}"
413+ emit()
414+ },
415+ )
416+ }
417+
418+ // ── SSE ─────────────────────────────────────────────────────────────────────
419+ private fun startSse() {
420+ closeSse()
421+ sseClosedIntentionally = false
422+ val client = SseClient(
423+ url = "$serverUrl/event",
424+ auth = authHeader(),
425+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426+ onStateChange = { open ->
427+ scope.launch {
428+ sseConnected = open
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
430+ emit()
431+ }
432+ },
433+ )
434+ sse = client
435+ client.connect()
436+ }
437+
438+ private fun closeSse() {
439+ sseClosedIntentionally = true
440+ reconnectJob?.cancel()
441+ reconnectJob = null
442+ sse?.close()
443+ sse = null
444+ }
445+
446+ private fun scheduleReconnect() {
447+ if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
455+ reconnectJob = scope.launch {
456+ delay(delayMs)
457+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458+ }
459+ }
460+
461+ private fun handleServerEvent(json: String) {
462+ val event = Protocol.parseEvent(json) ?: return
463+ // Only re-render if the event actually changed our state; applyEvent returns
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
466+ }
467+
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
469+ private fun applyEvent(event: Protocol.Event): Boolean {
470+ when (event) {
471+ is Protocol.Event.MessageUpdated -> {
472+ val info = event.info
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
477+ val streaming = info.time.completed == null && info.error == null
478+ val existing = messages.firstOrNull { it.id == info.id }
479+ if (existing != null) {
480+ existing.streaming = streaming
481+ if (existing.time == 0L) existing.time = info.time.created
482+ } else {
483+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484+ }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486+ return true
487+ }
488+
489+ is Protocol.Event.PartUpdated -> {
490+ val part = event.part
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
494+ return when (part) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
496+ false
497+ } else {
498+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499+ true
500+ }
501+ is Protocol.Part.Reasoning -> {
502+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503+ true
504+ }
505+ is Protocol.Part.Tool -> {
506+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507+ true
508+ }
509+ }
510+ }
511+
512+ is Protocol.Event.PartRemoved -> {
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
515+ return true
516+ }
517+
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
521+ return messages.removeAll { it.id == event.messageId }
522+ }
523+
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
526+ generating = false
527+ messages.forEach { it.streaming = false }
528+ return true
529+ }
530+
531+ is Protocol.Event.SessionError -> {
532+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533+ if (!scoped) return false
534+ event.error?.let { error = it.message() }
535+ generating = false
536+ messages.forEach { it.streaming = false }
537+ return true
538+ }
539+
540+ is Protocol.Event.SessionUpserted -> {
541+ val title = displayTitle(event.info.title)
542+ val existing = sessions.firstOrNull { it.id == event.info.id }
543+ if (existing != null) {
544+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545+ } else {
546+ sessions.add(0, SessionView(event.info.id, title))
547+ }
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
551+ if (isCurrent(event.info.id)) currentSessionTitle = title
552+ return true
553+ }
554+
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
557+ val removed = sessions.removeAll { it.id == id }
558+ if (isCurrent(id)) {
559+ currentSessionId = null
560+ messages.clear()
561+ screen = Screen.Sessions
562+ return true
563+ }
564+ return removed
565+ }
566+
567+ Protocol.Event.Other -> return false
568+ }
569+ }
570+
571+ // ── View / helpers ──────────────────────────────────────────────────────────
572+ private fun snapshot() = UiState(
573+ screen = screen,
574+ serverUrl = serverUrl,
575+ username = username,
576+ password = password,
577+ authRequired = authRequired,
578+ connected = connected,
579+ loading = loading,
580+ error = error,
581+ sessions = sessions.toList(),
582+ currentSessionId = currentSessionId,
583+ currentSessionTitle = currentSessionTitle,
584+ messages = messages.map { it.toView() },
585+ draft = draft,
586+ generating = generating,
587+ sseConnected = sseConnected,
588+ )
589+
590+ private fun emit() { _view.value = snapshot() }
591+
592+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594+
595+ private fun authHeader(): String? {
596+ if (!hasCredentials()) return null
597+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598+ return "Basic $encoded"
599+ }
600+}
601+
602+// ── Internal message state (parts tracked by id for streaming) ─────────────────
603+private class MsgState(val id: String, val role: String, var time: Long) {
604+ var status = MessageStatus.Sent
605+ var streaming = false
606+ val textParts = LinkedHashMap<String, String>()
607+ val reasoningParts = LinkedHashMap<String, String>()
608+ val toolParts = LinkedHashMap<String, ToolView>()
609+
610+ fun remove(partId: String) {
611+ textParts.remove(partId)
612+ reasoningParts.remove(partId)
613+ toolParts.remove(partId)
614+ }
615+
616+ fun toView(): MessageView {
617+ val reasoning = reasoningParts.values.joinToString("")
618+ return MessageView(
619+ id = id,
620+ role = role,
621+ text = textParts.values.joinToString(""),
622+ reasoning = reasoning.ifEmpty { null },
623+ tools = toolParts.values.toList(),
624+ time = time,
625+ status = status,
626+ streaming = streaming,
627+ )
628+ }
629+}
630+
631+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632+ val info = env.info
633+ val msg = MsgState(info.id, info.role, info.time.created)
634+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635+ for (raw in env.parts) {
636+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637+ when (val part = Protocol.parsePart(obj)) {
638+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641+ null -> Unit
642+ }
643+ }
644+ return msg
645+}
646+
647+private fun pickText(text: String, delta: String?, prior: String?): String =
648+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649+
650+private fun normalizeUrl(url: String): String {
651+ val trimmed = url.trim().trimEnd('/')
652+ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653+ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654+ return when {
655+ trimmed.isEmpty() -> ""
656+ "://" in trimmed -> trimmed
657+ else -> "http://$trimmed"
658+ }
659+}
660+
661+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662+
663+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664+@kotlinx.serialization.Serializable
665+private data class PromptBody(val parts: List<TextInput>)
666+
667+@kotlinx.serialization.Serializable
668+private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktnew file+148
@@ -0,0 +1,148 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.Json
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+import kotlinx.serialization.json.booleanOrNull
10+import kotlinx.serialization.json.contentOrNull
11+import kotlinx.serialization.json.decodeFromJsonElement
12+import kotlinx.serialization.json.jsonObject
13+
14+/**
15+ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16+ *
17+ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18+ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19+ * catch-all so new server versions never break the stream parser.
20+ */
21+object Protocol {
22+ val json = Json {
23+ ignoreUnknownKeys = true
24+ isLenient = true
25+ coerceInputValues = true
26+ }
27+
28+ // ─── Sessions ────────────────────────────────────────────────────────────
29+
30+ @Serializable
31+ data class WireSession(val id: String, val title: String = "")
32+
33+ fun parseSessions(body: String): List<WireSession> =
34+ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35+
36+ fun parseSession(body: String): WireSession? =
37+ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38+
39+ // ─── Messages ────────────────────────────────────────────────────────────
40+
41+ @Serializable
42+ data class WireTime(val created: Long = 0, val completed: Long? = null)
43+
44+ @Serializable
45+ data class WireError(val name: String = "", val data: JsonElement? = null) {
46+ fun message(): String {
47+ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48+ return when {
49+ !msg.isNullOrEmpty() -> msg
50+ name.isNotEmpty() -> name
51+ else -> "Unknown error"
52+ }
53+ }
54+ }
55+
56+ @Serializable
57+ data class WireMessageInfo(
58+ val id: String,
59+ val role: String = "",
60+ @SerialName("sessionID") val sessionId: String = "",
61+ val time: WireTime = WireTime(),
62+ val error: WireError? = null,
63+ )
64+
65+ @Serializable
66+ data class WireMessageEnvelope(
67+ val info: WireMessageInfo,
68+ val parts: List<JsonElement> = emptyList(),
69+ )
70+
71+ fun parseMessages(body: String): List<WireMessageEnvelope> =
72+ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73+
74+ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75+
76+ sealed interface Part {
77+ val id: String
78+ val messageId: String
79+
80+ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81+ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82+ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83+ }
84+
85+ /** Parse a single part JSON object; returns null for kinds we don't render. */
86+ fun parsePart(obj: JsonObject): Part? {
87+ val id = obj.str("id")
88+ val messageId = obj.str("messageID")
89+ return when (obj.str("type")) {
90+ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91+ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92+ "tool" -> {
93+ val state = obj["state"] as? JsonObject
94+ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95+ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96+ Part.Tool(id, messageId, obj.str("tool"), status, title)
97+ }
98+ else -> null
99+ }
100+ }
101+
102+ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103+
104+ sealed interface Event {
105+ data class MessageUpdated(val info: WireMessageInfo) : Event
106+ data class PartUpdated(val part: Part, val delta: String?) : Event
107+ data class PartRemoved(val messageId: String, val partId: String) : Event
108+ data class MessageRemoved(val messageId: String) : Event
109+ data class SessionIdle(val sessionId: String) : Event
110+ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111+ data class SessionUpserted(val info: WireSession) : Event
112+ data class SessionDeleted(val sessionId: String?) : Event
113+ data object Other : Event
114+ }
115+
116+ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117+ fun parseEvent(raw: String): Event? {
118+ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119+ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120+ return when (root.str("type")) {
121+ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122+ "message.part.updated" -> {
123+ val partObj = props["part"] as? JsonObject ?: return Event.Other
124+ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125+ }
126+ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127+ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128+ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129+ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130+ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131+ "session.deleted" -> Event.SessionDeleted(
132+ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133+ )
134+ else -> Event.Other
135+ }
136+ }
137+
138+ private inline fun <reified T> decode(el: JsonElement?): T? =
139+ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140+
141+ // ─── Small JSON helpers ───────────────────────────────────────────────────
142+
143+ private fun JsonObject.str(key: String): String =
144+ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145+
146+ private fun JsonObject.bool(key: String): Boolean =
147+ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+63
@@ -0,0 +1,63 @@
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.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5+ * the two implementations stay behaviourally identical.
6+ */
7+
8+enum class Screen { Connect, Sessions, Chat }
9+
10+enum class MessageStatus { Sent, Pending, Failed }
11+
12+data class SessionView(
13+ val id: String,
14+ val title: String,
15+)
16+
17+data class ToolView(
18+ val name: String,
19+ val status: String,
20+ val title: String?,
21+)
22+
23+data class MessageView(
24+ val id: String,
25+ val role: String,
26+ val text: String,
27+ val reasoning: String?,
28+ val tools: List<ToolView>,
29+ val time: Long,
30+ val status: MessageStatus,
31+ val streaming: Boolean,
32+) {
33+ val isUser: Boolean get() = role == "user"
34+}
35+
36+data class UiState(
37+ val screen: Screen = Screen.Connect,
38+ val serverUrl: String = "",
39+ val username: String = "",
40+ val password: String = "",
41+ val authRequired: Boolean = false,
42+ val connected: Boolean = false,
43+ val loading: Boolean = false,
44+ val error: String? = null,
45+ val sessions: List<SessionView> = emptyList(),
46+ val currentSessionId: String? = null,
47+ val currentSessionTitle: String = "",
48+ val messages: List<MessageView> = emptyList(),
49+ val draft: String = "",
50+ val generating: Boolean = false,
51+ val sseConnected: Boolean = true,
52+)
53+
54+/**
55+ * Everything the shell can ask the core to do. Internal HTTP results are not
56+ * events here (unlike the Rust core, which routes them through `then_send`);
57+ * the Kotlin driver awaits them inline in coroutines instead.
58+ */
59+sealed interface Event {
60+ data class ServerUrlChanged(val url: String) : Event
61+ data class UsernameChanged(val value: String) : Event
62+ data class PasswordChanged(val value: String) : Event
63+ data object Connect : Event
64+
65+ data object LoadSessions : Event
66+ data class SelectSession(val id: String) : Event
67+ data object CreateSession : Event
68+ data class DeleteSession(val id: String) : Event
69+
70+ data class DraftChanged(val text: String) : Event
71+ data class SendMessage(val text: String) : Event
72+ data object CancelGeneration : Event
73+
74+ data object NavigateToSessions : Event
75+ data object NavigateToConnect : Event
76+ data object DismissError : Event
77+}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktdeleted−29
@@ -1,29 +0,0 @@
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.ktdeleted−109
@@ -1,109 +0,0 @@
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.ktdeleted−24
@@ -1,24 +0,0 @@
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.ktdeleted−34
@@ -1,34 +0,0 @@
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.ktdeleted−60
@@ -1,60 +0,0 @@
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.ktdeleted−91
@@ -1,91 +0,0 @@
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.ktdeleted−43
@@ -1,43 +0,0 @@
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.ktdeleted−26
@@ -1,26 +0,0 @@
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.ktdeleted−66
@@ -1,66 +0,0 @@
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.ktdeleted−18
@@ -1,18 +0,0 @@
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.ktdeleted−28
@@ -1,28 +0,0 @@
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.ktdeleted−49
@@ -1,49 +0,0 @@
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.ktdeleted−174
@@ -1,174 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import android.util.Log
5−import io.ktor.client.HttpClient
6−import io.ktor.client.plugins.timeout
7−import io.ktor.client.plugins.sse.sse
8−import io.ktor.sse.ServerSentEvent
9−import kotlinx.coroutines.CancellationException
10−import kotlinx.coroutines.CoroutineScope
11−import kotlinx.coroutines.channels.BufferOverflow
12−import kotlinx.coroutines.channels.Channel
13−import kotlinx.coroutines.channels.ChannelResult
14−import kotlinx.coroutines.flow.Flow
15−import kotlinx.coroutines.flow.MutableStateFlow
16−import kotlinx.coroutines.flow.SharedFlow
17−import kotlinx.coroutines.flow.StateFlow
18−import kotlinx.coroutines.flow.SharingStarted
19−import kotlinx.coroutines.flow.asStateFlow
20−import kotlinx.coroutines.flow.buffer
21−import kotlinx.coroutines.flow.channelFlow
22−import kotlinx.coroutines.flow.produceIn
23−import kotlinx.coroutines.flow.shareIn
24−import kotlinx.coroutines.isActive
25−import kotlinx.coroutines.selects.onTimeout
26−import kotlinx.coroutines.selects.select
27−import java.io.IOException
28−import kotlin.random.Random
29−
30−/**
31− * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
32− * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
33− * subscribes (WhileSubscribed) and reconnects with backoff on drop.
34− *
35− * Ordering contract: callers that send a prompt must already be collecting [events]
36− * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
37− */
38−@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
39−class EventStreamClient(
40− private val client: HttpClient,
41− scope: CoroutineScope,
42−) {
43− enum class ConnectionState { Disconnected, Connecting, Connected }
44−
45− private val _state = MutableStateFlow(ConnectionState.Disconnected)
46− val state: StateFlow<ConnectionState> = _state.asStateFlow()
47−
48− val events: SharedFlow<BusEvent> = stream()
49− // Buffer so a slow subscriber (e.g. a reducer under lock) can't suspend send()
50− // and stall the SSE read loop. Drop oldest on overflow — the next event carries
51− // the freshest state, so a dropped interim delta is harmless.
52− .buffer(NetworkConfig.sseEventBufferCapacity, BufferOverflow.DROP_OLDEST)
53− .shareIn(
54− scope = scope,
55− started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000),
56− replay = 0,
57− )
58−
59− /** Conflated channel used to cut the reconnect backoff short when network returns. */
60− private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
61−
62− /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying. */
63− private fun isSseAuthFailure(e: Throwable): Boolean =
64− e is io.ktor.client.plugins.ClientRequestException &&
65− (e.response.status.value == 401 || e.response.status.value == 403)
66−
67− /** Request an immediate reconnect, skipping any in-progress backoff. */
68− fun triggerReconnect() { reconnectSignal.trySend(Unit) }
69−
70− /**
71− * Read SSE events from [events], racing each read against an idle-timeout watchdog.
72− * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
73− * clean stream close or scope cancellation.
74− */
75− private suspend fun readSseEvents(
76− scope: CoroutineScope,
77− events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
78− send: suspend (BusEvent) -> Unit,
79− ) {
80− while (scope.isActive) {
81− // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
82− // read), so a socket the peer never closed would otherwise hang until OS TCP
83− // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
84− // within the idle timeout, treat the connection as half-open and drop it.
85− val result: ChannelResult<ServerSentEvent>? = select {
86− events.onReceiveCatching { it }
87− onTimeout(NetworkConfig.sseIdleTimeoutMs) { null }
88− }
89− if (result == null) throw IOException("SSE idle timeout, reconnecting")
90− val sse = result.getOrNull() ?: break
91− val data = sse.data ?: continue
92− val event = try {
93− OpencodeJson.decodeFromString(BusEvent.serializer(), data)
94− } catch (e: kotlinx.serialization.SerializationException) {
95− Log.d("EventStream", "Skipping unparseable SSE event", e)
96− continue
97− }
98− if (_state.value != ConnectionState.Connected) {
99− _state.value = ConnectionState.Connected
100− }
101− send(event)
102− }
103− }
104−
105− private fun stream(): Flow<BusEvent> = channelFlow {
106− val scope = this
107− // Drain any stale reconnect signal from a previous collection cycle so it
108− // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
109− // stopped the upstream while triggerReconnect() queued a signal).
110− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
111− var backoffMs = NetworkConfig.sseInitialBackoffMs
112− while (isActive) {
113− _state.value = ConnectionState.Connecting
114− try {
115− // The SSE stream is long-lived: disable the request-level and socket
116− // timeouts (the client default is 60s for REST) in the request config
117− // so the connection isn't killed mid-stream. The idle-timeout watchdog
118− // below handles half-open connections instead.
119− client.sse(
120− "event",
121− request = {
122− timeout {
123− requestTimeoutMillis = Long.MAX_VALUE
124− // Disable the socket (read) timeout entirely for the SSE stream.
125− // A finite value would kill the connection whenever the server
126− // is quiet for longer than that (idle sessions, slow agent runs),
127− // causing a reconnect storm. Half-open detection is handled by
128− // the idle-timeout watchdog below plus OkHttp's pingInterval on
129− // HTTP/2 connections.
130− socketTimeoutMillis = Long.MAX_VALUE
131− }
132− },
133− ) {
134− backoffMs = NetworkConfig.sseInitialBackoffMs
135− // Clear any stale reconnect signal from a triggerReconnect() call
136− // made while the previous stream was healthy, so it doesn't suppress
137− // the next legitimate backoff.
138− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
139− // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
140− // element can be raced against an idle timeout via select.
141− val events = incoming.produceIn(scope)
142− try {
143− readSseEvents(scope, events) { send(it) }
144− } finally {
145− events.cancel()
146− }
147− }
148− } catch (c: CancellationException) {
149− throw c
150− } catch (e: Exception) {
151− // If the scope was cancelled (e.g. connection close), the closed-client
152− // exception is expected — don't log a spurious "stream error" warning.
153− if (isSseAuthFailure(e)) {
154− // Credentials are wrong — retrying won't help. Log and stop.
155− Log.w("EventStream", "SSE auth failed (401/403), stopping retries", e)
156− _state.value = ConnectionState.Disconnected
157− break
158− } else if (isActive) {
159− Log.w("EventStream", "SSE stream error, will retry", e)
160− }
161− }
162− _state.value = ConnectionState.Disconnected
163− if (!isActive) break
164− // Wait for the backoff, but allow a reconnect signal to cut it short.
165− var signaled = false
166− val jitter = (backoffMs * 0.2 * Random.nextDouble()).toLong()
167− select {
168− reconnectSignal.onReceive { signaled = true }
169− onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
170− }
171− backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
172− }
173− }
174−}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktdeleted−90
@@ -1,90 +0,0 @@
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− // A normal read timeout for REST calls. The SSE stream overrides this
31− // to infinite inside the sse {} request block (see EventStreamClient),
32− // so the long-lived event connection is never killed mid-stream.
33− readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
34− connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
35− retryOnConnectionFailure(true)
36− pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
37− }
38− }
39−
40− install(ContentNegotiation) {
41− json(OpencodeJson)
42− }
43−
44− install(SSE)
45−
46− // Request-level timeout for REST calls. The SSE stream overrides this to
47− // infinite inside the sse {} request block (see EventStreamClient) so the
48− // long-lived /event connection isn't killed after 60s. The engine's connect
49− // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
50− install(HttpTimeout) {
51− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
52− }
53−
54− if (profile.hasAuth) {
55− install(Auth) {
56− basic {
57− credentials {
58− BasicAuthCredentials(
59− username = profile.username.orEmpty(),
60− password = profile.password.orEmpty(),
61− )
62− }
63− // Send eagerly so opencode doesn't need a 401 challenge round-trip,
64− // but only over HTTPS — sending credentials over cleartext HTTP
65− // would expose them on the network.
66− sendWithoutRequest { request ->
67− request.url.protocol.name == "https"
68− }
69− }
70− }
71− }
72−
73− defaultRequest {
74− url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
75− }
76− }
77−
78− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
79− * Defaults to [https] when no scheme is given so credentials aren't accidentally
80− * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
81− fun normalizeBaseUrl(raw: String): String {
82− val trimmed = raw.trim()
83− val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
84− trimmed
85− } else {
86− "https://$trimmed"
87− }
88− return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
89− }
90−}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktdeleted−56
@@ -1,56 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−/**
4− * Centralized tuning parameters for the network and presentation layers. Keeping these
5− * in one place makes them easy to find and adjust without hunting through source files,
6− * and makes the intent of each value explicit rather than a bare magic number.
7− */
8−object NetworkConfig {
9−
10− // --- OkHttp engine timeouts (HttpClientFactory) ---
11−
12− /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13− const val connectTimeoutSeconds = 30L
14− /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15− const val readTimeoutSeconds = 60L
16− /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17− const val pingIntervalSeconds = 20L
18− /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19− const val restRequestTimeoutMs = 60_000L
20−
21− // --- REST retry (OpencodeApiClient.withRetry) ---
22−
23− /** Maximum number of attempts for a retriable REST call. */
24− const val retryMaxAttempts = 3
25− /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26− const val retryInitialDelayMs = 500L
27− /** Jitter factor for retry backoff (0.2 = ±20% random jitter added to each delay). */
28− const val retryJitterFactor = 0.2
29−
30− // --- SSE stream (EventStreamClient) ---
31−
32− /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
33− const val sseInitialBackoffMs = 500L
34− /** Cap on the SSE reconnect backoff. */
35− const val sseMaxBackoffMs = 10_000L
36− /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
37− const val sseIdleTimeoutMs = 90_000L
38− /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
39− const val sseEventBufferCapacity = 64
40− /** Max time to wait for the first SSE event before treating the connection as stale. */
41− const val sseHandshakeTimeoutMs = 30_000L
42−
43− // --- Session list (SessionListViewModel) ---
44−
45− /** Max sessions to fetch previews for in one batch. */
46− const val maxPreviewSessions = 50
47− /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
48− const val maxConcurrentPreviews = 8
49− /** Max characters of a session's last message to keep as a list preview. */
50− const val previewTextMaxLength = 200
51−
52− // --- File browser (FileBrowserViewModel) ---
53−
54− /** Debounce delay before firing a file-search request after the user stops typing. */
55− const val fileSearchDebounceMs = 250L
56−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktdeleted−189
@@ -1,189 +0,0 @@
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.plugins.ClientRequestException
22−import io.ktor.client.request.delete
23−import io.ktor.client.request.get
24−import io.ktor.client.request.parameter
25−import io.ktor.client.request.patch
26−import io.ktor.client.request.post
27−import io.ktor.client.request.setBody
28−import io.ktor.http.ContentType
29−import io.ktor.http.contentType
30−import kotlinx.coroutines.delay
31−import kotlin.random.Random
32−import java.net.URLEncoder
33−
34−/**
35− * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
36− * SSE stream lives in [EventStreamClient]; everything else is here.
37− */
38−class OpencodeApiClient(private val client: HttpClient) {
39−
40− /** Lightweight connectivity check. Throws on non-2xx / network failure. */
41− suspend fun ping() {
42− client.get("global/health")
43− }
44−
45− suspend fun listSessions(): List<Session> = withRetry {
46− client.get("session").body()
47− }
48−
49− suspend fun createSession(title: String? = null): Session =
50− client.post("session") {
51− contentType(ContentType.Application.Json)
52− setBody(CreateSessionRequest(title = title))
53− }.body()
54−
55− suspend fun updateSession(id: String, title: String): Session =
56− client.patch("session/${encode(id)}") {
57− contentType(ContentType.Application.Json)
58− setBody(UpdateSessionRequest(title = title))
59− }.body()
60−
61− suspend fun deleteSession(id: String) {
62− client.delete("session/${encode(id)}")
63− }
64−
65− suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
66− client.get("session/${encode(sessionId)}/message").body()
67− }
68−
69− suspend fun sendPrompt(
70− sessionId: String,
71− text: String,
72− model: ModelRef? = null,
73− agent: String? = null,
74− ): MessageWithParts =
75− client.post("session/${encode(sessionId)}/message") {
76− contentType(ContentType.Application.Json)
77− setBody(
78− PromptRequest(
79− parts = listOf(PromptPart(text = text)),
80− model = model,
81− agent = agent,
82− ),
83− )
84− }.body()
85−
86− suspend fun abort(sessionId: String) {
87− client.post("session/${encode(sessionId)}/abort")
88− }
89−
90− /** Invoke a slash-command by name via `POST /session/:id/command`. */
91− suspend fun runCommand(
92− sessionId: String,
93− command: String,
94− arguments: String = "",
95− agent: String? = null,
96− ): MessageWithParts =
97− client.post("session/${encode(sessionId)}/command") {
98− contentType(ContentType.Application.Json)
99− setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
100− }.body()
101−
102− suspend fun providers(): ProvidersResponse = withRetry {
103− client.get("config/providers").body()
104− }
105−
106− suspend fun agents(): List<Agent> = withRetry {
107− client.get("agent").body()
108− }
109−
110− suspend fun commands(): List<Command> = withRetry {
111− client.get("command").body()
112− }
113−
114− /** Respond to a permission request so a paused tool run can proceed. */
115− suspend fun respondPermission(
116− sessionId: String,
117− permissionId: String,
118− response: PermissionResponse,
119− ) = withRetry {
120− client.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
121− contentType(ContentType.Application.Json)
122− setBody(PermissionReplyBody(response.wire))
123− }
124− }
125−
126− // --- Files ---
127−
128− suspend fun findFiles(query: String): List<String> = withRetry {
129− client.get("find/file") { parameter("query", query) }.body()
130− }
131−
132− suspend fun listDirectory(path: String): List<FileNode> = withRetry {
133− client.get("file") { parameter("path", path) }.body()
134− }
135−
136− suspend fun readFile(path: String): FileContent = withRetry {
137− client.get("file/content") { parameter("path", path) }.body()
138− }
139−
140− suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
141− client.get("file/status").body()
142− }
143−}
144−
145−private suspend fun <T> withRetry(
146− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
147− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
148− block: suspend () -> T,
149−): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
150−
151−/**
152− * Retry a suspending block up to [maxAttempts] times with exponential backoff.
153− * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
154− * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
155− * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
156− * server errors are retried. Exposed as `internal` so the retry/skip rules are
157− * unit-testable without an HTTP server.
158− */
159−internal suspend fun <T> withRetryInternal(
160− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
161− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
162− jitterFactor: Double = NetworkConfig.retryJitterFactor,
163− block: suspend () -> T,
164−): T {
165− var lastError: Throwable? = null
166− for (attempt in 1..maxAttempts) {
167− try {
168− return block()
169− } catch (c: kotlinx.coroutines.CancellationException) {
170− throw c
171− } catch (t: Exception) {
172− lastError = t
173− if (t is ClientRequestException) {
174− // 429 (Too Many Requests) is transient — retry with backoff.
175− if (t.response.status.value != 429) throw t
176− }
177− if (attempt < maxAttempts) {
178− val baseDelay = initialDelayMs * (1 shl (attempt - 1))
179− val jitter = (baseDelay * jitterFactor * Random.nextDouble()).toLong()
180− delay(baseDelay + jitter)
181− }
182− }
183− }
184− throw lastError ?: IllegalStateException("withRetry failed without error")
185−}
186−
187−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
188−private fun encode(segment: String): String =
189− URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktdeleted−36
@@ -1,36 +0,0 @@
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/CrashLogger.ktdeleted−135
@@ -1,135 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.pm.PackageManager
5−import android.os.Build
6−import android.util.Log
7−import androidx.core.content.pm.PackageInfoCompat
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.SupervisorJob
11−import kotlinx.coroutines.flow.MutableStateFlow
12−import kotlinx.coroutines.flow.StateFlow
13−import kotlinx.coroutines.flow.asStateFlow
14−import kotlinx.coroutines.launch
15−import java.io.File
16−import java.io.PrintWriter
17−import java.io.StringWriter
18−import java.text.SimpleDateFormat
19−import java.util.Date
20−import java.util.Locale
21−
22−/**
23− * A self-contained crash reporter with no network or third-party backend. An uncaught
24− * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
25− * text report (timestamp, device/app metadata, and the full stack trace), then the
26− * previous handler is re-invoked so the process still dies normally.
27− *
28− * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
29− * shared, or deleted. This closes the "no idea what's crashing in the field" gap
30− * without pulling in a hosted analytics SDK.
31− */
32−class CrashLogger private constructor(private val appContext: Context) {
33−
34− data class CrashReport(
35− val fileName: String,
36− val timestamp: Long,
37− val preview: String,
38− )
39−
40− private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
41− private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
42−
43− private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
44− val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
45−
46− fun install() {
47− val previous = Thread.getDefaultUncaughtExceptionHandler()
48− Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
49− runCatching { writeReport(thread, throwable) }
50− .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
51− previous?.uncaughtException(thread, throwable)
52− }
53− // Load crash reports off the main thread so startup isn't blocked by file I/O
54− // (listFiles + reading the first line of each report). The StateFlow updates
55− // whenever the scan completes, so the Diagnostics screen reflects the result.
56− scope.launch { runCatching { refresh() } }
57− }
58−
59− fun refresh() {
60− _reports.value = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
61− ?.sortedByDescending { it.lastModified() }
62− ?.map { f ->
63− CrashReport(
64− fileName = f.name,
65− timestamp = f.lastModified(),
66− preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
67− )
68− }
69− .orEmpty()
70− }
71−
72− fun readReport(fileName: String): String? {
73− val file = File(crashDir, fileName).canonicalFile
74− if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
75− return runCatching { file.readText() }.getOrNull()
76− }
77−
78− fun deleteReport(fileName: String) {
79− val file = File(crashDir, fileName).canonicalFile
80− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
81− file.delete()
82− }
83− refresh()
84− }
85−
86− fun clearAll() {
87− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
88− refresh()
89− }
90−
91− fun reportCount(): Int = _reports.value.size
92−
93− private fun writeReport(thread: Thread, throwable: Throwable) {
94− val now = Date()
95− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
96− val file = File(crashDir, "crash-$stamp.txt")
97− val sw = StringWriter()
98− PrintWriter(sw).use { pw ->
99− pw.println("opencode-android crash report")
100− pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
101− pw.println("Thread: ${thread.name}")
102− pw.println("App version: ${appVersion()}")
103− pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
104− pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
105− pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
106− pw.println()
107− pw.println(throwable.javaClass.name + ": " + throwable.message)
108− throwable.printStackTrace(pw)
109− }
110− file.writeText(sw.toString())
111− }
112−
113− private fun appVersion(): String = runCatching {
114− val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
115− appContext.packageManager.getPackageInfo(
116− appContext.packageName,
117− PackageManager.PackageInfoFlags.of(0),
118− )
119− } else {
120− @Suppress("DEPRECATION")
121− appContext.packageManager.getPackageInfo(appContext.packageName, 0)
122− }
123− "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
124− }.getOrDefault("unknown")
125−
126− companion object {
127− @Volatile
128− private var instance: CrashLogger? = null
129−
130− fun get(context: Context): CrashLogger =
131− instance ?: synchronized(this) {
132− instance ?: CrashLogger(context.applicationContext).also { instance = it }
133− }
134− }
135−}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktdeleted−52
@@ -1,52 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import java.util.concurrent.CountDownLatch
5−import java.util.concurrent.TimeUnit
6−import kotlinx.coroutines.CoroutineScope
7−import kotlinx.coroutines.launch
8−
9−/**
10− * Persists the in-progress message draft for each session so it survives
11− * back-navigation and process death. Backed by SharedPreferences because the
12− * UI needs the initial value synchronously and drafts are low-stakes data.
13− *
14− * The prefs file is loaded on a background thread at construction time so the
15− * first [get] call on the main thread doesn't trigger a synchronous disk read.
16− */
17−class DraftStore(context: Context, scope: CoroutineScope) {
18−
19− private val appContext = context.applicationContext
20− private val prefsReady = CountDownLatch(1)
21− private val prefs by lazy {
22− appContext.getSharedPreferences("drafts", Context.MODE_PRIVATE)
23− }
24−
25− init {
26− // Eagerly load the prefs file on a background thread so the first main-thread
27− // get() doesn't block on disk I/O. The latch ensures get() waits for this
28− // background load instead of triggering its own lazy load on the main thread.
29− scope.launch {
30− runCatching { prefs.all }
31− prefsReady.countDown()
32− }
33− }
34−
35− fun get(sessionId: String): String {
36− prefsReady.await(2, TimeUnit.SECONDS)
37− return prefs.getString(sessionId, "").orEmpty()
38− }
39−
40− fun set(sessionId: String, text: String) {
41− prefsReady.await(2, TimeUnit.SECONDS)
42− prefs.edit().apply {
43− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
44− }.apply()
45− }
46−
47− /** Remove the draft for a session (call on session deletion to avoid orphaned entries). */
48− fun remove(sessionId: String) {
49− prefsReady.await(2, TimeUnit.SECONDS)
50− prefs.edit().remove(sessionId).apply()
51− }
52−}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktdeleted−77
@@ -1,77 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.ClientRequestException
4−import io.ktor.client.plugins.HttpRequestTimeoutException
5−import io.ktor.client.plugins.ResponseException
6−import io.ktor.client.plugins.ServerResponseException
7−import java.io.IOException
8−import java.net.ConnectException
9−import java.net.SocketTimeoutException
10−import java.net.UnknownHostException
11−
12−/**
13− * User-facing categories for a thrown error. Extracted so the classification logic is
14− * unit-testable without an Android [android.content.Context] (the mapping from a kind
15− * to a localized string lives in [friendlyError]).
16− */
17−enum class ErrorKind {
18− /** Server host could not be resolved / connection refused. */
19− NOT_REACHABLE,
20−
21− /** Connect or read timed out. */
22− TIMEOUT,
23−
24− /** Server returned a 5xx response. */
25− SERVER,
26−
27− /** Server returned a 4xx response (auth, not-found, etc.). */
28− CLIENT,
29−
30− /** Any other network-level I/O failure. */
31− NETWORK,
32−
33− /** Anything that isn't network-related. */
34− UNKNOWN,
35−}
36−
37−/**
38− * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
39− * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
40− * by a library still classifies correctly.
41− */
42−fun classifyError(t: Throwable): ErrorKind {
43− var current: Throwable? = t
44− // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
45− // IOException) still classify by their root.
46− var hops = 0
47− while (current != null && hops < 8) {
48− when (current) {
49− is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
50− is SocketTimeoutException, is HttpRequestTimeoutException ->
51− return ErrorKind.TIMEOUT
52− is ServerResponseException -> return ErrorKind.SERVER
53− is ClientRequestException -> return ErrorKind.CLIENT
54− is IOException -> return ErrorKind.NETWORK
55− else -> {
56− // ResponseException covers any other HTTP status failure (e.g. a redirect
57− // or an unrecognized status family) surfaced because expectSuccess = true.
58− if (current is ResponseException) return ErrorKind.SERVER
59− }
60− }
61− current = current.cause
62− hops++
63− }
64− return ErrorKind.UNKNOWN
65−}
66−
67−/** The HTTP status code carried by a client/server error, if any. */
68−fun responseStatusCode(t: Throwable): Int? {
69− var current: Throwable? = t
70− var hops = 0
71− while (current != null && hops < 8) {
72− if (current is ResponseException) return current.response.status.value
73− current = current.cause
74− hops++
75− }
76− return null
77−}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktdeleted−137
@@ -1,137 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.SharedPreferences
5−import android.util.Log
6−import androidx.datastore.preferences.core.edit
7−import androidx.datastore.preferences.core.stringPreferencesKey
8−import androidx.datastore.preferences.preferencesDataStore
9−import androidx.security.crypto.EncryptedSharedPreferences
10−import androidx.security.crypto.MasterKey
11−import soy.iko.opencode.data.model.ServerProfile
12−import soy.iko.opencode.data.network.OpencodeJson
13−import kotlinx.coroutines.Dispatchers
14−import kotlinx.coroutines.flow.Flow
15−import kotlinx.coroutines.flow.map
16−import kotlinx.coroutines.withContext
17−import kotlinx.serialization.Serializable
18−import kotlinx.serialization.builtins.ListSerializer
19−
20−private val Context.dataStore by preferencesDataStore(name = "server_profiles")
21−
22−/**
23− * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
24− * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
25− * by profile id, and merged back in on read.
26− */
27−class ProfileStore(context: Context) {
28−
29− private val appContext = context.applicationContext
30− private val profilesKey = stringPreferencesKey("profiles_json")
31−
32− private val securePrefs: SharedPreferences by lazy {
33− runCatching {
34− val masterKey = MasterKey.Builder(appContext)
35− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
36− .build()
37− EncryptedSharedPreferences.create(
38− appContext,
39− "server_secrets",
40− masterKey,
41− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
42− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
43− )
44− }.getOrElse {
45− Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
46− appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
47− }
48− }
49−
50− /** Stored shape on DataStore (everything except the secret password). */
51− @Serializable
52− private data class StoredProfile(
53− val id: String,
54− val label: String,
55− val baseUrl: String,
56− val username: String? = null,
57− val lastUsed: Long = 0,
58− )
59−
60− val profiles: Flow<List<ServerProfile>> = appContext.dataStore.data.map { prefs ->
61− val json = prefs[profilesKey] ?: return@map emptyList()
62− runCatching {
63− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
64− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
65− .getOrDefault(emptyList())
66− .sortedByDescending { it.lastUsed }
67− .map { it.toProfile() }
68− }
69−
70− private suspend fun StoredProfile.toProfile(): ServerProfile {
71− val pw = username?.let { passwordFor(id) }
72− return ServerProfile(
73− id = id,
74− label = label,
75− baseUrl = baseUrl,
76− username = username,
77− password = pw,
78− lastUsed = lastUsed,
79− )
80− }
81−
82− suspend fun resolve(profile: ServerProfile): ServerProfile {
83− val pw = if (profile.hasAuth) passwordFor(profile.id) else null
84− return profile.copy(password = pw)
85− }
86−
87− private suspend fun passwordFor(id: String): String? =
88− withContext(Dispatchers.IO) { securePrefs.getString(passwordKey(id), null) }
89−
90− suspend fun save(profile: ServerProfile) {
91− appContext.dataStore.edit { prefs ->
92− val current = prefs[profilesKey]?.let {
93− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
94− .getOrDefault(emptyList())
95− } ?: emptyList()
96− val stored = StoredProfile(
97− id = profile.id,
98− label = profile.label,
99− baseUrl = profile.baseUrl,
100− username = profile.username?.takeIf { it.isNotBlank() },
101− lastUsed = profile.lastUsed,
102− )
103− val updated = current.filterNot { it.id == profile.id } + stored
104− prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
105− }
106− // Secret goes to encrypted storage (or is cleared when auth removed).
107− // EncryptedSharedPreferences encrypts on the calling thread, so move to IO.
108− withContext(Dispatchers.IO) {
109− securePrefs.edit().apply {
110− val pw = profile.password
111− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
112− putString(passwordKey(profile.id), pw)
113− } else {
114− remove(passwordKey(profile.id))
115− }
116− }.commit()
117− }
118− }
119−
120− suspend fun delete(id: String) {
121− appContext.dataStore.edit { prefs ->
122− val current = prefs[profilesKey]?.let {
123− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
124− .getOrDefault(emptyList())
125− } ?: emptyList()
126− prefs[profilesKey] = OpencodeJson.encodeToString(
127− ListSerializer(StoredProfile.serializer()),
128− current.filterNot { it.id == id },
129− )
130− }
131− withContext(Dispatchers.IO) {
132− securePrefs.edit().remove(passwordKey(id)).commit()
133− }
134− }
135−
136− private fun passwordKey(id: String) = "pw_$id"
137−}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktdeleted−221
@@ -1,221 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessagePartRemoved
5−import soy.iko.opencode.data.model.MessagePartUpdated
6−import soy.iko.opencode.data.model.MessageRemoved
7−import soy.iko.opencode.data.model.MessageUpdated
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.model.ModelRef
10−import soy.iko.opencode.data.model.Part
11−import soy.iko.opencode.data.model.SessionError
12−import soy.iko.opencode.data.model.SessionIdle
13−import soy.iko.opencode.data.model.UnknownMessage
14−import soy.iko.opencode.data.network.EventStreamClient
15−import soy.iko.opencode.data.network.OpencodeApiClient
16−import android.util.Log
17−import kotlinx.coroutines.flow.Flow
18−import kotlinx.coroutines.flow.channelFlow
19−import kotlinx.coroutines.flow.conflate
20−import kotlinx.coroutines.launch
21−import kotlinx.coroutines.sync.Mutex
22−import kotlinx.coroutines.sync.withLock
23−
24−/**
25− * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
26− * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
27− * each keyed by message id with parts keyed by part id (idempotent upserts).
28− */
29−class SessionRepository(
30− private val api: OpencodeApiClient,
31− private val eventStream: EventStreamClient,
32−) {
33− suspend fun listSessions() = api.listSessions()
34− suspend fun createSession(title: String? = null) = api.createSession(title)
35− suspend fun deleteSession(id: String) = api.deleteSession(id)
36− suspend fun abort(sessionId: String) = api.abort(sessionId)
37−
38− suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
39− api.sendPrompt(sessionId, text, model, agent)
40−
41− suspend fun runCommand(
42− sessionId: String,
43− command: String,
44− arguments: String = "",
45− agent: String? = null,
46− ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
47−
48− /**
49− * A live, ordered view of [sessionId]'s messages. Begins collecting the event
50− * stream before the initial REST load so no streamed part is missed, then reduces
51− * events into the in-memory state. The flow is conflated so a fast burst of
52− * streaming tokens (each publishing a fresh snapshot) never back-pressures the
53− * event reducer — a slow collector simply sees the most recent snapshot.
54− */
55− fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
56− val store = MessageStore()
57− val lock = Mutex()
58−
59− // Collect the snapshot under the lock, then publish outside it so a slow
60− // downstream collector can't stall event processing.
61− suspend fun publish() {
62− val snapshot = lock.withLock { store.snapshot() }
63− send(snapshot)
64− }
65−
66− // Subscribe to events first so we don't miss early deltas during the initial load.
67− val job = launch {
68− eventStream.events.collect { event ->
69− val changed = lock.withLock { store.reduce(sessionId, event) }
70− if (changed) publish()
71− }
72− }
73−
74− // Re-seed from REST when the SSE stream reconnects after a drop. Events emitted
75− // by the server during the disconnection gap are lost from the in-memory state;
76− // re-fetching the current snapshot fills those holes. The seed merge logic
77− // (see MessageStore.seed) handles interleaving with concurrently-arrived events.
78− launch {
79− var wasConnected = false
80− eventStream.state.collect { state ->
81− if (state == EventStreamClient.ConnectionState.Connected) {
82− if (wasConnected) {
83− val fresh = runCatching { api.listMessages(sessionId) }.getOrDefault(emptyList())
84− lock.withLock { store.seed(fresh, prune = true) }
85− publish()
86− }
87− wasConnected = true
88− } else if (state == EventStreamClient.ConnectionState.Disconnected) {
89− wasConnected = false
90− }
91− }
92− }
93−
94− val initial = runCatching { api.listMessages(sessionId) }
95− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE", it) }
96− .getOrDefault(emptyList())
97− lock.withLock { store.seed(initial) }
98− publish()
99−
100− // Keep the flow alive until the collector cancels; the launched job is torn down with it.
101− job.join()
102− }.conflate()
103−
104− companion object {
105− /** Convenience: is this event a run-completion signal for [sessionId]? */
106− fun isIdle(event: BusEvent, sessionId: String): Boolean =
107− event is SessionIdle && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
108−
109− fun isError(event: BusEvent, sessionId: String): Boolean =
110− event is SessionError && (event.properties.sessionID == null || event.properties.sessionID == sessionId)
111− }
112−}
113−
114−/**
115− * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
116− * Exposed as `internal` so the reducer logic can be unit-tested directly.
117− */
118−internal class MessageStore {
119− // messageId -> (info + parts), insertion-ordered.
120− private val messages = LinkedHashMap<String, MessageWithParts>()
121−
122− fun snapshot(): List<MessageWithParts> = messages.values.toList()
123−
124− fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
125− // On re-seed (after SSE reconnect), remove messages that are no longer in the
126− // server snapshot (e.g. deleted during the disconnection gap). This keeps the
127− // in-memory state in sync with the authoritative REST snapshot rather than
128− // accumulating stale messages forever. Skipped on the initial seed to avoid
129− // racing with just-arrived SSE events whose messages may not yet be in REST.
130− if (prune) {
131− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
132− messages.keys.retainAll(incomingIds)
133− }
134−
135− for (m in initial) {
136− val existing = messages[m.info.id]
137− if (existing == null) {
138− messages[m.info.id] = m
139− } else {
140− // A part streamed in between subscribe and this initial load may already
141− // have populated this message (see observeMessages). Merge instead of
142− // overwriting so that newer streamed part isn't discarded: take the
143− // snapshot's part order, swap in the streamed version where ids overlap,
144− // append any streamed-only parts, and adopt the authoritative REST info.
145− val streamedById = existing.parts.associateBy { it.id }
146− val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
147− val ordered = m.parts.map { p -> streamedById[p.id] ?: p }.toMutableList()
148− for (p in existing.parts) {
149− if (p.id !in snapshotIds) ordered.add(p)
150− }
151− val info = if (existing.info is UnknownMessage) m.info else existing.info
152− messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
153− }
154− }
155− }
156−
157− /** Returns true if the state changed (and a new snapshot should be published). */
158− fun reduce(sessionId: String, event: BusEvent): Boolean {
159− return when (event) {
160− is MessageUpdated -> {
161− val info = event.properties.info
162− if (info.sessionID != sessionId) {
163− false
164− } else {
165− val existing = messages[info.id]
166− messages[info.id] = existing?.copy(info = info) ?: MessageWithParts(info)
167− true
168− }
169− }
170−
171− is MessagePartUpdated -> {
172− val part = event.properties.part
173− val messageId = part.messageID ?: event.properties.messageID
174− val partSession = part.sessionID ?: event.properties.sessionID
175− if (messageId == null || (partSession != null && partSession != sessionId)) {
176− false
177− } else {
178− upsertPart(messageId, part)
179− }
180− }
181−
182− is MessagePartRemoved -> {
183− val messageId = event.properties.messageID
184− val partId = event.properties.partID
185− if (messageId == null || partId == null) false else removePart(messageId, partId)
186− }
187−
188− is MessageRemoved -> {
189− val id = event.properties.messageID
190− if (id == null) false else messages.remove(id) != null
191− }
192−
193− else -> false
194− }
195− }
196−
197− private fun upsertPart(messageId: String, part: Part): Boolean {
198− val current = messages[messageId]
199− val parts = current?.parts.orEmpty()
200− val idx = parts.indexOfFirst { it.id == part.id }
201− val newParts = if (idx >= 0) {
202− parts.toMutableList().also { it[idx] = part }
203− } else {
204− parts + part
205− }
206− messages[messageId] = current?.copy(parts = newParts)
207− ?: MessageWithParts(
208− info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
209− parts = newParts,
210− )
211− return true
212− }
213−
214− private fun removePart(messageId: String, partId: String): Boolean {
215− val current = messages[messageId] ?: return false
216− val newParts = current.parts.filterNot { it.id == partId }
217− if (newParts.size == current.parts.size) return false
218− messages[messageId] = current.copy(parts = newParts)
219− return true
220− }
221−}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktdeleted−38
@@ -1,38 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.stringPreferencesKey
7−import androidx.datastore.preferences.preferencesDataStore
8−import kotlinx.coroutines.flow.Flow
9−import kotlinx.coroutines.flow.map
10−
11−enum class ThemeMode { SYSTEM, LIGHT, DARK }
12−
13−private val Context.settingsDataStore by preferencesDataStore(name = "settings")
14−
15−/** App-preferences store: theme mode, dynamic color (Material You). */
16−class SettingsStore(context: Context) {
17−
18− private val appContext = context.applicationContext
19− private val themeKey = stringPreferencesKey("theme_mode")
20− private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21−
22− val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
23− runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
24− .getOrDefault(ThemeMode.SYSTEM)
25− }
26−
27− val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
28− prefs[dynamicColorKey] ?: true
29− }
30−
31− suspend fun setThemeMode(mode: ThemeMode) {
32− appContext.settingsDataStore.edit { it[themeKey] = mode.name }
33− }
34−
35− suspend fun setDynamicColor(enabled: Boolean) {
36− appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
37− }
38−}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktdeleted−286
@@ -1,286 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import android.content.Context
4−import android.net.ConnectivityManager
5−import android.net.Network
6−import android.net.NetworkRequest
7−import android.util.Log
8−import soy.iko.opencode.data.model.BusEvent
9−import soy.iko.opencode.data.model.MessagePartUpdated
10−import soy.iko.opencode.data.model.MessageUpdated
11−import soy.iko.opencode.data.model.ServerProfile
12−import soy.iko.opencode.data.model.SessionIdle
13−import soy.iko.opencode.data.repo.DraftStore
14−import soy.iko.opencode.data.repo.ErrorKind
15−import soy.iko.opencode.data.repo.ProfileStore
16−import soy.iko.opencode.data.repo.SettingsStore
17−import soy.iko.opencode.data.repo.classifyError
18−import soy.iko.opencode.data.repo.responseStatusCode
19−import soy.iko.opencode.notification.NotificationChannels
20−import soy.iko.opencode.notification.SessionNotifications
21−import soy.iko.opencode.R
22−import kotlinx.coroutines.CoroutineScope
23−import kotlinx.coroutines.Dispatchers
24−import kotlinx.coroutines.ExperimentalCoroutinesApi
25−import kotlinx.coroutines.SupervisorJob
26−import kotlinx.coroutines.cancel
27−import kotlinx.coroutines.flow.MutableStateFlow
28−import kotlinx.coroutines.flow.StateFlow
29−import kotlinx.coroutines.flow.asStateFlow
30−import kotlinx.coroutines.flow.emptyFlow
31−import kotlinx.coroutines.flow.first
32−import kotlinx.coroutines.flow.flatMapLatest
33−import kotlinx.coroutines.flow.update
34−import kotlinx.coroutines.launch
35−import kotlinx.coroutines.sync.Mutex
36−import kotlinx.coroutines.sync.withLock
37−import java.util.concurrent.atomic.AtomicBoolean
38−
39−/**
40− * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
41− * process-wide singletons and the currently active [OpencodeConnection].
42− */
43−class AppContainer(context: Context) {
44−
45− private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
46− val profileStore = ProfileStore(context)
47− val settingsStore = SettingsStore(context)
48− val draftStore = DraftStore(context, appScope)
49−
50− private val appContext = context.applicationContext
51−
52− private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
53− val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
54−
55− /** True while a background auto-reconnect to the most-recent server is in flight. */
56− private val _reconnecting = MutableStateFlow(false)
57− val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
58−
59− /**
60− * Set once a startup auto-reconnect succeeds. The server list collects this and
61− * (once) navigates straight to the session list so a returning user skips it.
62− * [consumeAutoConnect] guards against re-firing on screen re-entry.
63− */
64− private val _autoConnectDone = MutableStateFlow(false)
65− val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
66− private val autoConnectConsumed = AtomicBoolean(false)
67− fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
68−
69− /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
70− private val _pendingShare = MutableStateFlow<String?>(null)
71− val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
72− fun setPendingShare(text: String) { _pendingShare.value = text }
73− fun consumePendingShare(): String? {
74− val current = _pendingShare.value ?: return null
75− return if (_pendingShare.compareAndSet(current, null)) current else null
76− }
77−
78− /**
79− * A session id to open from an external trigger (a notification tap or a deep link).
80− * The nav host consumes it once a connection is active.
81− */
82− private val _pendingOpenSession = MutableStateFlow<String?>(null)
83− val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
84− fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
85− fun consumePendingOpenSession(): String? {
86− val current = _pendingOpenSession.value ?: return null
87− return if (_pendingOpenSession.compareAndSet(current, null)) current else null
88− }
89−
90− /**
91− * The session the user is currently viewing (or null when not in a chat). Drives the
92− * unread tracker: messages arriving for any *other* session mark it unread, and
93− * opening a session clears its unread state.
94− */
95− private val _currentSession = MutableStateFlow<String?>(null)
96− val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
97−
98− /** Session ids that received activity while not being viewed. */
99− private val _unread = MutableStateFlow<Set<String>>(emptySet())
100− val unread: StateFlow<Set<String>> = _unread.asStateFlow()
101−
102− fun setCurrentSession(id: String?) {
103− _currentSession.value = id
104− if (id != null) {
105− _unread.update { it - id }
106− SessionNotifications.cancel(appContext, id)
107− }
108− }
109−
110− /** Resolve a localized string — view models reach resources through the container. */
111− fun string(id: Int, vararg formatArgs: Any): String =
112− if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
113−
114− /**
115− * Convert a throwable into a user-facing message. Classifies the error by concrete
116− * type (network, timeout, HTTP status) rather than string-matching class names, so
117− * the message reflects what actually went wrong without leaking internal URLs/state.
118− */
119− fun friendlyError(t: Throwable): String {
120− val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
121− return when (classifyError(t)) {
122− ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
123− string(R.string.error_not_reachable, baseUrl)
124− ErrorKind.TIMEOUT -> string(R.string.error_timeout)
125− ErrorKind.SERVER -> string(R.string.error_server)
126− // Show only the HTTP status (e.g. 401, 404) — never the request URL,
127− // which a ClientRequestException carries in its message and which we
128− // promised not to leak (it can include auth or internal paths).
129− ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
130− ?: string(R.string.error_generic)
131− ErrorKind.UNKNOWN -> string(R.string.error_generic)
132− }
133− }
134−
135− init {
136− NotificationChannels.create(appContext)
137− observeMessageActivity()
138− appScope.launch { autoConnect() }
139− }
140−
141− /** Release resources held for the process lifetime (network callback, app scope). */
142− fun shutdown() {
143− _activeConnection.value?.close()
144− _activeConnection.value = null
145− val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
146− networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
147− appScope.cancel()
148− }
149−
150− /**
151− * Watch the SSE bus for new message activity and badge any session that isn't
152− * currently open. This powers the unread dot on the session list so the user can
153− * tell which conversations got a reply while they were elsewhere.
154− *
155− * Uses [flatMapLatest] so a server switch (which replaces the active connection)
156− * re-subscribes to the new event stream and drops the old one.
157− */
158− @OptIn(ExperimentalCoroutinesApi::class)
159− private fun observeMessageActivity() {
160− appScope.launch {
161− activeConnection
162− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
163− .collect { event ->
164− // SessionIdle is not "message activity" (don't badge as unread)
165− // but signals a run finished — fire a completion notification if the
166− // session isn't currently being viewed and was actively streaming.
167− if (event is SessionIdle) {
168− val idleSid = event.properties.sessionID ?: return@collect
169− if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
170− notifySessionCompleted(idleSid)
171− }
172− return@collect
173− }
174− val sid = sessionOf(event) ?: return@collect
175− if (sid != _currentSession.value) {
176− _unread.update { it + sid }
177− }
178− // Track sessions actively streaming so we know which idle events
179− // represent a finished run worth notifying about.
180− if (event is MessagePartUpdated || event is MessageUpdated) {
181− activeRuns.add(sid)
182− }
183− }
184− }
185− }
186−
187− /** Session ids currently streaming an assistant run (best-effort, in-process). */
188− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
189−
190− /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
191− private val connectionMutex = Mutex()
192−
193− /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
194− * a DataStore + encrypted-prefs write on every rapid reconnect. */
195− private companion object {
196− const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
197− }
198−
199− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
200− private suspend fun notifySessionCompleted(sessionId: String) {
201− val conn = activeConnection.value ?: return
202− val title = runCatching {
203− conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
204− }.getOrNull() ?: sessionId
205− SessionNotifications.postCompleted(appContext, sessionId, title)
206− }
207−
208− /** Extract the session id an event pertains to, for message-activity events. */
209− private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
210−
211− /**
212− * On cold start, transparently reconnect to the most recently used server so a
213− * returning user lands in their session list instead of the empty server screen.
214− */
215− private suspend fun autoConnect() {
216− val recent = runCatching { profileStore.profiles.first() }
217− .getOrDefault(emptyList())
218− .firstOrNull() ?: return
219− _reconnecting.value = true
220− val ok = runCatching {
221− val conn = connect(recent)
222− conn.api.ping()
223− }.isSuccess
224− _reconnecting.value = false
225− if (ok) _autoConnectDone.value = true else disconnect()
226− }
227−
228− /**
229− * When the device regains connectivity, nudge the active SSE stream to reconnect
230− * immediately instead of waiting out the exponential backoff.
231− */
232− private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
233−
234− private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
235− val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
236− ?: return null
237− val callback = object : ConnectivityManager.NetworkCallback() {
238− override fun onAvailable(network: Network) {
239− activeConnection.value?.events?.triggerReconnect()
240− }
241− }
242− runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
243− .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
244− return callback
245− }
246−
247− /** Open (or re-open) a connection to [profile], replacing any current one.
248− * Only persists the updated `lastUsed` timestamp if it's stale (older than a
249− * minute), so rapid reconnect storms don't each trigger a DataStore write. */
250− suspend fun connect(profile: ServerProfile): OpencodeConnection =
251− connectionMutex.withLock {
252− _activeConnection.value?.close()
253− _activeConnection.value = null
254− activeRuns.clear()
255− val now = System.currentTimeMillis()
256− val resolved = profileStore.resolve(profile)
257− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
258− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
259− if (needsSave) profileStore.save(finalProfile)
260− OpencodeConnection(finalProfile).also { _activeConnection.value = it }
261− }
262−
263− suspend fun disconnect() {
264− // Acquire the connection mutex so disconnect is serialized with connect().
265− // Previously this was fire-and-forget (launch on appScope), which raced with
266− // a subsequent connect(): connect() could acquire the mutex, create a new
267− // connection, and then the pending disconnect() would close it.
268− connectionMutex.withLock {
269− _activeConnection.value?.close()
270− _activeConnection.value = null
271− }
272− }
273−}
274−
275−/**
276− * Extract the session id a message-activity event pertains to, or null for events that
277− * don't describe message activity. This drives the unread badge: any session that
278− * receives activity while it isn't the currently-viewed one gets badged. Extracted as
279− * a top-level `internal` function so the rule is unit-testable without an Android
280− * [Context] (the surrounding [AppContainer] needs one).
281− */
282−internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
283− is MessageUpdated -> event.properties.info.sessionID
284− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
285− else -> null
286−}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktdeleted−31
@@ -1,31 +0,0 @@
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.IO)
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/notification/NotificationChannels.ktdeleted−34
@@ -1,34 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.NotificationChannel
4−import android.app.NotificationManager
5−import android.content.Context
6−import soy.iko.opencode.R
7−
8−/**
9− * The app's notification channels. Created once at startup (idempotent — recreating an
10− * existing channel is a no-op). Two channels keep user control granular:
11− *
12− * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13− * "agent running" indicator.
14− * - [COMPLETED]: default importance. Used when a background session finishes a run.
15− */
16−object NotificationChannels {
17− const val STATUS = "run_status"
18− const val COMPLETED = "session_completed"
19−
20− fun create(context: Context) {
21− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
22− nm.createNotificationChannel(
23− NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
24− description = context.getString(R.string.notif_channel_status_desc)
25− setShowBadge(false)
26− },
27− )
28− nm.createNotificationChannel(
29− NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
30− description = context.getString(R.string.notif_channel_completed_desc)
31− },
32− )
33− }
34−}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktdeleted−82
@@ -1,82 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.Notification
4−import android.app.Service
5−import android.content.Context
6−import android.content.Intent
7−import android.content.pm.ServiceInfo
8−import android.os.Build
9−import android.os.IBinder
10−import android.util.Log
11−import androidx.core.app.NotificationCompat
12−import androidx.core.app.ServiceCompat
13−import soy.iko.opencode.R
14−
15−/**
16− * A foreground service kept alive while an opencode agent is actively running. Holding a
17− * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
18− * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
19− *
20− * The service is started when a run begins ([start]) and stopped when it goes idle
21− * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
22− * Started/stopped via Compose from the chat screen based on the `running` state.
23− */
24−class RunForegroundService : Service() {
25−
26− override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
27− val notification = buildNotification()
28− // startForeground can throw ForegroundServiceStartNotAllowedException on
29− // Android 12+ if the app is in the background when the service starts. Wrap
30− // it so a backgrounded start (e.g. the user navigates away at the wrong
31− // moment) doesn't crash the app — the SSE stream will just continue without
32− // foreground priority and may be killed by the system sooner.
33− runCatching {
34− ServiceCompat.startForeground(
35− this,
36− NOTIF_ID,
37− notification,
38− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
39− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
40− } else {
41− 0
42− },
43− )
44− }.onFailure {
45− Log.w(TAG, "startForeground failed; running without foreground priority", it)
46− stopSelf()
47− }
48− return START_NOT_STICKY
49− }
50−
51− private fun buildNotification(): Notification =
52− NotificationCompat.Builder(this, NotificationChannels.STATUS)
53− .setSmallIcon(R.drawable.ic_launcher_foreground)
54− .setContentTitle(getString(R.string.notif_running_title))
55− .setContentText(getString(R.string.notif_running_text))
56− .setOngoing(true)
57− .setPriority(NotificationCompat.PRIORITY_LOW)
58− .setCategory(NotificationCompat.CATEGORY_PROGRESS)
59− .build()
60−
61− override fun onDestroy() {
62− // Safety net: if the process is being killed while a run is in progress,
63− // ensure the notification is removed rather than lingering.
64− ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
65− super.onDestroy()
66− }
67−
68− override fun onBind(intent: Intent?): IBinder? = null
69−
70− companion object {
71− private const val TAG = "RunForegroundService"
72− private const val NOTIF_ID = 1
73−
74− fun start(context: Context) {
75− context.startForegroundService(Intent(context, RunForegroundService::class.java))
76− }
77−
78− fun stop(context: Context) {
79− context.stopService(Intent(context, RunForegroundService::class.java))
80− }
81− }
82−}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktdeleted−83
@@ -1,83 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.PendingIntent
4−import android.content.Context
5−import android.content.Intent
6−import android.os.Build
7−import android.util.Log
8−import androidx.core.app.NotificationCompat
9−import androidx.core.app.NotificationManagerCompat
10−import soy.iko.opencode.MainActivity
11−import soy.iko.opencode.R
12−import java.security.MessageDigest
13−
14−/**
15− * Builds and posts the "session completed" notification when a background session
16− * finishes a run. Tapping it opens the session via [MainActivity] with an
17− * [MainActivity.EXTRA_SESSION_ID] extra.
18− *
19− * Notification posting is guarded for Android 13+ runtime permission: if the user
20− * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
21− */
22−object SessionNotifications {
23−
24− private const val NOTIF_ID_PREFIX = 4000
25− private const val TAG = "SessionNotifications"
26−
27− /** Derive a stable, collision-resistant notification id from a session id.
28− * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
29− * bits makes accidental collisions astronomically unlikely. */
30− private fun notifIdFor(sessionId: String): Int {
31− val digest = MessageDigest.getInstance("SHA-256").digest(sessionId.toByteArray())
32− // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
33− val hash = ((digest[0].toInt() and 0xFF) shl 24 or
34− (digest[1].toInt() and 0xFF) shl 16 or
35− (digest[2].toInt() and 0xFF) shl 8 or
36− (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
37− // Offset by NOTIF_ID_PREFIX, wrapping within the positive Int range so the
38− // prefix is preserved (not masked away) and ids don't collide with the
39− // foreground service's NOTIF_ID = 1.
40− return (NOTIF_ID_PREFIX + hash).and(0x7FFFFFFF)
41− }
42−
43− fun postCompleted(context: Context, sessionId: String, title: String) {
44− // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
45− // denied it, skip the post instead of throwing. Foreground-service notifications
46− // are exempt, so the running indicator still shows during a run.
47− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
48− androidx.core.content.ContextCompat.checkSelfPermission(
49− context, android.Manifest.permission.POST_NOTIFICATIONS,
50− ) != android.content.pm.PackageManager.PERMISSION_GRANTED
51− ) {
52− return
53− }
54− val notifId = notifIdFor(sessionId)
55− val openIntent = Intent(context, MainActivity::class.java).apply {
56− action = Intent.ACTION_VIEW
57− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
58− putExtra(MainActivity.EXTRA_SESSION_ID, sessionId)
59− }
60− val pendingIntent = PendingIntent.getActivity(
61− context, notifId, openIntent,
62− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
63− )
64− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
65− .setSmallIcon(R.drawable.ic_launcher_foreground)
66− .setContentTitle(context.getString(R.string.notif_completed_title))
67− .setContentText(context.getString(R.string.notif_completed_text, title))
68− .setAutoCancel(true)
69− .setContentIntent(pendingIntent)
70− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
71− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
72− .build()
73−
74− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
75− .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
76− }
77−
78− /** Cancel a session's completion notification (e.g. when the user opens it). */
79− fun cancel(context: Context, sessionId: String) {
80− val notifId = notifIdFor(sessionId)
81− NotificationManagerCompat.from(context).cancel(notifId)
82− }
83−}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+105−137
@@ -1,156 +1,124 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3−import androidx.compose.foundation.layout.BoxWithConstraints
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
4import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
5import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.LaunchedEffect
6import androidx.compose.runtime.getValue
19import androidx.compose.runtime.getValue
7−import androidx.compose.runtime.rememberCoroutineScope
20+import androidx.compose.runtime.mutableStateOf
8−import androidx.compose.ui.unit.dp
21+import androidx.compose.runtime.remember
9−import androidx.navigation.NavType
22+import androidx.compose.runtime.setValue
10−import androidx.navigation.compose.NavHost
23+import androidx.compose.ui.Modifier
11−import androidx.navigation.compose.composable
24+import soy.iko.opencode.core.Event
12−import androidx.navigation.compose.rememberNavController
25+import soy.iko.opencode.core.Screen
13−import androidx.navigation.navArgument
14−import androidx.lifecycle.compose.collectAsStateWithLifecycle
15−import kotlinx.coroutines.launch
16−import soy.iko.opencode.di.AppContainer
26+import soy.iko.opencode.core.UiState
17−import soy.iko.opencode.ui.chat.ChatScreen
27+import soy.iko.opencode.ui.screens.ChatScreen
18−import soy.iko.opencode.ui.file.FileBrowserScreen
28+import soy.iko.opencode.ui.screens.ConnectScreen
19−import soy.iko.opencode.ui.file.FileViewScreen
20−import soy.iko.opencode.ui.server.ServerEditScreen
29+import soy.iko.opencode.ui.screens.SessionsScreen
21−import soy.iko.opencode.ui.server.ServerListScreen
22−import soy.iko.opencode.ui.session.SessionListScreen
23−import soy.iko.opencode.ui.session.TwoPaneSessionChat
24−import soy.iko.opencode.ui.settings.DiagnosticsScreen
25−import soy.iko.opencode.ui.settings.SettingsScreen
26
30
27@Composable
31@Composable
28−fun OpencodeApp(container: AppContainer) {
32+fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
29− val navController = rememberNavController()
33+ val snackbar = remember { SnackbarHostState() }
30− val scope = rememberCoroutineScope()
34+ var showStopConfirm by remember { mutableStateOf(false) }
31− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
35+ var showDisconnectConfirm by remember { mutableStateOf(false) }
32− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
33− val connection by container.activeConnection.collectAsStateWithLifecycle()
34
36
35− // When text is shared into the app, surface the session list so the user can pick
37+ LaunchedEffect(state.error) {
36− // (or create) a conversation to drop it into. The chosen session's draft is set
38+ state.error?.let {
37− // in [onOpenSession] below. Fresh launches rely on auto-reconnect to get here.
39+ snackbar.showSnackbar(
38− LaunchedEffect(pendingShare) {
40+ message = it,
39− if (pendingShare == null) return@LaunchedEffect
41+ actionLabel = "Dismiss",
40− if (container.activeConnection.value == null) return@LaunchedEffect
42+ duration = SnackbarDuration.Short,
41− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
43+ )
42− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
44+ // Always clear the error from state after showing.
45+ dispatch(Event.DismissError)
43 }
46 }
44 }
47 }
45
48
46− // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
49+ BackHandler(enabled = state.screen != Screen.Connect) {
47− // the open conversation side by side instead of a single-pane back stack.
50+ when (state.screen) {
48− BoxWithConstraints {
51+ // Leaving mid-generation would silently drop the reply, so confirm first.
49− val isTwoPane = maxWidth >= 840.dp && connection != null
52+ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
50−
53+ // Disconnecting drops the server session, so guard the back button.
51− // Open a session requested by a notification tap or deep link, once connected.
54+ Screen.Sessions -> showDisconnectConfirm = true
52− // In two-pane mode the detail pane consumes the request instead of navigating.
55+ Screen.Connect -> Unit
53− LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
54− val id = pendingOpenSession ?: return@LaunchedEffect
55− if (connection == null || isTwoPane) return@LaunchedEffect
56− container.consumePendingOpenSession()
57− navController.navigate(Routes.chat(id)) { launchSingleTop = true }
58 }
56 }
57+ }
59
58
60− NavHost(navController = navController, startDestination = Routes.SERVERS) {
59+ if (showStopConfirm) {
61−
60+ AlertDialog(
62− composable(Routes.SERVERS) {
61+ onDismissRequest = { showStopConfirm = false },
63− ServerListScreen(
62+ title = { Text("Stop generation?") },
64− container = container,
63+ text = { Text("Leaving will stop the current generation.") },
65− onConnected = { navController.navigate(Routes.SESSIONS) },
64+ confirmButton = {
66− onAddProfile = { navController.navigate(Routes.serverEdit()) },
65+ TextButton(onClick = {
67− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
66+ showStopConfirm = false
68− )
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+ )
69− }
79+ }
70
80
71− composable(
81+ if (showDisconnectConfirm) {
72− route = "${Routes.SERVER_EDIT}?id={id}",
82+ AlertDialog(
73− arguments = listOf(navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null }),
83+ onDismissRequest = { showDisconnectConfirm = false },
74− ) { entry ->
84+ title = { Text("Disconnect?") },
75− ServerEditScreen(
85+ text = { Text("You will be disconnected from the server.") },
76− container = container,
86+ confirmButton = {
77− profileId = entry.arguments?.getString("id"),
87+ TextButton(onClick = {
78− onDone = { navController.popBackStack() },
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+ },
79− )
99+ )
80− }
100+ }
81
101
82− composable(Routes.SESSIONS) {
102+ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
83− if (isTwoPane) {
103+ AnimatedContent(
84− TwoPaneSessionChat(
104+ targetState = state.screen,
85− container = container,
105+ modifier = Modifier.fillMaxSize().padding(padding),
86− onOpenFiles = { navController.navigate(Routes.FILES) },
106+ transitionSpec = {
87− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
107+ // Compare screen ordinals to decide slide direction: forward navigation
88− onDisconnect = {
108+ // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
89− scope.launch { container.disconnect() }
109+ if (targetState.ordinal > initialState.ordinal) {
90− navController.popBackStack(Routes.SERVERS, inclusive = false)
110+ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
91− },
92− )
93− } else {
111+ } else {
94− SessionListScreen(
112+ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
95− container = container,
113+ }
96− onOpenSession = { id ->
97− container.consumePendingShare()?.let { container.draftStore.set(id, it) }
98− navController.navigate(Routes.chat(id))
99− },
114+ },
100− onDisconnect = {
115+ label = "screen",
101− scope.launch { container.disconnect() }
116+ ) { screen ->
102− navController.popBackStack(Routes.SERVERS, inclusive = false)
117+ when (screen) {
103− },
118+ Screen.Connect -> ConnectScreen(state, dispatch)
104− onOpenFiles = { navController.navigate(Routes.FILES) },
119+ Screen.Sessions -> SessionsScreen(state, dispatch)
105− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
120+ Screen.Chat -> ChatScreen(state, dispatch)
106− )
107 }
121 }
108 }
122 }
109−
110− composable(
111− route = "${Routes.CHAT}/{sessionId}",
112− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
113− ) { entry ->
114− ChatScreen(
115− container = container,
116− sessionId = entry.arguments?.getString("sessionId").orEmpty(),
117− onBack = { navController.popBackStack() },
118− )
119− }
120−
121− composable(Routes.FILES) {
122− FileBrowserScreen(
123− container = container,
124− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
125− onBack = { navController.popBackStack() },
126− )
127− }
128−
129− composable(
130− route = "${Routes.FILE_VIEW}?path={path}",
131− arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
132− ) { entry ->
133− FileViewScreen(
134− container = container,
135− path = entry.arguments?.getString("path").orEmpty(),
136− onBack = { navController.popBackStack() },
137− )
138− }
139−
140− composable(Routes.SETTINGS) {
141− SettingsScreen(
142− container = container,
143− onBack = { navController.popBackStack() },
144− onManageServers = {
145− navController.popBackStack(Routes.SERVERS, inclusive = false)
146− },
147− onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
148− )
149− }
150−
151− composable(Routes.DIAGNOSTICS) {
152− DiagnosticsScreen(onBack = { navController.popBackStack() })
153− }
154− }
155 }
123 }
156}
124}
app/src/main/java/soy/iko/opencode/ui/Routes.ktdeleted−20
@@ -1,20 +0,0 @@
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 DIAGNOSTICS = "diagnostics"
9− const val FILES = "files"
10−
11− const val SERVER_EDIT = "server_edit"
12− fun serverEdit(id: String? = null) =
13− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
14−
15− const val CHAT = "chat"
16− fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
17−
18− const val FILE_VIEW = "file_view"
19− fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
20−}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktdeleted−16
@@ -1,16 +0,0 @@
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.ktdeleted−138
@@ -1,138 +0,0 @@
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.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.material.icons.Icons
13−import androidx.compose.material.icons.filled.Check
14−import androidx.compose.material3.CircularProgressIndicator
15−import androidx.compose.material3.ExperimentalMaterial3Api
16−import androidx.compose.material3.Icon
17−import androidx.compose.material3.MaterialTheme
18−import androidx.compose.material3.ModalBottomSheet
19−import androidx.compose.material3.Text
20−import androidx.compose.runtime.Composable
21−import androidx.compose.ui.Alignment
22−import androidx.compose.ui.Modifier
23−import androidx.compose.ui.res.stringResource
24−import androidx.compose.ui.semantics.Role
25−import androidx.compose.ui.semantics.role
26−import androidx.compose.ui.semantics.selected
27−import androidx.compose.ui.semantics.semantics
28−import androidx.compose.ui.unit.dp
29−import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
31−import soy.iko.opencode.data.model.Agent
32−import soy.iko.opencode.R
33−
34−/** Bottom sheet that lists every agent and lets the user pick one. */
35−@OptIn(ExperimentalMaterial3Api::class)
36−@Composable
37−fun AgentPickerSheet(
38− agents: List<Agent>,
39− selected: String?,
40− loading: Boolean,
41− onSelect: (Agent?) -> Unit,
42− onDismiss: () -> Unit,
43−) {
44− ModalBottomSheet(onDismissRequest = onDismiss) {
45− Text(
46− stringResource(R.string.agent),
47− style = MaterialTheme.typography.titleMedium,
48− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
49− )
50− if (loading) {
51− Box(
52− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53− contentAlignment = Alignment.Center,
54− ) {
55− CircularProgressIndicator()
56− }
57− } else if (agents.isEmpty()) {
58− Text(
59− stringResource(R.string.no_agents),
60− style = MaterialTheme.typography.bodyMedium,
61− color = MaterialTheme.colorScheme.onSurfaceVariant,
62− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
63− )
64− } else {
65− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
66− item(key = "__default") {
67− Column(
68− modifier = Modifier
69− .fillMaxWidth()
70− .semantics(mergeDescendants = true) {
71− role = Role.RadioButton
72− if (selected == null) this.selected = true
73− }
74− .clickable { onSelect(null); onDismiss() }
75− .padding(horizontal = 24.dp, vertical = 12.dp),
76− ) {
77− Row(verticalAlignment = Alignment.CenterVertically) {
78− Text(
79− stringResource(R.string.default_agent),
80− style = MaterialTheme.typography.bodyLarge,
81− color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
82− modifier = Modifier.weight(1f),
83− )
84− if (selected == null) {
85− Icon(
86− Icons.Filled.Check,
87− contentDescription = null,
88− tint = MaterialTheme.colorScheme.primary,
89− modifier = Modifier.size(18.dp),
90− )
91− }
92− }
93− Text(
94− stringResource(R.string.default_agent_desc),
95− style = MaterialTheme.typography.bodySmall,
96− color = MaterialTheme.colorScheme.onSurfaceVariant,
97− )
98− }
99− }
100− items(agents, key = { it.name }) { agent ->
101− val isSelected = agent.name == selected
102− Column(
103− modifier = Modifier
104− .fillMaxWidth()
105− .semantics(mergeDescendants = true) {
106− role = Role.RadioButton
107− if (isSelected) this.selected = true
108− }
109− .clickable { onSelect(agent); onDismiss() }
110− .padding(horizontal = 24.dp, vertical = 12.dp),
111− ) {
112− Row(verticalAlignment = Alignment.CenterVertically) {
113− Text(
114− agent.name,
115− style = MaterialTheme.typography.bodyLarge,
116− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
117− modifier = Modifier.weight(1f),
118− )
119− if (isSelected) {
120− Icon(
121− Icons.Filled.Check,
122− contentDescription = null,
123− tint = MaterialTheme.colorScheme.primary,
124− modifier = Modifier.size(18.dp),
125− )
126− }
127− }
128− Text(
129− agent.displayDescription,
130− style = MaterialTheme.typography.bodySmall,
131− color = MaterialTheme.colorScheme.onSurfaceVariant,
132− )
133− }
134− }
135− }
136− }
137− }
138−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktdeleted−472
@@ -1,472 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.activity.compose.BackHandler
4−import androidx.compose.animation.AnimatedVisibility
5−import androidx.compose.foundation.clickable
6−import androidx.compose.foundation.layout.Arrangement
7−import androidx.compose.foundation.layout.Box
8−import androidx.compose.foundation.layout.Column
9−import androidx.compose.foundation.layout.PaddingValues
10−import androidx.compose.foundation.layout.Row
11−import androidx.compose.foundation.layout.Spacer
12−import androidx.compose.foundation.layout.WindowInsets
13−import androidx.compose.foundation.layout.fillMaxSize
14−import androidx.compose.foundation.layout.fillMaxWidth
15−import androidx.compose.foundation.layout.imePadding
16−import androidx.compose.foundation.layout.navigationBars
17−import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
19−import androidx.compose.foundation.layout.union
20−import androidx.compose.foundation.layout.windowInsetsPadding
21−import androidx.compose.foundation.lazy.LazyColumn
22−import androidx.compose.foundation.lazy.items
23−import androidx.compose.foundation.lazy.rememberLazyListState
24−import androidx.compose.material.icons.Icons
25−import androidx.compose.material.icons.automirrored.filled.ArrowBack
26−import androidx.compose.material.icons.automirrored.filled.Send
27−import androidx.compose.material.icons.filled.AutoAwesome
28−import androidx.compose.material.icons.filled.KeyboardArrowDown
29−import androidx.compose.material.icons.filled.Share
30−import androidx.compose.material.icons.filled.SmartToy
31−import androidx.compose.material.icons.filled.Stop
32−import androidx.compose.material.icons.filled.Terminal
33−import androidx.compose.material.icons.filled.Tune
34−import androidx.compose.material3.AlertDialog
35−import androidx.compose.material3.Button
36−import androidx.compose.material3.CircularProgressIndicator
37−import androidx.compose.material3.ExperimentalMaterial3Api
38−import androidx.compose.material3.ExtendedFloatingActionButton
39−import androidx.compose.material3.FloatingActionButton
40−import androidx.compose.material3.Icon
41−import androidx.compose.material3.IconButton
42−import androidx.compose.material3.MaterialTheme
43−import androidx.compose.material3.OutlinedTextField
44−import androidx.compose.material3.Scaffold
45−import androidx.compose.material3.SnackbarHost
46−import androidx.compose.material3.SnackbarHostState
47−import androidx.compose.material3.Surface
48−import androidx.compose.material3.Text
49−import androidx.compose.material3.TextButton
50−import androidx.compose.material3.TopAppBar
51−import androidx.compose.runtime.Composable
52−import androidx.compose.runtime.DisposableEffect
53−import androidx.compose.runtime.LaunchedEffect
54−import androidx.compose.runtime.derivedStateOf
55−import androidx.compose.runtime.getValue
56−import androidx.compose.runtime.mutableStateOf
57−import androidx.compose.runtime.remember
58−import androidx.compose.runtime.rememberCoroutineScope
59−import androidx.compose.runtime.setValue
60−import androidx.compose.foundation.text.KeyboardActions
61−import androidx.compose.foundation.text.KeyboardOptions
62−import androidx.compose.ui.Alignment
63−import androidx.compose.ui.Modifier
64−import androidx.compose.ui.input.key.Key
65−import androidx.compose.ui.input.key.KeyEventType
66−import androidx.compose.ui.input.key.isShiftPressed
67−import androidx.compose.ui.input.key.key
68−import androidx.compose.ui.input.key.onPreviewKeyEvent
69−import androidx.compose.ui.input.key.type
70−import androidx.compose.ui.platform.LocalContext
71−import androidx.compose.ui.platform.LocalHapticFeedback
72−import androidx.compose.ui.platform.LocalView
73−import androidx.compose.ui.res.stringResource
74−import androidx.compose.ui.semantics.Role
75−import androidx.compose.ui.semantics.role
76−import androidx.compose.ui.semantics.semantics
77−import androidx.compose.ui.text.input.ImeAction
78−import androidx.compose.ui.unit.dp
79−import androidx.lifecycle.compose.collectAsStateWithLifecycle
80−import androidx.lifecycle.viewmodel.compose.viewModel
81−import kotlinx.coroutines.launch
82−import soy.iko.opencode.data.network.EventStreamClient
83−import soy.iko.opencode.data.model.ReasoningPart
84−import soy.iko.opencode.data.model.TextPart
85−import soy.iko.opencode.di.AppContainer
86−import soy.iko.opencode.R
87−import soy.iko.opencode.ui.components.ConnectionBanner
88−import soy.iko.opencode.ui.components.toImageContext
89−import soy.iko.opencode.ui.vmFactory
90−
91−@OptIn(ExperimentalMaterial3Api::class)
92−@Composable
93−fun ChatScreen(
94− container: AppContainer,
95− sessionId: String,
96− onBack: () -> Unit,
97−) {
98− val vm: ChatViewModel = viewModel(factory = vmFactory { ChatViewModel(container, sessionId) })
99− val messages by vm.messages.collectAsStateWithLifecycle()
100− val running by vm.running.collectAsStateWithLifecycle()
101− val error by vm.error.collectAsStateWithLifecycle()
102− val loading by vm.loading.collectAsStateWithLifecycle()
103− val models by vm.models.collectAsStateWithLifecycle()
104− val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
105− val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
106− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
107− val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
108− val agents by vm.agents.collectAsStateWithLifecycle()
109− val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
110− val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
111− val commands by vm.commands.collectAsStateWithLifecycle()
112− val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
113− val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
114− val failedDraft by vm.failedDraft.collectAsStateWithLifecycle()
115− val draft by vm.draft.collectAsStateWithLifecycle()
116− val haptics = LocalHapticFeedback.current
117− val scope = rememberCoroutineScope()
118− val shareContext = LocalContext.current
119− val shareLabel = stringResource(R.string.share)
120− val defaultShareSubject = stringResource(R.string.share_subject)
121−
122− // Connection profile (with auth) for rendering inline image attachments.
123− // Collect as state so a server switch recomposes the image context.
124− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
125− val imageContext = remember(activeConnection?.profile) {
126− activeConnection?.profile?.toImageContext()
127− }
128−
129− // Mark this session as read while the user is viewing it; clear on leave so new
130− // background activity can badge it again.
131− DisposableEffect(sessionId) {
132− container.setCurrentSession(sessionId)
133− onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
134− }
135−
136− val listState = rememberLazyListState()
137− val snackbar = remember { SnackbarHostState() }
138− var showModelPicker by remember { mutableStateOf(false) }
139− var showAgentPicker by remember { mutableStateOf(false) }
140− var showCommandPicker by remember { mutableStateOf(false) }
141− var showExitConfirm by remember { mutableStateOf(false) }
142−
143− // Keep the screen awake and hold a foreground priority while the agent is working,
144− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
145− val currentView = LocalView.current
146− val appContext = LocalContext.current.applicationContext
147− DisposableEffect(running) {
148− currentView.keepScreenOn = running
149− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
150− onDispose {
151− currentView.keepScreenOn = false
152− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
153− }
154− }
155−
156− // Track whether the list is pinned to the bottom so streaming auto-scroll
157− // doesn't fight a user who scrolled up to read earlier output.
158− val isPinnedToBottom by remember {
159− derivedStateOf {
160− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
161− lastVisible >= messages.lastIndex || messages.isEmpty()
162− }
163− }
164−
165− // Jump to the latest message when the conversation first loads so the user
166− // lands on recent context, not the oldest.
167− LaunchedEffect(messages.isNotEmpty()) {
168− if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
169− }
170−
171− // Keep the newest content in view as parts stream in — only if already at the bottom.
172− // Include the growing text length so we follow streaming within a single part, not
173− // only when a brand-new message or part is added.
174− val lastPartLen = messages.lastOrNull()?.parts?.lastOrNull()?.let { part ->
175− when (part) {
176− is TextPart -> part.text.length
177− is ReasoningPart -> part.text.length
178− else -> 0
179− }
180− } ?: 0
181− LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size, lastPartLen) {
182− if (messages.isNotEmpty() && isPinnedToBottom) {
183− listState.animateScrollToItem(messages.lastIndex)
184− }
185− }
186− val retryLabel = stringResource(R.string.retry)
187− LaunchedEffect(error) {
188− val msg = error ?: return@LaunchedEffect
189− val result = if (failedDraft != null) {
190− snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
191− } else {
192− snackbar.showSnackbar(msg)
193− }
194− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
195− vm.clearError()
196− }
197−
198− fun doSend() {
199− if (vm.send(draft)) {
200− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
201− }
202− }
203−
204− BackHandler(enabled = running) { showExitConfirm = true }
205−
206− Scaffold(
207− topBar = {
208− TopAppBar(
209− title = {
210− Column(modifier = Modifier
211− .clickable(enabled = models.isNotEmpty()) { showModelPicker = true }
212− .semantics { role = Role.Button }
213− ) {
214− Text(sessionTitle ?: stringResource(R.string.session))
215− Text(
216− buildString {
217− append(selectedModel?.modelLabel ?: stringResource(R.string.default_model))
218− selectedAgent?.let { append(" · $it") }
219− },
220− style = MaterialTheme.typography.labelSmall,
221− color = MaterialTheme.colorScheme.onSurfaceVariant,
222− )
223− }
224− },
225− navigationIcon = {
226− IconButton(onClick = {
227− if (running) showExitConfirm = true else onBack()
228− }) {
229− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
230− }
231− },
232− actions = {
233− IconButton(
234− onClick = {
235− val md = buildConversationMarkdown(messages, sessionTitle)
236− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
237− type = "text/markdown"
238− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
239− putExtra(android.content.Intent.EXTRA_TEXT, md)
240− }
241− runCatching { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
242− },
243− enabled = messages.isNotEmpty(),
244− ) {
245− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
246− }
247− IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
248− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
249− }
250− IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
251− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
252− }
253− IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
254− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
255− }
256− },
257− )
258− },
259− snackbarHost = { SnackbarHost(snackbar) },
260− bottomBar = {
261− ChatInputBar(
262− value = draft,
263− onValueChange = vm::updateDraft,
264− running = running,
265− enabled = vm.connected,
266− onSend = ::doSend,
267− onAbort = { vm.abort() },
268− )
269− },
270− ) { padding ->
271− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
272− if (!vm.connected) {
273− Column(
274− modifier = Modifier.align(Alignment.Center).padding(24.dp),
275− horizontalAlignment = Alignment.CenterHorizontally,
276− ) {
277− Text(
278− stringResource(R.string.not_connected),
279− color = MaterialTheme.colorScheme.error,
280− )
281− Spacer(Modifier.size(12.dp))
282− Button(onClick = { vm.reconnect() }) {
283− Text(stringResource(R.string.reconnect))
284− }
285− }
286− } else {
287− ConnectionBanner(
288− state = connectionState,
289− modifier = Modifier.align(Alignment.TopCenter),
290− )
291− if (loading && messages.isEmpty()) {
292− CircularProgressIndicator(Modifier.align(Alignment.Center))
293− } else if (messages.isEmpty() && !running) {
294− EmptyConversation(
295− modifier = Modifier.align(Alignment.Center),
296− )
297− } else {
298− LazyColumn(
299− state = listState,
300− modifier = Modifier.fillMaxSize(),
301− contentPadding = PaddingValues(16.dp),
302− verticalArrangement = Arrangement.spacedBy(16.dp),
303− ) {
304− items(messages, key = { it.info.id }) { message ->
305− MessageBubble(message, isRunning = running, imageContext = imageContext)
306− }
307− if (running) {
308− item(key = "__typing") {
309− Row(verticalAlignment = Alignment.CenterVertically) {
310− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
311− Text(stringResource(R.string.working), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
312− }
313− }
314− }
315− }
316− // Jump-to-latest affordance when the user has scrolled away during a stream.
317− AnimatedVisibility(
318− visible = !isPinnedToBottom && messages.isNotEmpty(),
319− modifier = Modifier.align(Alignment.BottomEnd),
320− ) {
321− ExtendedFloatingActionButton(
322− onClick = {
323− if (messages.isNotEmpty()) {
324− scope.launch { listState.animateScrollToItem(messages.lastIndex) }
325− }
326− },
327− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
328− text = { Text(stringResource(R.string.latest)) },
329− modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
330− )
331− }
332− }
333− }
334− }
335− }
336−
337− if (showExitConfirm) {
338− AlertDialog(
339− onDismissRequest = { showExitConfirm = false },
340− title = { Text(stringResource(R.string.stop_and_exit_title)) },
341− text = { Text(stringResource(R.string.stop_and_exit_text)) },
342− confirmButton = {
343− TextButton(onClick = {
344− showExitConfirm = false
345− vm.abort()
346− onBack()
347− }) { Text(stringResource(R.string.stop_and_exit)) }
348− },
349− dismissButton = {
350− TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
351− },
352− )
353− }
354−
355− if (showModelPicker) {
356− ModelPickerSheet(
357− options = models,
358− selected = selectedModel,
359− loading = modelsLoading,
360− onSelect = { vm.selectModel(it) },
361− onDismiss = { showModelPicker = false },
362− )
363− }
364−
365− if (showAgentPicker) {
366− AgentPickerSheet(
367− agents = agents,
368− selected = selectedAgent,
369− loading = agentsLoading,
370− onSelect = { vm.selectAgent(it?.name) },
371− onDismiss = { showAgentPicker = false },
372− )
373− }
374−
375− if (showCommandPicker) {
376− CommandPickerSheet(
377− commands = commands,
378− loading = commandsLoading,
379− onSelect = { vm.runCommand(it) },
380− onDismiss = { showCommandPicker = false },
381− )
382− }
383−
384− pendingPermission?.let { permission ->
385− PermissionDialog(
386− permission = permission,
387− onRespond = { response -> vm.respondPermission(permission, response) },
388− )
389− }
390−}
391−
392−@Composable
393−private fun ChatInputBar(
394− value: String,
395− onValueChange: (String) -> Unit,
396− running: Boolean,
397− enabled: Boolean,
398− onSend: () -> Unit,
399− onAbort: () -> Unit,
400−) {
401− Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
402− Row(
403− modifier = Modifier.fillMaxWidth()
404− .windowInsetsPadding(WindowInsets.navigationBars)
405− .padding(8.dp),
406− verticalAlignment = Alignment.Bottom,
407− ) {
408− OutlinedTextField(
409− value = value,
410− onValueChange = onValueChange,
411− modifier = Modifier
412− .weight(1f)
413− .onPreviewKeyEvent { event ->
414− if (event.type == KeyEventType.KeyDown &&
415− event.key == Key.Enter &&
416− !event.isShiftPressed &&
417− enabled && value.isNotBlank()
418− ) {
419− onSend()
420− true
421− } else {
422− false
423− }
424− },
425− placeholder = { Text(stringResource(R.string.message_placeholder)) },
426− enabled = enabled,
427− maxLines = 6,
428− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
429− keyboardActions = KeyboardActions(onSend = { if (enabled && value.isNotBlank()) onSend() }),
430− )
431− if (running) {
432− IconButton(onClick = onAbort, modifier = Modifier.padding(start = 4.dp)) {
433− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
434− }
435− } else {
436− IconButton(
437− onClick = onSend,
438− enabled = enabled && value.isNotBlank(),
439− modifier = Modifier.padding(start = 4.dp),
440− ) {
441− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
442− }
443− }
444− }
445− }
446−}
447−
448−@Composable
449−private fun EmptyConversation(modifier: Modifier = Modifier) {
450− Column(
451− modifier = modifier.padding(32.dp),
452− horizontalAlignment = Alignment.CenterHorizontally,
453− ) {
454− Icon(
455− Icons.Filled.AutoAwesome,
456− contentDescription = null,
457− modifier = Modifier.size(48.dp),
458− tint = MaterialTheme.colorScheme.onSurfaceVariant,
459− )
460− Spacer(Modifier.size(16.dp))
461− Text(
462− stringResource(R.string.empty_chat_title),
463− style = MaterialTheme.typography.titleMedium,
464− )
465− Spacer(Modifier.size(8.dp))
466− Text(
467− stringResource(R.string.empty_chat_hint),
468− style = MaterialTheme.typography.bodyMedium,
469− color = MaterialTheme.colorScheme.onSurfaceVariant,
470− )
471− }
472−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktdeleted−313
@@ -1,313 +0,0 @@
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.SessionDeleted
14−import soy.iko.opencode.data.model.SessionUpdated
15−import soy.iko.opencode.data.model.TextPart
16−import soy.iko.opencode.data.model.defaultOption
17−import soy.iko.opencode.data.model.toOptions
18−import soy.iko.opencode.data.network.EventStreamClient
19−import soy.iko.opencode.data.repo.SessionRepository
20−import soy.iko.opencode.di.AppContainer
21−import soy.iko.opencode.R
22−import soy.iko.opencode.util.runCatchingCancellable
23−import android.util.Log
24−import kotlinx.coroutines.ExperimentalCoroutinesApi
25−import kotlinx.coroutines.flow.MutableStateFlow
26−import kotlinx.coroutines.flow.SharingStarted
27−import kotlinx.coroutines.flow.StateFlow
28−import kotlinx.coroutines.flow.asStateFlow
29−import kotlinx.coroutines.flow.collectLatest
30−import kotlinx.coroutines.flow.first
31−import kotlinx.coroutines.flow.flatMapLatest
32−import kotlinx.coroutines.flow.flowOf
33−import kotlinx.coroutines.flow.onEach
34−import kotlinx.coroutines.flow.stateIn
35−import kotlinx.coroutines.launch
36−
37−@OptIn(ExperimentalCoroutinesApi::class)
38−class ChatViewModel(
39− private val container: AppContainer,
40− private val sessionId: String,
41−) : ViewModel() {
42−
43− private val connection get() = container.activeConnection.value
44−
45− val connected: Boolean get() = connection != null
46−
47− private val _loading = MutableStateFlow(true)
48− val loading: StateFlow<Boolean> = _loading.asStateFlow()
49−
50− val messages: StateFlow<List<MessageWithParts>> =
51− container.activeConnection
52− .flatMapLatest { conn ->
53− conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
54− }
55− .onEach { _loading.value = false }
56− .stateIn(
57− scope = viewModelScope,
58− started = SharingStarted.WhileSubscribed(5_000),
59− initialValue = emptyList(),
60− )
61−
62− private val _running = MutableStateFlow(false)
63− val running: StateFlow<Boolean> = _running.asStateFlow()
64−
65− private val _error = MutableStateFlow<String?>(null)
66− val error: StateFlow<String?> = _error.asStateFlow()
67−
68− private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
69− val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
70−
71− private val _modelsLoading = MutableStateFlow(true)
72− val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
73−
74− private val _selectedModel = MutableStateFlow<ModelOption?>(null)
75− val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
76−
77− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
78− container.activeConnection
79− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
80− .stateIn(
81− scope = viewModelScope,
82− started = SharingStarted.WhileSubscribed(5_000),
83− initialValue = EventStreamClient.ConnectionState.Disconnected,
84− )
85−
86− private val _pendingPermission = MutableStateFlow<Permission?>(null)
87− val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
88−
89− private val _agents = MutableStateFlow<List<Agent>>(emptyList())
90− val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
91−
92− private val _agentsLoading = MutableStateFlow(true)
93− val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
94−
95− private val _selectedAgent = MutableStateFlow<String?>(null)
96− val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
97−
98− private val _commands = MutableStateFlow<List<Command>>(emptyList())
99− val commands: StateFlow<List<Command>> = _commands.asStateFlow()
100−
101− private val _commandsLoading = MutableStateFlow(true)
102− val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
103−
104− private val _sessionTitle = MutableStateFlow<String?>(null)
105− val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
106−
107− /** The text of the last send that failed, surfaced so the UI can offer a retry. */
108− private val _failedDraft = MutableStateFlow<String?>(null)
109− val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
110−
111− /** Per-session draft, persisted so it survives navigation/process death. */
112− private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
113− val draft: StateFlow<String> = _draft.asStateFlow()
114−
115− fun clearError() { _error.value = null }
116−
117− fun selectModel(option: ModelOption) { _selectedModel.value = option }
118−
119− fun selectAgent(name: String?) { _selectedAgent.value = name }
120−
121− fun updateDraft(text: String) {
122− _draft.value = text
123− container.draftStore.set(sessionId, text)
124− }
125−
126− init {
127− // Watch the bus for run completion / errors / permission asks for this session.
128− viewModelScope.launch {
129− container.activeConnection.collectLatest { conn ->
130− if (conn == null) return@collectLatest
131− conn.events.events.collect { event ->
132− if (SessionRepository.isIdle(event, sessionId)) _running.value = false
133− if (SessionRepository.isError(event, sessionId)) {
134− _running.value = false
135− _error.value = container.string(R.string.error_agent_reported)
136− }
137− when (event) {
138− is PermissionUpdated ->
139− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
140− is PermissionReplied ->
141− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
142− is SessionUpdated ->
143− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
144− is SessionDeleted ->
145− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
146− _sessionTitle.value = event.properties.info?.displayTitle
147− }
148− else -> {}
149− }
150− }
151− }
152− }
153− // Load the model catalog; preselect the server default. Failure is non-fatal —
154− // sending with no model just uses the server's default agent/model.
155− viewModelScope.launch {
156− container.activeConnection.collectLatest { conn ->
157− if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; _modelsLoading.value = false; return@collectLatest }
158− _modelsLoading.value = true
159− runCatchingCancellable { conn.api.providers() }
160− .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
161− .getOrNull()?.let { resp ->
162− val options = resp.toOptions()
163− _models.value = options
164− _selectedModel.value = resp.defaultOption(options)
165− }
166− _modelsLoading.value = false
167− }
168− }
169− // Load the agent catalog (non-fatal).
170− viewModelScope.launch {
171− container.activeConnection.collectLatest { conn ->
172− if (conn == null) { _agents.value = emptyList(); _agentsLoading.value = false; return@collectLatest }
173− _agentsLoading.value = true
174− runCatchingCancellable { conn.api.agents() }
175− .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
176− .getOrNull()?.let { _agents.value = it }
177− _agentsLoading.value = false
178− }
179− }
180− // Load the command catalog (non-fatal).
181− viewModelScope.launch {
182− container.activeConnection.collectLatest { conn ->
183− if (conn == null) { _commands.value = emptyList(); _commandsLoading.value = false; return@collectLatest }
184− _commandsLoading.value = true
185− runCatchingCancellable { conn.api.commands() }
186− .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
187− .getOrNull()?.let { _commands.value = it }
188− _commandsLoading.value = false
189− }
190− }
191− // Resolve the human-readable session title for the app bar (non-fatal).
192− viewModelScope.launch {
193− container.activeConnection.collectLatest { conn ->
194− if (conn == null) return@collectLatest
195− runCatchingCancellable { conn.repository.listSessions() }
196− .getOrNull()
197− ?.firstOrNull { it.id == sessionId }
198− ?.let { _sessionTitle.value = it.displayTitle }
199− }
200− }
201− // If the SSE stream drops mid-run, the run indicator would spin forever;
202− // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
203− viewModelScope.launch {
204− container.activeConnection.collectLatest { conn ->
205− if (conn == null) return@collectLatest
206− conn.events.state.collect { state ->
207− if (state == EventStreamClient.ConnectionState.Disconnected && _running.value) {
208− _running.value = false
209− }
210− }
211− }
212− }
213− }
214−
215− /** Sends [text]; returns true on success so the caller can clear the draft only then. */
216− fun send(text: String): Boolean {
217− val conn = connection ?: return false
218− val trimmed = text.trim()
219− if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
220− _error.value = null
221− _failedDraft.value = null
222− // Clear the in-memory draft for the UI immediately, but don't persist the clear
223− // yet — if the send fails and the process dies before we restore, the draft
224− // would be lost forever. The persisted draft is cleared only on success.
225− _draft.value = ""
226− viewModelScope.launch {
227− runCatchingCancellable {
228− conn.repository.sendPrompt(
229− sessionId,
230− trimmed,
231− model = _selectedModel.value?.ref,
232− agent = _selectedAgent.value,
233− )
234− }.onFailure {
235− _failedDraft.value = trimmed
236− // Only restore the draft if the user hasn't typed anything new since.
237− if (_draft.value.isBlank()) updateDraft(trimmed)
238− _error.value = container.friendlyError(it)
239− }.onSuccess {
240− // Send succeeded — now it's safe to persist the empty draft.
241− container.draftStore.set(sessionId, "")
242− }
243− _running.value = false
244− }
245− return true
246− }
247−
248− /** Re-send the last draft whose send failed, if any. */
249− fun retryFailed() {
250− val draft = _failedDraft.value ?: return
251− _failedDraft.value = null
252− send(draft)
253− }
254−
255− /** Invoke a slash-command by name via the server's /command endpoint. */
256− fun runCommand(command: Command) {
257− val conn = connection ?: return
258− if (!_running.compareAndSet(false, true)) return
259− _error.value = null
260− viewModelScope.launch {
261− runCatchingCancellable {
262− conn.repository.runCommand(sessionId, command.name, agent = command.agent)
263− }.onFailure { _error.value = container.friendlyError(it) }
264− _running.value = false
265− }
266− }
267−
268− fun abort() {
269− val conn = connection ?: return
270− viewModelScope.launch {
271− runCatchingCancellable { conn.repository.abort(sessionId) }
272− .onSuccess { _running.value = false }
273− .onFailure { _error.value = container.friendlyError(it) }
274− }
275− }
276−
277− /** Reconnect to the most recently used server profile (used when the connection is gone). */
278− fun reconnect() {
279− if (container.activeConnection.value != null) return
280− viewModelScope.launch {
281− _loading.value = true
282− val recent = runCatching { container.profileStore.profiles.first() }
283− .getOrDefault(emptyList())
284− .firstOrNull() ?: run { _loading.value = false; return@launch }
285− runCatchingCancellable {
286− val conn = container.connect(recent)
287− conn.api.ping()
288− }.onSuccess {
289− _loading.value = false
290− }.onFailure {
291− container.disconnect()
292− _error.value = container.friendlyError(it)
293− _loading.value = false
294− }
295− }
296− }
297−
298− fun respondPermission(permission: Permission, response: PermissionResponse) {
299− val conn = connection ?: return
300− // Clear optimistically; permission.replied will confirm. If the call fails we
301− // restore the pending permission so the user can retry instead of being stuck
302− // with a dismissed dialog and a paused tool run.
303− _pendingPermission.value = null
304− viewModelScope.launch {
305− runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
306− .onFailure {
307− _error.value = container.friendlyError(it)
308− // Only restore if no new permission has arrived in the meantime.
309− if (_pendingPermission.value == null) _pendingPermission.value = permission
310− }
311− }
312− }
313−}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktdeleted−91
@@ -1,91 +0,0 @@
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.CircularProgressIndicator
13−import androidx.compose.material3.ExperimentalMaterial3Api
14−import androidx.compose.material3.Icon
15−import androidx.compose.material3.MaterialTheme
16−import androidx.compose.material3.ModalBottomSheet
17−import androidx.compose.material3.Text
18−import androidx.compose.runtime.Composable
19−import androidx.compose.ui.Alignment
20−import androidx.compose.ui.Modifier
21−import androidx.compose.ui.res.stringResource
22−import androidx.compose.ui.semantics.Role
23−import androidx.compose.ui.semantics.role
24−import androidx.compose.ui.text.style.TextOverflow
25−import androidx.compose.ui.unit.dp
26−import androidx.compose.foundation.layout.Box
27−import soy.iko.opencode.data.model.Command
28−import soy.iko.opencode.R
29−
30−/** Bottom sheet that lists slash-commands; selecting one invokes it. */
31−@OptIn(ExperimentalMaterial3Api::class)
32−@Composable
33−fun CommandPickerSheet(
34− commands: List<Command>,
35− loading: Boolean,
36− onSelect: (Command) -> Unit,
37− onDismiss: () -> Unit,
38−) {
39− ModalBottomSheet(onDismissRequest = onDismiss) {
40− Text(
41− stringResource(R.string.commands),
42− style = MaterialTheme.typography.titleMedium,
43− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
44− )
45− if (loading) {
46− Box(
47− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
48− contentAlignment = Alignment.Center,
49− ) {
50− CircularProgressIndicator()
51− }
52− } else if (commands.isEmpty()) {
53− Text(
54− stringResource(R.string.no_commands),
55− style = MaterialTheme.typography.bodyMedium,
56− color = MaterialTheme.colorScheme.onSurfaceVariant,
57− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
58− )
59− } else {
60− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
61− items(commands, key = { it.name }) { cmd ->
62− Column(
63− modifier = Modifier
64− .fillMaxWidth()
65− .clickable(role = Role.Button) { onSelect(cmd); onDismiss() }
66− .padding(horizontal = 24.dp, vertical = 12.dp),
67− ) {
68− androidx.compose.foundation.layout.Row(
69− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
70− ) {
71− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
72− Text(
73− "/${cmd.name}",
74− style = MaterialTheme.typography.bodyLarge,
75− modifier = Modifier.padding(start = 8.dp),
76− )
77− }
78− Text(
79− cmd.displayDescription,
80− style = MaterialTheme.typography.bodySmall,
81− color = MaterialTheme.colorScheme.onSurfaceVariant,
82− maxLines = 2,
83− overflow = TextOverflow.Ellipsis,
84− modifier = Modifier.padding(start = 28.dp, top = 2.dp),
85− )
86− }
87− }
88− }
89− }
90− }
91−}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktdeleted−40
@@ -1,40 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.MessageWithParts
5−import soy.iko.opencode.data.model.ReasoningPart
6−import soy.iko.opencode.data.model.TextPart
7−import soy.iko.opencode.data.model.UserMessage
8−
9−/**
10− * Render a conversation as Markdown for sharing/exporting. Each message becomes a
11− * role-prefixed section containing its text parts; reasoning and tool parts are
12− * omitted to keep the exported transcript readable. Pure (no Android deps) so it can
13− * be unit-tested directly.
14− */
15−fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
16− val sb = StringBuilder()
17− if (!title.isNullOrBlank()) {
18− sb.append("# ").append(title.trim()).append("\n\n")
19− }
20− for (message in messages) {
21− val heading = when (message.info) {
22− is UserMessage -> "## You"
23− is AssistantMessage -> "## opencode"
24− else -> null
25− } ?: continue
26− val body = message.parts
27− .mapNotNull { part ->
28− when (part) {
29− is TextPart -> part.text.takeIf { it.isNotBlank() }
30− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }?.let { "_${it}_" }
31− else -> null
32− }
33− }
34− .joinToString("\n\n")
35− .trim()
36− if (body.isEmpty()) continue
37− sb.append(heading).append("\n\n").append(body).append("\n\n")
38− }
39− return sb.toString().trimEnd()
40−}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktdeleted−118
@@ -1,118 +0,0 @@
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.shape.RoundedCornerShape
10−import androidx.compose.material3.MaterialTheme
11−import androidx.compose.material3.Text
12−import androidx.compose.runtime.Composable
13−import androidx.compose.ui.Alignment
14−import androidx.compose.ui.Modifier
15−import androidx.compose.ui.draw.clip
16−import androidx.compose.ui.res.stringResource
17−import androidx.compose.ui.unit.dp
18−import soy.iko.opencode.R
19−import soy.iko.opencode.data.model.AssistantMessage
20−import soy.iko.opencode.data.model.MessageWithParts
21−import soy.iko.opencode.data.model.Tokens
22−import soy.iko.opencode.data.model.UserMessage
23−import soy.iko.opencode.ui.components.ImageLoadContext
24−import soy.iko.opencode.ui.components.rememberRelativeTime
25−
26−/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
27−@Composable
28−fun MessageBubble(
29− message: MessageWithParts,
30− modifier: Modifier = Modifier,
31− isRunning: Boolean = false,
32− imageContext: ImageLoadContext? = null,
33−) {
34− when (message.info) {
35− is UserMessage -> UserBubble(message, imageContext, modifier)
36− else -> AssistantBlock(message, isRunning, imageContext, modifier)
37− }
38−}
39−
40−@Composable
41−private fun UserBubble(message: MessageWithParts, imageContext: ImageLoadContext?, modifier: Modifier) {
42− Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
43− Column(
44− modifier = Modifier
45− .fillMaxWidth(0.85f)
46− .clip(RoundedCornerShape(16.dp))
47− .background(MaterialTheme.colorScheme.primaryContainer)
48− .padding(12.dp),
49− verticalArrangement = Arrangement.spacedBy(6.dp),
50− ) {
51− for (part in message.parts) PartView(part, imageContext = imageContext)
52− MessageTimestamp(message.info)
53− }
54− }
55−}
56−
57−@Composable
58−private fun AssistantBlock(message: MessageWithParts, isRunning: Boolean, imageContext: ImageLoadContext?, modifier: Modifier) {
59− Column(
60− modifier = modifier.fillMaxWidth(),
61− verticalArrangement = Arrangement.spacedBy(8.dp),
62− ) {
63− val info = message.info
64− if (info is AssistantMessage && info.modelID != null) {
65− Text(
66− info.modelID,
67− style = MaterialTheme.typography.labelSmall,
68− color = MaterialTheme.colorScheme.onSurfaceVariant,
69− )
70− }
71− for (part in message.parts) {
72− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext)
73− }
74− if (info is AssistantMessage) {
75− val cost = info.cost
76− val tokens = info.tokens
77− if (info.isComplete && (cost != null || tokens != null)) {
78− val tokenFormat = stringResource(R.string.tokens_in_out)
79− val parts = buildList {
80− tokens?.let { add(formatTokens(it, tokenFormat)) }
81− cost?.takeIf { it > 0 }?.let { add(formatCost(it)) }
82− }
83− if (parts.isNotEmpty()) {
84− Text(
85− parts.joinToString(" • "),
86− style = MaterialTheme.typography.labelSmall,
87− color = MaterialTheme.colorScheme.onSurfaceVariant,
88− )
89− }
90− }
91− }
92− MessageTimestamp(message.info)
93− }
94−}
95−
96−@Composable
97−private fun MessageTimestamp(info: soy.iko.opencode.data.model.MessageInfo) {
98− val t = rememberRelativeTime(info.time?.created)
99− if (t.isNotEmpty()) {
100− Text(
101− t,
102− style = MaterialTheme.typography.labelSmall,
103− color = MaterialTheme.colorScheme.onSurfaceVariant,
104− )
105− }
106−}
107−
108−private val tokenNumberFormat: java.text.NumberFormat =
109− java.text.DecimalFormat.getNumberInstance(java.util.Locale.US)
110−
111−private fun formatTokens(tokens: Tokens, format: String): String =
112− format.format(tokenNumberFormat.format(tokens.input), tokenNumberFormat.format(tokens.output))
113−
114−private fun formatCost(cost: Double): String =
115− // Locale.US so the formatting is stable regardless of device locale (avoids
116− // non-ASCII digits or comma decimal separators in a dollar amount).
117− if (cost < 0.01) String.format(java.util.Locale.US, "$%.4f", cost)
118− else String.format(java.util.Locale.US, "$%.2f", cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktdeleted−108
@@ -1,108 +0,0 @@
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.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.material.icons.Icons
13−import androidx.compose.material.icons.filled.Check
14−import androidx.compose.material3.CircularProgressIndicator
15−import androidx.compose.material3.ExperimentalMaterial3Api
16−import androidx.compose.material3.Icon
17−import androidx.compose.material3.MaterialTheme
18−import androidx.compose.material3.ModalBottomSheet
19−import androidx.compose.material3.Text
20−import androidx.compose.runtime.Composable
21−import androidx.compose.ui.Alignment
22−import androidx.compose.ui.Modifier
23−import androidx.compose.ui.res.stringResource
24−import androidx.compose.ui.semantics.Role
25−import androidx.compose.ui.semantics.role
26−import androidx.compose.ui.semantics.selected
27−import androidx.compose.ui.semantics.semantics
28−import androidx.compose.ui.unit.dp
29−import androidx.compose.foundation.layout.Box
30−import androidx.compose.foundation.layout.fillMaxWidth
31−import soy.iko.opencode.data.model.ModelOption
32−import soy.iko.opencode.R
33−
34−/** Bottom sheet that lists every provider/model and lets the user pick one. */
35−@OptIn(ExperimentalMaterial3Api::class)
36−@Composable
37−fun ModelPickerSheet(
38− options: List<ModelOption>,
39− selected: ModelOption?,
40− loading: Boolean,
41− onSelect: (ModelOption) -> Unit,
42− onDismiss: () -> Unit,
43−) {
44− ModalBottomSheet(onDismissRequest = onDismiss) {
45− Text(
46− stringResource(R.string.model),
47− style = MaterialTheme.typography.titleMedium,
48− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
49− )
50− if (loading) {
51− Box(
52− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
53− contentAlignment = Alignment.Center,
54− ) {
55− CircularProgressIndicator()
56− }
57− } else if (options.isEmpty()) {
58− Text(
59− stringResource(R.string.no_models),
60− style = MaterialTheme.typography.bodyMedium,
61− color = MaterialTheme.colorScheme.onSurfaceVariant,
62− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
63− )
64− } else {
65− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
66− items(options, key = { it.providerID + "/" + it.modelID }) { option ->
67− val isSelected = option.providerID == selected?.providerID &&
68− option.modelID == selected.modelID
69− Column(
70− modifier = Modifier
71− .fillMaxWidth()
72− .semantics(mergeDescendants = true) {
73− role = Role.RadioButton
74− if (isSelected) this.selected = true
75− }
76− .clickable {
77− onSelect(option)
78− onDismiss()
79− }
80− .padding(horizontal = 24.dp, vertical = 12.dp),
81− ) {
82− Row(verticalAlignment = Alignment.CenterVertically) {
83− Text(
84− option.modelLabel,
85− style = MaterialTheme.typography.bodyLarge,
86− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
87− modifier = Modifier.weight(1f),
88− )
89− if (isSelected) {
90− Icon(
91− Icons.Filled.Check,
92− contentDescription = null,
93− tint = MaterialTheme.colorScheme.primary,
94− modifier = Modifier.size(18.dp),
95− )
96− }
97− }
98− Text(
99− option.providerLabel,
100− style = MaterialTheme.typography.bodySmall,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− )
103− }
104− }
105− }
106− }
107− }
108−}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktdeleted−269
@@ -1,269 +0,0 @@
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.ContentCopy
16−import androidx.compose.material.icons.filled.Description
17−import androidx.compose.material.icons.filled.Error
18−import androidx.compose.material.icons.filled.ExpandLess
19−import androidx.compose.material.icons.filled.ExpandMore
20−import androidx.compose.material.icons.filled.Psychology
21−import androidx.compose.material3.CircularProgressIndicator
22−import androidx.compose.material3.Icon
23−import androidx.compose.material3.IconButton
24−import androidx.compose.material3.MaterialTheme
25−import androidx.compose.material3.Text
26−import androidx.compose.material3.TextButton
27−import androidx.compose.runtime.Composable
28−import androidx.compose.runtime.getValue
29−import androidx.compose.runtime.mutableStateOf
30−import androidx.compose.runtime.remember
31−import androidx.compose.runtime.setValue
32−import androidx.compose.ui.Alignment
33−import androidx.compose.ui.Modifier
34−import androidx.compose.ui.draw.clip
35−import androidx.compose.ui.platform.LocalContext
36−import androidx.compose.ui.semantics.Role
37−import androidx.compose.ui.semantics.contentDescription
38−import androidx.compose.ui.semantics.role
39−import androidx.compose.ui.semantics.semantics
40−import androidx.compose.ui.semantics.stateDescription
41−import androidx.compose.ui.text.font.FontFamily
42−import androidx.compose.ui.res.stringResource
43−import androidx.compose.ui.unit.dp
44−import soy.iko.opencode.data.model.FilePart
45−import soy.iko.opencode.data.model.Part
46−import soy.iko.opencode.data.model.ReasoningPart
47−import soy.iko.opencode.data.model.StepFinishPart
48−import soy.iko.opencode.data.model.StepStartPart
49−import soy.iko.opencode.data.model.TextPart
50−import soy.iko.opencode.data.model.ToolCompleted
51−import soy.iko.opencode.data.model.ToolError
52−import soy.iko.opencode.data.model.ToolPart
53−import soy.iko.opencode.data.model.ToolPending
54−import soy.iko.opencode.data.model.ToolRunning
55−import soy.iko.opencode.data.model.ToolState
56−import soy.iko.opencode.data.model.ToolUnknown
57−import soy.iko.opencode.data.model.UnknownPart
58−import soy.iko.opencode.ui.components.DiffView
59−import soy.iko.opencode.ui.components.ImageLoadContext
60−import soy.iko.opencode.ui.components.MarkdownText
61−import soy.iko.opencode.ui.components.RemoteImage
62−import soy.iko.opencode.ui.components.copyToClipboard
63−import soy.iko.opencode.ui.components.isImage
64−import soy.iko.opencode.ui.components.looksLikeDiff
65−import soy.iko.opencode.R
66−
67−private const val COLLAPSED_LIMIT = 600
68−
69−/**
70− * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
71− * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
72− */
73−@Composable
74−fun PartView(part: Part, modifier: Modifier = Modifier, isRunning: Boolean = false, imageContext: ImageLoadContext? = null) {
75− when (part) {
76− is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
77− MarkdownText(part.text, modifier = modifier)
78− }
79− is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, modifier)
80− is ToolPart -> ToolCallView(part, modifier)
81− is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
82− RemoteImage(part, imageContext, modifier)
83− } else {
84− FileChip(part, modifier)
85− }
86− is StepStartPart -> {} // boundary marker — nothing to draw
87− is StepFinishPart -> {} // metrics handled at message level
88− is UnknownPart -> {} // forward-compat: unknown part types are ignored
89− }
90−}
91−
92−@Composable
93−private fun ReasoningBlock(text: String, streaming: Boolean, modifier: Modifier) {
94− if (text.isBlank()) return
95− val context = LocalContext.current
96− var expanded by remember { mutableStateOf(false) }
97− val expandedState = stringResource(R.string.state_expanded)
98− val collapsedState = stringResource(R.string.state_collapsed)
99− Column(modifier = modifier.fillMaxWidth()) {
100− Row(
101− modifier = Modifier
102− .clickable { expanded = !expanded }
103− .padding(vertical = 4.dp)
104− .semantics {
105− role = Role.Button
106− stateDescription = if (expanded) expandedState else collapsedState
107− },
108− verticalAlignment = Alignment.CenterVertically,
109− ) {
110− if (streaming) {
111− CircularProgressIndicator(
112− Modifier.size(14.dp),
113− strokeWidth = 2.dp,
114− color = MaterialTheme.colorScheme.onSurfaceVariant,
115− )
116− } else {
117− Icon(
118− Icons.Filled.Psychology,
119− contentDescription = null,
120− modifier = Modifier.size(16.dp),
121− tint = MaterialTheme.colorScheme.onSurfaceVariant,
122− )
123− }
124− Text(
125− if (streaming) stringResource(R.string.thinking) else stringResource(R.string.thoughts),
126− style = MaterialTheme.typography.labelMedium,
127− color = MaterialTheme.colorScheme.onSurfaceVariant,
128− modifier = Modifier.padding(start = 6.dp),
129− )
130− }
131− AnimatedVisibility(visible = expanded) {
132− Column {
133− Text(
134− text,
135− style = MaterialTheme.typography.bodySmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− )
138− TextButton(
139− onClick = { copyToClipboard(context, "reasoning", text) },
140− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
141− ) {
142− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
143− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
144− }
145− }
146− }
147− }
148−}
149−
150−@Composable
151−private fun ToolCallView(part: ToolPart, modifier: Modifier) {
152− val context = LocalContext.current
153− Column(
154− modifier = modifier
155− .fillMaxWidth()
156− .clip(RoundedCornerShape(8.dp))
157− .background(MaterialTheme.colorScheme.surfaceVariant)
158− .padding(10.dp),
159− ) {
160− Row(verticalAlignment = Alignment.CenterVertically) {
161− ToolStatusIcon(part.state)
162− Text(
163− part.tool,
164− style = MaterialTheme.typography.labelLarge,
165− fontFamily = FontFamily.Monospace,
166− modifier = Modifier.padding(start = 6.dp),
167− )
168− }
169− val detail = when (val s = part.state) {
170− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
171− is ToolError -> s.error ?: stringResource(R.string.error_generic)
172− else -> null
173− }
174− if (detail != null) {
175− val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
176− var expanded by remember(detail) { mutableStateOf(false) }
177− val expandedState = stringResource(R.string.state_expanded)
178− val collapsedState = stringResource(R.string.state_collapsed)
179− val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
180− if (looksLikeDiff(display)) {
181− DiffView(display)
182− } else {
183− Text(
184− display,
185− style = MaterialTheme.typography.bodySmall,
186− fontFamily = FontFamily.Monospace,
187− modifier = Modifier.padding(top = 6.dp),
188− )
189− }
190− if (detail.length > COLLAPSED_LIMIT) {
191− TextButton(
192− onClick = { expanded = !expanded },
193− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
194− modifier = Modifier.semantics {
195− stateDescription = if (expanded) expandedState else collapsedState
196− },
197− ) {
198− Icon(
199− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
200− contentDescription = null,
201− modifier = Modifier.size(16.dp),
202− )
203− Text(
204− if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
205− style = MaterialTheme.typography.labelSmall,
206− modifier = Modifier.padding(start = 4.dp),
207− )
208− }
209− }
210− TextButton(
211− onClick = { copyToClipboard(context, "output", detail) },
212− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213− ) {
214− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
215− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
216− }
217− }
218− }
219−}
220−
221−@Composable
222−private fun ToolStatusIcon(state: ToolState) {
223− when (state) {
224− is ToolPending, is ToolRunning ->
225− CircularProgressIndicator(
226− modifier = Modifier.size(16.dp).semantics { contentDescription = "" },
227− strokeWidth = 2.dp,
228− )
229− is ToolCompleted ->
230− Icon(
231− Icons.Filled.CheckCircle,
232− contentDescription = stringResource(R.string.tool_completed),
233− modifier = Modifier.size(16.dp),
234− tint = MaterialTheme.colorScheme.primary,
235− )
236− is ToolError ->
237− Icon(
238− Icons.Filled.Error,
239− contentDescription = stringResource(R.string.tool_error),
240− modifier = Modifier.size(16.dp),
241− tint = MaterialTheme.colorScheme.error,
242− )
243− is ToolUnknown ->
244− Icon(Icons.Filled.Bolt, contentDescription = null, modifier = Modifier.size(16.dp))
245− }
246−}
247−
248−@Composable
249−private fun FileChip(part: FilePart, modifier: Modifier) {
250− Row(
251− modifier = modifier
252− .clip(RoundedCornerShape(12.dp))
253− .background(MaterialTheme.colorScheme.surfaceVariant)
254− .padding(horizontal = 12.dp, vertical = 6.dp),
255− verticalAlignment = Alignment.CenterVertically,
256− ) {
257− Icon(
258− Icons.Filled.Description,
259− contentDescription = null,
260− modifier = Modifier.size(16.dp),
261− tint = MaterialTheme.colorScheme.onSurfaceVariant,
262− )
263− Text(
264− part.filename ?: part.url ?: stringResource(R.string.file),
265− style = MaterialTheme.typography.bodyMedium,
266− modifier = Modifier.padding(start = 6.dp),
267− )
268− }
269−}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktdeleted−95
@@ -1,95 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.padding
8−import androidx.activity.compose.BackHandler
9−import androidx.compose.material.icons.Icons
10−import androidx.compose.material.icons.filled.Shield
11−import androidx.compose.material3.AlertDialog
12−import androidx.compose.material3.Button
13−import androidx.compose.material3.Icon
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.OutlinedButton
16−import androidx.compose.material3.Text
17−import androidx.compose.material3.TextButton
18−import androidx.compose.runtime.Composable
19−import androidx.compose.ui.Modifier
20−import androidx.compose.ui.platform.LocalHapticFeedback
21−import androidx.compose.ui.res.stringResource
22−import androidx.compose.ui.text.font.FontFamily
23−import androidx.compose.ui.unit.dp
24−import soy.iko.opencode.data.model.Permission
25−import soy.iko.opencode.data.model.PermissionResponse
26−import soy.iko.opencode.R
27−
28−/**
29− * Modal asking the user to approve a tool the agent wants to run. The agent run is
30− * paused until one of these responses is sent. Non-dismissable by tap-outside so the
31− * decision is explicit.
32− */
33−@Composable
34−fun PermissionDialog(
35− permission: Permission,
36− onRespond: (PermissionResponse) -> Unit,
37−) {
38− val haptics = LocalHapticFeedback.current
39− // Intercept back so the dialog can't be dismissed without an explicit choice.
40− BackHandler { onRespond(PermissionResponse.REJECT) }
41− AlertDialog(
42− onDismissRequest = { /* require an explicit choice */ },
43− icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
44− title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
45− text = {
46− Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
47− permission.type?.let {
48− Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
49− }
50− permission.patternText?.let {
51− Text(
52− it,
53− style = MaterialTheme.typography.bodyMedium,
54− fontFamily = FontFamily.Monospace,
55− color = MaterialTheme.colorScheme.onSurfaceVariant,
56− )
57− }
58− Text(
59− stringResource(R.string.permission_text),
60− style = MaterialTheme.typography.bodySmall,
61− color = MaterialTheme.colorScheme.onSurfaceVariant,
62− )
63− }
64− },
65− confirmButton = {
66− Column(
67− modifier = Modifier.fillMaxWidth(),
68− verticalArrangement = Arrangement.spacedBy(4.dp),
69− ) {
70− Button(
71− onClick = {
72− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
73− onRespond(PermissionResponse.ONCE)
74− },
75− modifier = Modifier.fillMaxWidth(),
76− ) { Text(stringResource(R.string.allow_once)) }
77− OutlinedButton(
78− onClick = {
79− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
80− onRespond(PermissionResponse.ALWAYS)
81− },
82− modifier = Modifier.fillMaxWidth(),
83− ) { Text(stringResource(R.string.always_allow)) }
84− }
85− },
86− dismissButton = {
87− TextButton(onClick = {
88− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
89− onRespond(PermissionResponse.REJECT)
90− }) {
91− Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error)
92− }
93− },
94− )
95−}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktdeleted−54
@@ -1,54 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Row
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.padding
7−import androidx.compose.foundation.layout.size
8−import androidx.compose.material3.CircularProgressIndicator
9−import androidx.compose.material3.MaterialTheme
10−import androidx.compose.material3.Surface
11−import androidx.compose.material3.Text
12−import androidx.compose.runtime.Composable
13−import androidx.compose.ui.Alignment
14−import androidx.compose.ui.Modifier
15−import androidx.compose.ui.res.stringResource
16−import androidx.compose.ui.unit.dp
17−import soy.iko.opencode.R
18−import soy.iko.opencode.data.network.EventStreamClient
19−
20−/**
21− * Banner shown when the SSE event stream is not connected. Shared by the chat and the
22− * session list so a dropped stream is visible everywhere, not just mid-conversation.
23− */
24−@Composable
25−fun ConnectionBanner(
26− state: EventStreamClient.ConnectionState,
27− modifier: Modifier = Modifier,
28−) {
29− val text = when (state) {
30− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
31− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
32− EventStreamClient.ConnectionState.Connected -> null
33− }
34− if (text != null) {
35− Surface(
36− modifier = modifier.fillMaxWidth(),
37− color = MaterialTheme.colorScheme.tertiaryContainer,
38− ) {
39− Row(
40− modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
41− horizontalArrangement = Arrangement.Center,
42− verticalAlignment = Alignment.CenterVertically,
43− ) {
44− CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 2.dp)
45− Text(
46− text,
47− style = MaterialTheme.typography.labelMedium,
48− color = MaterialTheme.colorScheme.onTertiaryContainer,
49− modifier = Modifier.padding(start = 6.dp),
50− )
51− }
52− }
53− }
54−}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktdeleted−154
@@ -1,154 +0,0 @@
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.Arrangement
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.Spacer
9−import androidx.compose.foundation.layout.fillMaxWidth
10−import androidx.compose.foundation.layout.padding
11−import androidx.compose.foundation.layout.width
12−import androidx.compose.foundation.rememberScrollState
13−import androidx.compose.foundation.shape.RoundedCornerShape
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.filled.ContentCopy
16−import androidx.compose.material3.Icon
17−import androidx.compose.material3.IconButton
18−import androidx.compose.material3.MaterialTheme
19−import androidx.compose.material3.Text
20−import androidx.compose.runtime.Composable
21−import androidx.compose.runtime.remember
22−import androidx.compose.ui.Alignment
23−import androidx.compose.ui.Modifier
24−import androidx.compose.ui.draw.clip
25−import androidx.compose.ui.graphics.Color
26−import androidx.compose.ui.platform.LocalContext
27−import androidx.compose.ui.text.font.FontFamily
28−import androidx.compose.ui.unit.dp
29−import androidx.compose.ui.res.stringResource
30−import soy.iko.opencode.R
31−
32−/** A single line of a parsed unified diff. */
33−sealed interface DiffLine {
34− val text: String
35−
36− data class Hunk(override val text: String) : DiffLine // @@ ... @@
37− data class FileHeader(override val text: String) : DiffLine // --- / +++
38− data class Context(override val text: String) : DiffLine
39− data class Add(override val text: String) : DiffLine
40− data class Remove(override val text: String) : DiffLine
41−}
42−
43−/** Parse a unified diff string into typed [DiffLine]s. */
44−fun parseDiff(diff: String): List<DiffLine> {
45− val result = mutableListOf<DiffLine>()
46− for (raw in diff.split("\n")) {
47− when {
48− raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
49− raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
50− raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
51− raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
52− raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
53− raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
54− raw.isNotBlank() -> result.add(DiffLine.Context(raw))
55− }
56− }
57− return result
58−}
59−
60−/** Heuristic: does this string look like a unified diff? */
61−fun looksLikeDiff(text: String): Boolean {
62− val lines = text.lineSequence()
63− var hunkCount = 0
64− var addCount = 0
65− var removeCount = 0
66− for (line in lines) {
67− when {
68− line.startsWith("@@") -> hunkCount++
69− line.startsWith("+") && !line.startsWith("+++") -> addCount++
70− line.startsWith("-") && !line.startsWith("---") -> removeCount++
71− }
72− if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
73− }
74− return false
75−}
76−
77−/**
78− * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
79− * safe to embed inside another vertically scrolling container (e.g. the chat message
80− * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
81− * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
82− * [Modifier.verticalScroll] in [modifier].
83− */
84−@Composable
85−fun DiffView(diff: String, modifier: Modifier = Modifier) {
86− val lines = remember(diff) { parseDiff(diff) }
87− val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
88− val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
89− val addText = MaterialTheme.colorScheme.primary
90− val removeText = MaterialTheme.colorScheme.error
91− val context = LocalContext.current
92− val hScrollState = rememberScrollState()
93−
94− Column(
95− modifier = modifier
96− .fillMaxWidth()
97− .clip(RoundedCornerShape(8.dp))
98− .background(MaterialTheme.colorScheme.surfaceVariant),
99− ) {
100− Row(
101− modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
102− horizontalArrangement = Arrangement.End,
103− ) {
104− IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
105− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
106− }
107− }
108− lines.forEach { line ->
109− Row(modifier = Modifier.horizontalScroll(hScrollState).padding(horizontal = 10.dp)) {
110− when (line) {
111− is DiffLine.Hunk -> Text(
112− line.text,
113− style = MaterialTheme.typography.bodySmall,
114− fontFamily = FontFamily.Monospace,
115− color = MaterialTheme.colorScheme.tertiary,
116− modifier = Modifier.padding(vertical = 2.dp),
117− )
118− is DiffLine.FileHeader -> Text(
119− line.text,
120− style = MaterialTheme.typography.bodySmall,
121− fontFamily = FontFamily.Monospace,
122− color = MaterialTheme.colorScheme.onSurfaceVariant,
123− modifier = Modifier.padding(vertical = 1.dp),
124− )
125− is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
126− is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
127− is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
128− }
129− }
130− }
131− Spacer(Modifier.padding(bottom = 10.dp))
132− }
133−}
134−
135−@Composable
136−private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
137− Row(
138− modifier = Modifier.background(bg),
139− ) {
140− Text(
141− prefix,
142− style = MaterialTheme.typography.bodySmall,
143− fontFamily = FontFamily.Monospace,
144− color = textColor,
145− modifier = Modifier.width(16.dp),
146− )
147− Text(
148− text,
149− style = MaterialTheme.typography.bodySmall,
150− fontFamily = FontFamily.Monospace,
151− color = textColor,
152− )
153− }
154−}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktdeleted−133
@@ -1,133 +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.widget.Toast
7−import androidx.compose.foundation.ExperimentalFoundationApi
8−import androidx.compose.foundation.background
9−import androidx.compose.foundation.combinedClickable
10−import androidx.compose.foundation.horizontalScroll
11−import androidx.compose.foundation.layout.Box
12−import androidx.compose.foundation.layout.fillMaxWidth
13−import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.foundation.rememberScrollState
16−import androidx.compose.foundation.shape.RoundedCornerShape
17−import androidx.compose.material.icons.Icons
18−import androidx.compose.material.icons.filled.ContentCopy
19−import androidx.compose.material3.Icon
20−import androidx.compose.material3.IconButton
21−import androidx.compose.material3.MaterialTheme
22−import androidx.compose.material3.Surface
23−import androidx.compose.material3.Text
24−import androidx.compose.runtime.Composable
25−import androidx.compose.ui.Alignment
26−import androidx.compose.ui.Modifier
27−import androidx.compose.ui.draw.clip
28−import androidx.compose.ui.platform.LocalContext
29−import androidx.compose.ui.text.TextStyle
30−import androidx.compose.ui.text.font.FontFamily
31−import androidx.compose.ui.unit.dp
32−import com.mikepenz.markdown.compose.components.MarkdownComponentModel
33−import com.mikepenz.markdown.compose.components.markdownComponents
34−import com.mikepenz.markdown.m3.Markdown
35−import org.intellij.markdown.MarkdownElementTypes
36−import org.intellij.markdown.ast.ASTNode
37−import org.intellij.markdown.ast.getTextInNode
38−import soy.iko.opencode.R
39−
40−/**
41− * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
42− * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
43− * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
44− * inline copy button.
45− */
46−@OptIn(ExperimentalFoundationApi::class)
47−@Composable
48−fun MarkdownText(
49− markdown: String,
50− modifier: Modifier = Modifier,
51− style: TextStyle = MaterialTheme.typography.bodyLarge,
52−) {
53− val context = LocalContext.current
54− Markdown(
55− content = markdown,
56− modifier = modifier.combinedClickable(
57− onClick = {},
58− onLongClick = { copyToClipboard(context, markdown) },
59− ),
60− components = markdownComponents(
61− codeFence = { CodeWithCopy(it) },
62− codeBlock = { CodeWithCopy(it) },
63− ),
64− )
65−}
66−
67−/**
68− * Renders a fenced or indented code block with a small copy button in the top-right corner.
69− * Self-contained so it doesn't depend on the library's internal code-block composables.
70− */
71−@Composable
72−private fun CodeWithCopy(model: MarkdownComponentModel) {
73− val context = LocalContext.current
74− val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
75− val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
76− Box(
77− modifier = Modifier
78− .fillMaxWidth()
79− .clip(RoundedCornerShape(8.dp))
80− .background(MaterialTheme.colorScheme.surfaceVariant),
81− ) {
82− Text(
83− text = code,
84− style = codeStyle,
85− modifier = Modifier
86− .horizontalScroll(rememberScrollState())
87− .fillMaxWidth()
88− .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
89− )
90− Surface(
91− color = MaterialTheme.colorScheme.surface,
92− shape = RoundedCornerShape(6.dp),
93− modifier = Modifier
94− .align(Alignment.TopEnd)
95− .padding(4.dp),
96− ) {
97− IconButton(
98− onClick = { copyToClipboard(context, "code", code) },
99− modifier = Modifier.size(48.dp),
100− ) {
101− Icon(
102− Icons.Filled.ContentCopy,
103− contentDescription = context.getString(R.string.copy),
104− modifier = Modifier.size(14.dp),
105− tint = MaterialTheme.colorScheme.onSurfaceVariant,
106− )
107− }
108− }
109− }
110−}
111−
112−/**
113− * Extract the raw code text from a markdown code node, stripping fence markers
114− * (```/~~~ and the language tag) so only the code content is copied.
115− */
116−private fun extractCode(content: String, node: ASTNode): String {
117− val raw = node.getTextInNode(content).toString()
118− val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
119− if (!isOpenFence) return raw.trimIndent()
120− val lines = raw.lines()
121− val body = lines.drop(1).toMutableList()
122− if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
123− body.removeAt(body.lastIndex)
124− }
125− return body.joinToString("\n").trimEnd()
126−}
127−
128−/** Copy [text] to the system clipboard and show a confirmation toast. */
129−internal fun copyToClipboard(context: Context, label: String, text: String = label) {
130− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
131− clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
132− Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
133−}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktnew file+479
@@ -0,0 +1,479 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.content.Intent
7+import android.net.Uri
8+import android.widget.Toast
9+import androidx.compose.foundation.ExperimentalFoundationApi
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.clickable
12+import androidx.compose.foundation.combinedClickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.fillMaxWidth
17+import androidx.compose.foundation.layout.padding
18+import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.selection.SelectionContainer
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Build
24+import androidx.compose.material.icons.filled.Check
25+import androidx.compose.material.icons.filled.ErrorOutline
26+import androidx.compose.material.icons.filled.ExpandLess
27+import androidx.compose.material.icons.filled.ExpandMore
28+import androidx.compose.material.icons.filled.Schedule
29+import androidx.compose.material.icons.filled.SmartToy
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.DropdownMenuItem
32+import androidx.compose.material3.Icon
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.Text
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.graphics.Color
44+import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
47+import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
49+import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
51+import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.withLink
54+import androidx.compose.ui.text.withStyle
55+import androidx.compose.ui.text.style.TextDecoration
56+import androidx.compose.ui.text.style.TextOverflow
57+import androidx.compose.ui.unit.dp
58+import androidx.compose.ui.unit.sp
59+import soy.iko.opencode.core.MessageStatus
60+import soy.iko.opencode.core.MessageView
61+import soy.iko.opencode.core.ToolView
62+import soy.iko.opencode.ui.theme.MonoStyle
63+
64+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65+
66+@OptIn(ExperimentalFoundationApi::class)
67+@Composable
68+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69+ val isUser = message.isUser
70+ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71+ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73+ val shape = RoundedCornerShape(
74+ topStart = 16.dp,
75+ topEnd = 16.dp,
76+ bottomStart = if (isUser) 16.dp else 4.dp,
77+ bottomEnd = if (isUser) 4.dp else 16.dp,
78+ )
79+
80+ val context = LocalContext.current
81+ var showMenu by remember { mutableStateOf(false) }
82+
83+ Row(
84+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86+ ) {
87+ if (!isUser) {
88+ Icon(
89+ Icons.Filled.SmartToy,
90+ contentDescription = null,
91+ tint = MaterialTheme.colorScheme.primary,
92+ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93+ )
94+ }
95+ Column(
96+ modifier = Modifier
97+ .fillMaxWidth(0.85f)
98+ .widthIn(max = 320.dp)
99+ .clip(shape)
100+ .background(bubbleColor)
101+ .combinedClickable(
102+ onClick = {},
103+ onLongClick = {
104+ if (message.text.isNotEmpty()) {
105+ showMenu = true
106+ }
107+ },
108+ )
109+ .padding(horizontal = 14.dp, vertical = 10.dp),
110+ ) {
111+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112+ DropdownMenuItem(
113+ text = { Text("Copy") },
114+ onClick = {
115+ copyToClipboard(context, message.text)
116+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117+ showMenu = false
118+ },
119+ )
120+ DropdownMenuItem(
121+ text = { Text("Share") },
122+ onClick = {
123+ shareText(context, message.text)
124+ showMenu = false
125+ },
126+ )
127+ }
128+
129+ message.reasoning?.let { ReasoningBlock(it, context) }
130+
131+ // Give a quick overview of how many tools ran before listing them.
132+ if (message.tools.size > 1) {
133+ Text(
134+ text = "${message.tools.size} tool calls",
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(bottom = 4.dp),
138+ )
139+ }
140+
141+ message.tools.forEach { ToolRow(it) }
142+
143+ SelectionContainer {
144+ if (message.text.isNotEmpty()) {
145+ val linkColor = MaterialTheme.colorScheme.primary
146+ // Tint the code background off the bubble's own foreground colour so it
147+ // contrasts on both bubble types (user bubbles use primaryContainer).
148+ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151+ }
152+ Text(
153+ text = annotatedText,
154+ color = textColor,
155+ // Give assistant replies (often long, code-heavy) extra line
156+ // spacing for readability; user bubbles keep the default.
157+ style = if (isUser) {
158+ MaterialTheme.typography.bodyLarge
159+ } else {
160+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161+ },
162+ )
163+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165+ }
166+ }
167+
168+ if (message.time > 0) {
169+ Text(
170+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171+ style = MaterialTheme.typography.labelSmall,
172+ color = mutedColor,
173+ modifier = Modifier.padding(top = 4.dp),
174+ )
175+ }
176+
177+ if (isUser && message.status != MessageStatus.Sent) {
178+ StatusLine(message.status, onRetry = onRetry)
179+ }
180+ }
181+ }
182+}
183+
184+private fun copyToClipboard(context: Context, text: String) {
185+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187+}
188+
189+private val urlRegex = Regex("""https?://[^\s]+""")
190+
191+/**
192+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193+ * prose and code segments: code segments are rendered monospace on a tinted
194+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196+ */
197+private fun buildLinkedText(
198+ text: String,
199+ streaming: Boolean,
200+ linkColor: Color,
201+ codeBackground: Color,
202+ context: Context,
203+): AnnotatedString = buildAnnotatedString {
204+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206+ val segments = text.split("```")
207+ segments.forEachIndexed { index, segment ->
208+ if (index % 2 == 1) {
209+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210+ append(segment)
211+ }
212+ } else {
213+ appendLinkedText(segment, linkColor, codeBackground, context)
214+ }
215+ }
216+ if (streaming) append(" ▌")
217+}
218+
219+/**
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224+ * inline spans reach here.
225+ */
226+private fun AnnotatedString.Builder.appendLinkedText(
227+ text: String,
228+ linkColor: Color,
229+ codeBackground: Color,
230+ context: Context,
231+) {
232+ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233+ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234+ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235+ // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236+ // segment on "*" would misfire on the leftover asterisks from the ** markers.
237+ val segments = text.split("**")
238+ // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239+ // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240+ val unclosed = segments.size % 2 == 0
241+ segments.forEachIndexed { index, segment ->
242+ val isUnclosedTail = unclosed && index == segments.size - 1
243+ if (index % 2 == 1 && !isUnclosedTail) {
244+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245+ appendInlineCode(segment, linkColor, codeBackground, context)
246+ }
247+ } else {
248+ if (isUnclosedTail) append("**")
249+ appendItalicText(segment, linkColor, codeBackground, context)
250+ }
251+ }
252+}
253+
254+/**
255+ * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256+ * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257+ * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258+ */
259+private fun AnnotatedString.Builder.appendItalicText(
260+ text: String,
261+ linkColor: Color,
262+ codeBackground: Color,
263+ context: Context,
264+) {
265+ // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266+ // (markers dropped); even segments are ordinary prose.
267+ val segments = text.split("*")
268+ // An even segment count means an odd number of * markers, so the final * is unclosed:
269+ // its trailing segment isn't italic. Restore the literal * and render it as prose.
270+ val unclosed = segments.size % 2 == 0
271+ segments.forEachIndexed { index, segment ->
272+ val isUnclosedTail = unclosed && index == segments.size - 1
273+ if (index % 2 == 1 && !isUnclosedTail) {
274+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275+ appendInlineCode(segment, linkColor, codeBackground, context)
276+ }
277+ } else {
278+ if (isUnclosedTail) append("*")
279+ appendInlineCode(segment, linkColor, codeBackground, context)
280+ }
281+ }
282+}
283+
284+/**
285+ * Append [text] to the builder, rendering single-backtick pairs as inline code
286+ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287+ * into a clickable link.
288+ */
289+private fun AnnotatedString.Builder.appendInlineCode(
290+ text: String,
291+ linkColor: Color,
292+ codeBackground: Color,
293+ context: Context,
294+) {
295+ // Odd-indexed segments sit between a pair of single backticks, so they're inline
296+ // code. Even segments are ordinary prose and get URL linking applied.
297+ val segments = text.split("`")
298+ // An even segment count means an odd number of backtick markers, so the final backtick
299+ // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300+ val unclosed = segments.size % 2 == 0
301+ segments.forEachIndexed { index, segment ->
302+ val isUnclosedTail = unclosed && index == segments.size - 1
303+ if (index % 2 == 1 && !isUnclosedTail) {
304+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305+ append(segment)
306+ }
307+ } else {
308+ if (isUnclosedTail) append("`")
309+ appendUrls(segment, linkColor, context)
310+ }
311+ }
312+}
313+
314+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315+private fun AnnotatedString.Builder.appendUrls(
316+ text: String,
317+ linkColor: Color,
318+ context: Context,
319+) {
320+ val matches = urlRegex.findAll(text).toList()
321+ var cursor = 0
322+ for (match in matches) {
323+ append(text.substring(cursor, match.range.first))
324+ val url = match.value
325+ withLink(
326+ LinkAnnotation.Clickable(
327+ tag = url,
328+ styles = TextLinkStyles(
329+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330+ ),
331+ linkInteractionListener = {
332+ runCatching {
333+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334+ }
335+ },
336+ ),
337+ ) {
338+ append(url)
339+ }
340+ cursor = match.range.last + 1
341+ }
342+ append(text.substring(cursor))
343+}
344+
345+private fun shareText(context: Context, text: String) {
346+ val intent = Intent(Intent.ACTION_SEND).apply {
347+ type = "text/plain"
348+ putExtra(Intent.EXTRA_TEXT, text)
349+ }
350+ context.startActivity(Intent.createChooser(intent, null))
351+}
352+
353+@Composable
354+private fun ReasoningBlock(text: String, context: Context) {
355+ var expanded by remember { mutableStateOf(false) }
356+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
357+ Row(
358+ verticalAlignment = Alignment.CenterVertically,
359+ modifier = Modifier.clickable { expanded = !expanded },
360+ ) {
361+ Text(
362+ text = if (expanded) "Show less" else "Reasoning",
363+ style = MaterialTheme.typography.bodySmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ )
366+ Icon(
367+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
370+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371+ )
372+ }
373+ if (expanded) {
374+ Text(
375+ text = text,
376+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377+ color = MaterialTheme.colorScheme.onSurfaceVariant,
378+ modifier = Modifier.padding(top = 4.dp),
379+ )
380+ Text(
381+ text = "Copy",
382+ style = MaterialTheme.typography.labelSmall,
383+ color = MaterialTheme.colorScheme.primary,
384+ modifier = Modifier
385+ .padding(top = 4.dp)
386+ .clickable {
387+ copyToClipboard(context, text)
388+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389+ },
390+ )
391+ }
392+ }
393+}
394+
395+@Composable
396+private fun ToolRow(tool: ToolView) {
397+ var expanded by remember { mutableStateOf(false) }
398+ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399+ val statusColor = when (tool.status) {
400+ "completed", "success" -> MaterialTheme.colorScheme.primary
401+ "error", "failed" -> MaterialTheme.colorScheme.error
402+ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403+ else -> MaterialTheme.colorScheme.onSurfaceVariant
404+ }
405+ Row(
406+ verticalAlignment = Alignment.CenterVertically,
407+ modifier = Modifier
408+ .fillMaxWidth()
409+ .clickable { expanded = !expanded }
410+ .padding(bottom = 6.dp),
411+ ) {
412+ Icon(
413+ Icons.Filled.Build,
414+ contentDescription = null,
415+ tint = MaterialTheme.colorScheme.primary,
416+ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417+ )
418+ Text(
419+ text = buildAnnotatedString {
420+ withStyle(SpanStyle(color = baseColor)) {
421+ append(tool.name)
422+ append(" · ")
423+ }
424+ withStyle(SpanStyle(color = statusColor)) {
425+ append(tool.status)
426+ }
427+ if (expanded) tool.title?.let {
428+ withStyle(SpanStyle(color = baseColor)) {
429+ append(" — ")
430+ append(it)
431+ }
432+ }
433+ },
434+ style = MonoStyle,
435+ color = baseColor,
436+ maxLines = if (expanded) Int.MAX_VALUE else 1,
437+ overflow = TextOverflow.Ellipsis,
438+ modifier = Modifier.weight(1f),
439+ )
440+ Icon(
441+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442+ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
444+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445+ )
446+ }
447+}
448+
449+@Composable
450+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451+ val (icon, label, tint) = when (status) {
452+ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453+ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454+ MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455+ }
456+ Row(
457+ verticalAlignment = Alignment.CenterVertically,
458+ horizontalArrangement = Arrangement.End,
459+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460+ ) {
461+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462+ Text(
463+ text = label,
464+ style = MaterialTheme.typography.labelSmall,
465+ color = tint,
466+ modifier = Modifier.padding(start = 4.dp),
467+ )
468+ if (status == MessageStatus.Failed) {
469+ Text(
470+ text = "Retry",
471+ style = MaterialTheme.typography.labelSmall,
472+ color = MaterialTheme.colorScheme.primary,
473+ modifier = Modifier
474+ .padding(start = 12.dp)
475+ .clickable { onRetry() },
476+ )
477+ }
478+ }
479+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktdeleted−130
@@ -1,130 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.util.Base64
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.shape.RoundedCornerShape
8−import androidx.compose.material3.MaterialTheme
9−import androidx.compose.runtime.Composable
10−import androidx.compose.runtime.remember
11−import androidx.compose.ui.Alignment
12−import androidx.compose.ui.Modifier
13−import androidx.compose.ui.draw.clip
14−import androidx.compose.ui.layout.ContentScale
15−import androidx.compose.ui.platform.LocalContext
16−import androidx.compose.ui.res.stringResource
17−import androidx.compose.ui.unit.dp
18−import coil.compose.SubcomposeAsyncImage
19−import coil.request.ImageRequest
20−import androidx.compose.material3.CircularProgressIndicator
21−import androidx.compose.material3.Icon
22−import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.BrokenImage
24−import soy.iko.opencode.R
25−import soy.iko.opencode.data.model.FilePart
26−import soy.iko.opencode.data.model.ServerProfile
27−import soy.iko.opencode.data.network.HttpClientFactory
28−
29−/**
30− * Carries the bits needed to load an image off the opencode server: the base URL (for
31− * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
32− */
33−data class ImageLoadContext(
34− val baseUrl: String,
35− val basicAuthHeader: String?,
36−)
37−
38−/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
39−fun ServerProfile.toImageContext(): ImageLoadContext {
40− val auth = if (hasAuth && !password.isNullOrEmpty()) {
41− val raw = "$username:$password".toByteArray()
42− "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
43− } else {
44− null
45− }
46− return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
47−}
48−
49−/** True if the part references a raster/vector image we can render. */
50−val FilePart.isImage: Boolean
51− get() = mime?.startsWith("image/") == true
52−
53−/**
54− * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
55− * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
56− */
57−private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
58− val src = source
59− if (!src.isNullOrBlank() && src.startsWith("data:")) {
60− val comma = src.indexOf(',')
61− if (comma < 0) return null
62− val payload = src.substring(comma + 1)
63− return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
64− }
65− val url = url ?: return null
66− return if (url.startsWith("http://") || url.startsWith("https://")) {
67− url
68− } else {
69− // Resolve relative URL against the base. Collapse any ../ segments to
70− // prevent path traversal from escaping the server's base path.
71− val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
72− val resolved = runCatching {
73− java.net.URI(base).resolve(url).normalize().toString()
74− }.getOrElse { return null }
75− // Guard: if normalization escaped the base path, return null (don't
76− // attempt to load the base URL itself as an image — it's not one).
77− if (!resolved.startsWith(base)) null else resolved
78− }
79−}
80−
81−/**
82− * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
83− * and server-relative URLs (fetched with Basic auth when the server requires it).
84− * Falls back to nothing when the part has no loadable source.
85− */
86−@Composable
87−fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
88− val context = LocalContext.current
89− val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
90− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
91− ImageRequest.Builder(context)
92− .data(model)
93− .apply {
94− // Only send Basic auth over HTTPS; sending credentials over
95− // cleartext HTTP would expose them on the network.
96− val isHttps = (model as? String)?.startsWith("https://") == true
97− if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
98− }
99− .crossfade(true)
100− .build()
101− }
102− SubcomposeAsyncImage(
103− model = request,
104− contentDescription = part.filename ?: stringResource(R.string.image),
105− contentScale = ContentScale.FillWidth,
106− modifier = modifier
107− .heightIn(max = 320.dp)
108− .clip(RoundedCornerShape(12.dp)),
109− loading = {
110− Box(
111− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
112− contentAlignment = Alignment.Center,
113− ) {
114− CircularProgressIndicator()
115− }
116− },
117− error = {
118− Box(
119− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
120− contentAlignment = Alignment.Center,
121− ) {
122− Icon(
123− Icons.Filled.BrokenImage,
124− contentDescription = stringResource(R.string.image_failed),
125− tint = MaterialTheme.colorScheme.onSurfaceVariant,
126− )
127− }
128− },
129− )
130−}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktdeleted−52
@@ -1,52 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.runtime.Composable
4−import androidx.compose.runtime.LaunchedEffect
5−import androidx.compose.runtime.getValue
6−import androidx.compose.runtime.mutableLongStateOf
7−import androidx.compose.runtime.remember
8−import androidx.compose.runtime.setValue
9−import kotlinx.coroutines.delay
10−import java.util.concurrent.TimeUnit
11−
12−/** Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d"). */
13−fun relativeTime(epochMillis: Long?): String {
14− if (epochMillis == null || epochMillis <= 0) return ""
15− val diff = System.currentTimeMillis() - epochMillis
16− if (diff < 0) return "now"
17− val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
18− if (minutes < 1) return "now"
19− if (minutes < 60) return "${minutes}m"
20− val hours = TimeUnit.MILLISECONDS.toHours(diff)
21− if (hours < 24) return "${hours}h"
22− val days = TimeUnit.MILLISECONDS.toDays(diff)
23− if (days < 7) return "${days}d"
24− val weeks = days / 7
25− if (weeks < 5) return "${weeks}w"
26− val months = days / 30
27− if (months < 12) return "${months}mo"
28− return "${days / 365}y"
29−}
30−
31−/**
32− * Returns a relative-time string for [epochMillis] that auto-refreshes so the label
33− * doesn't go stale (e.g. "3m" → "4m" → "5m" while the screen is visible). Re-evaluates
34− * every [intervalMs] (default 30 s — coarse enough for battery, fine enough for the
35− * smallest unit we show, which is minutes).
36− */
37−@Composable
38−fun rememberRelativeTime(epochMillis: Long?, intervalMs: Long = 30_000L): String {
39− // Recompose trigger: incrementing this state causes the composable to re-execute
40− // on each interval, re-evaluating relativeTime() so the label stays fresh.
41− var tick by remember { mutableLongStateOf(0L) }
42− LaunchedEffect(epochMillis) {
43− if (epochMillis == null) return@LaunchedEffect
44− while (true) {
45− delay(intervalMs)
46− tick++
47− }
48− }
49− // Read tick so Compose tracks it as a dependency and recomposes when it changes.
50− @Suppress("UNUSED_EXPRESSION") tick
51− return relativeTime(epochMillis)
52−}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktdeleted−274
@@ -1,274 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.horizontalScroll
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.foundation.rememberScrollState
15−import androidx.compose.foundation.text.KeyboardOptions
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.automirrored.filled.ArrowBack
18−import androidx.compose.material.icons.filled.ChevronRight
19−import androidx.compose.material.icons.filled.Description
20−import androidx.compose.material.icons.filled.Folder
21−import androidx.compose.material.icons.filled.Home
22−import androidx.compose.material.icons.filled.Search
23−import androidx.compose.material3.CircularProgressIndicator
24−import androidx.compose.material3.ExperimentalMaterial3Api
25−import androidx.compose.material3.HorizontalDivider
26−import androidx.compose.material3.Icon
27−import androidx.compose.material3.IconButton
28−import androidx.compose.material3.MaterialTheme
29−import androidx.compose.material3.OutlinedTextField
30−import androidx.compose.material3.Scaffold
31−import androidx.compose.material3.SnackbarHost
32−import androidx.compose.material3.SnackbarHostState
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.remember
39−import androidx.compose.ui.Alignment
40−import androidx.compose.ui.Modifier
41−import androidx.compose.ui.graphics.Color
42−import androidx.compose.ui.semantics.Role
43−import androidx.compose.ui.semantics.contentDescription
44−import androidx.compose.ui.semantics.role
45−import androidx.compose.ui.semantics.semantics
46−import androidx.compose.ui.text.font.FontFamily
47−import androidx.compose.ui.text.input.ImeAction
48−import androidx.compose.ui.text.style.TextOverflow
49−import androidx.compose.ui.unit.dp
50−import androidx.compose.ui.res.stringResource
51−import androidx.lifecycle.compose.collectAsStateWithLifecycle
52−import androidx.lifecycle.viewmodel.compose.viewModel
53−import soy.iko.opencode.data.model.FileStatusEntry
54−import soy.iko.opencode.di.AppContainer
55−import soy.iko.opencode.R
56−import soy.iko.opencode.ui.vmFactory
57−
58−@OptIn(ExperimentalMaterial3Api::class)
59−@Composable
60−fun FileBrowserScreen(
61− container: AppContainer,
62− onOpenFile: (String) -> Unit,
63− onBack: () -> Unit,
64−) {
65− val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
66− val state by vm.state.collectAsStateWithLifecycle()
67− val transientError by vm.transientError.collectAsStateWithLifecycle()
68− val snackbar = remember { SnackbarHostState() }
69−
70− LaunchedEffect(transientError) {
71− val msg = transientError ?: return@LaunchedEffect
72− snackbar.showSnackbar(msg)
73− vm.clearTransientError()
74− }
75−
76− Scaffold(
77− topBar = {
78− TopAppBar(
79− title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
80− navigationIcon = {
81− IconButton(onClick = onBack) {
82− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
83− }
84− },
85− )
86− },
87− snackbarHost = { SnackbarHost(snackbar) },
88− ) { padding ->
89− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
90− // Tappable breadcrumb trail so deep paths are navigable.
91− Breadcrumbs(
92− path = state.path,
93− onNavigate = vm::open,
94− modifier = Modifier.fillMaxWidth(),
95− )
96− OutlinedTextField(
97− value = state.query,
98− onValueChange = vm::setQuery,
99− modifier = Modifier.fillMaxWidth().padding(12.dp),
100− placeholder = { Text(stringResource(R.string.search_files)) },
101− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102− singleLine = true,
103− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
104− )
105−
106− Box(modifier = Modifier.fillMaxSize()) {
107− when {
108− state.loading || state.searching -> CircularProgressIndicator(Modifier.align(Alignment.Center))
109− state.error != null && !state.isSearching -> Text(
110− state.error ?: "",
111− modifier = Modifier.align(Alignment.Center).padding(24.dp),
112− color = MaterialTheme.colorScheme.error,
113− )
114− state.isSearching -> SearchResults(state.results, onOpenFile)
115− else -> DirectoryListing(
116− state = state,
117− onOpenDir = vm::open,
118− onUp = vm::up,
119− onOpenFile = onOpenFile,
120− )
121− }
122− }
123− }
124− }
125−}
126−
127−@Composable
128−private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
129− val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
130− Row(
131− modifier = modifier
132− .horizontalScroll(rememberScrollState())
133− .padding(horizontal = 16.dp, vertical = 8.dp),
134− verticalAlignment = Alignment.CenterVertically,
135− ) {
136− IconButton(onClick = { onNavigate("") }) {
137− Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
138− }
139− var acc = ""
140− segments.forEachIndexed { index, segment ->
141− acc = if (acc.isEmpty()) segment else "$acc/$segment"
142− Icon(
143− Icons.Filled.ChevronRight,
144− contentDescription = null,
145− modifier = Modifier.size(16.dp),
146− tint = MaterialTheme.colorScheme.onSurfaceVariant,
147− )
148− val target = acc
149− Text(
150− segment,
151− style = MaterialTheme.typography.bodyMedium,
152− color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
153− modifier = Modifier
154− .clickable(role = Role.Button) { onNavigate(target) }
155− .padding(horizontal = 6.dp, vertical = 14.dp),
156− )
157− }
158− }
159−}
160−
161−@Composable
162−private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
163− if (results.isEmpty()) {
164− Text(stringResource(R.string.no_matches), modifier = Modifier.padding(24.dp))
165− return
166− }
167− LazyColumn(modifier = Modifier.fillMaxSize()) {
168− items(results, key = { it }) { path ->
169− FileRow(icon = false, label = path, onClick = { onOpenFile(path) })
170− HorizontalDivider()
171− }
172− }
173−}
174−
175−@Composable
176−private fun DirectoryListing(
177− state: FileBrowserState,
178− onOpenDir: (String) -> Unit,
179− onUp: () -> Unit,
180− onOpenFile: (String) -> Unit,
181−) {
182− if (state.entries.isEmpty() && state.path.isBlank()) {
183− Text(
184− stringResource(R.string.empty_folder),
185− modifier = Modifier.fillMaxWidth().padding(24.dp),
186− color = MaterialTheme.colorScheme.onSurfaceVariant,
187− )
188− return
189− }
190− LazyColumn(modifier = Modifier.fillMaxSize()) {
191− if (state.path.isNotBlank()) {
192− item(key = "__up") {
193− FileRow(icon = true, label = "..", onClick = onUp)
194− HorizontalDivider()
195− }
196− }
197− items(state.entries, key = { it.path }) { node ->
198− FileRow(
199− icon = node.isDirectory,
200− label = node.name,
201− onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
202− status = state.statusMap[node.path],
203− )
204− HorizontalDivider()
205− }
206− }
207−}
208−
209−@Composable
210−private fun FileRow(
211− icon: Boolean,
212− label: String,
213− onClick: () -> Unit,
214− status: FileStatusEntry? = null,
215−) {
216− val desc = if (label == "..") stringResource(R.string.parent_dir)
217− else if (icon) stringResource(R.string.folder, label)
218− else stringResource(R.string.file_label, label)
219− Row(
220− modifier = Modifier
221− .fillMaxWidth()
222− .clickable(role = Role.Button, onClick = onClick)
223− .semantics(mergeDescendants = true) { contentDescription = desc }
224− .padding(horizontal = 16.dp, vertical = 14.dp),
225− verticalAlignment = Alignment.CenterVertically,
226− ) {
227− Icon(
228− if (icon) Icons.Filled.Folder else Icons.Filled.Description,
229− contentDescription = null,
230− modifier = Modifier.size(20.dp),
231− tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
232− )
233− Text(
234− label,
235− style = MaterialTheme.typography.bodyLarge,
236− maxLines = 1,
237− overflow = TextOverflow.Ellipsis,
238− modifier = Modifier.weight(1f).padding(start = 8.dp),
239− )
240− if (status != null) StatusBadge(status)
241− }
242−}
243−
244−@Composable
245−private fun StatusBadge(status: FileStatusEntry) {
246− val (letter, color, desc) = when (status.status) {
247− "added" -> Triple("A", Color(0xFF4CAF50), stringResource(R.string.git_added))
248− "modified" -> Triple("M", Color(0xFFFFA000), stringResource(R.string.git_modified))
249− "deleted" -> Triple("D", MaterialTheme.colorScheme.error, stringResource(R.string.git_deleted))
250− else -> Triple("·", MaterialTheme.colorScheme.onSurfaceVariant, "")
251− }
252− Row(
253− verticalAlignment = Alignment.CenterVertically,
254− modifier = Modifier.semantics(mergeDescendants = true) {
255− if (desc.isNotEmpty()) contentDescription = desc
256− },
257− ) {
258− Text(
259− letter,
260− style = MaterialTheme.typography.labelMedium,
261− fontFamily = FontFamily.Monospace,
262− color = color,
263− )
264− if (status.added > 0 || status.removed > 0) {
265− Text(
266− "+${status.added} −${status.removed}",
267− style = MaterialTheme.typography.labelSmall,
268− fontFamily = FontFamily.Monospace,
269− color = MaterialTheme.colorScheme.onSurfaceVariant,
270− modifier = Modifier.padding(start = 4.dp),
271− )
272− }
273− }
274−}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktdeleted−135
@@ -1,135 +0,0 @@
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.data.model.FileStatusEntry
7−import soy.iko.opencode.data.network.NetworkConfig
8−import soy.iko.opencode.di.AppContainer
9−import soy.iko.opencode.R
10−import soy.iko.opencode.util.runCatchingCancellable
11−import kotlinx.coroutines.ExperimentalCoroutinesApi
12−import kotlinx.coroutines.Job
13−import kotlinx.coroutines.delay
14−import kotlinx.coroutines.flow.MutableStateFlow
15−import kotlinx.coroutines.flow.StateFlow
16−import kotlinx.coroutines.flow.asStateFlow
17−import kotlinx.coroutines.flow.collectLatest
18−import kotlinx.coroutines.launch
19−
20−data class FileBrowserState(
21− val path: String = "",
22− val entries: List<FileNode> = emptyList(),
23− val statusMap: Map<String, FileStatusEntry> = emptyMap(),
24− val query: String = "",
25− val results: List<String> = emptyList(),
26− val searching: Boolean = false,
27− val loading: Boolean = true,
28− val error: String? = null,
29−) {
30− val isSearching: Boolean get() = query.isNotBlank()
31−}
32−
33−@OptIn(ExperimentalCoroutinesApi::class)
34−class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
35−
36− private val api get() = container.activeConnection.value?.api
37−
38− private val _state = MutableStateFlow(FileBrowserState())
39− val state: StateFlow<FileBrowserState> = _state.asStateFlow()
40−
41− /** Transient errors (failed VCS status fetch, etc.) surfaced without hiding the file list. */
42− private val _transientError = MutableStateFlow<String?>(null)
43− val transientError: StateFlow<String?> = _transientError.asStateFlow()
44− fun clearTransientError() { _transientError.value = null }
45−
46− private var searchJob: Job? = null
47− private var openJob: Job? = null
48−
49− init {
50− open("")
51− // Observe the active connection so VCS status loads (or reloads) when a connection
52− // becomes available — including when the view opens during a reconnect window where
53− // activeConnection.value was momentarily null. Without this, loadStatus() would be
54− // called once from init with a null api and never retried.
55− viewModelScope.launch {
56− container.activeConnection.collectLatest { conn ->
57− if (conn == null) return@collectLatest
58− loadStatus()
59− // Re-open the current directory when a connection becomes available
60− // so the initial open("") (which may have run before auto-reconnect
61− // finished and found no connection) doesn't leave a stale error.
62− open(_state.value.path)
63− }
64− }
65− }
66−
67− /** Tracks the in-flight VCS status load so a server switch can cancel it. */
68− private var statusJob: Job? = null
69−
70− /** Fetch the repo-wide VCS status once so file rows can show git badges. */
71− private fun loadStatus() {
72− val client = api ?: return
73− statusJob?.cancel()
74− statusJob = viewModelScope.launch {
75− runCatchingCancellable { client.fileStatus() }
76− .onSuccess { entries ->
77− _state.value = _state.value.copy(statusMap = entries.associateBy { it.path })
78− }
79− .onFailure {
80− // VCS status is best-effort: don't block the file browser, but surface
81− // a transient error so the user knows why badges are missing.
82− _transientError.value = container.friendlyError(it)
83− }
84− }
85− }
86−
87− fun open(path: String) {
88− val client = api ?: run {
89− _state.value = _state.value.copy(loading = false, error = container.string(R.string.not_connected))
90− return
91− }
92− _state.value = _state.value.copy(path = path, loading = true, error = null)
93− openJob?.cancel()
94− searchJob?.cancel()
95− if (_state.value.isSearching) _state.value = _state.value.copy(results = emptyList(), searching = false)
96− openJob = viewModelScope.launch {
97− runCatchingCancellable { client.listDirectory(path) }
98− .onSuccess { entries ->
99− _state.value = _state.value.copy(
100− entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
101− loading = false,
102− )
103− }
104− .onFailure { _state.value = _state.value.copy(loading = false, error = container.friendlyError(it)) }
105− }
106− }
107−
108− /** Navigate up one path segment. */
109− fun up() {
110− val current = _state.value.path
111− if (current.isBlank()) return
112− val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
113− open(parent)
114− }
115−
116− fun setQuery(query: String) {
117− _state.value = _state.value.copy(query = query, error = null)
118− searchJob?.cancel()
119− if (query.isBlank()) {
120− _state.value = _state.value.copy(results = emptyList(), searching = false)
121− return
122− }
123− val client = api ?: run {
124− _state.value = _state.value.copy(searching = false, error = container.string(R.string.not_connected))
125− return
126− }
127− searchJob = viewModelScope.launch {
128− delay(NetworkConfig.fileSearchDebounceMs) // debounce
129− _state.value = _state.value.copy(searching = true)
130− runCatchingCancellable { client.findFiles(query) }
131− .onSuccess { _state.value = _state.value.copy(results = it, searching = false) }
132− .onFailure { _state.value = _state.value.copy(searching = false, error = container.friendlyError(it)) }
133− }
134− }
135−}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktdeleted−48
@@ -1,48 +0,0 @@
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 soy.iko.opencode.R
8−import kotlinx.coroutines.ExperimentalCoroutinesApi
9−import kotlinx.coroutines.flow.MutableStateFlow
10−import kotlinx.coroutines.flow.StateFlow
11−import kotlinx.coroutines.flow.asStateFlow
12−import kotlinx.coroutines.flow.collectLatest
13−import kotlinx.coroutines.launch
14−
15−data class FileViewState(
16− val loading: Boolean = true,
17− val content: FileContent? = null,
18− val error: String? = null,
19−)
20−
21−@OptIn(ExperimentalCoroutinesApi::class)
22−class FileViewModel(
23− private val container: AppContainer,
24− private val path: String,
25−) : ViewModel() {
26−
27− private val _state = MutableStateFlow(FileViewState())
28− val state: StateFlow<FileViewState> = _state.asStateFlow()
29−
30− init {
31− // Observe the active connection so the file loads (or reloads) when a connection
32− // becomes available — including when the view opens during a reconnect window
33− // where activeConnection.value was momentarily null. collectLatest cancels the
34− // in-flight load if the connection is replaced mid-read.
35− viewModelScope.launch {
36− container.activeConnection.collectLatest { conn ->
37− if (conn == null) {
38− _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
39− return@collectLatest
40− }
41− _state.value = FileViewState(loading = true)
42− runCatching { conn.api.readFile(path) }
43− .onSuccess { _state.value = FileViewState(loading = false, content = it) }
44− .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
45− }
46− }
47− }
48−}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktdeleted−158
@@ -1,158 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import android.content.Intent
4−import androidx.compose.foundation.horizontalScroll
5−import androidx.compose.foundation.layout.Box
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.width
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.itemsIndexed
12−import androidx.compose.foundation.rememberScrollState
13−import androidx.compose.foundation.verticalScroll
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.automirrored.filled.ArrowBack
16−import androidx.compose.material.icons.filled.ContentCopy
17−import androidx.compose.material.icons.filled.Share
18−import androidx.compose.material3.CircularProgressIndicator
19−import androidx.compose.material3.ExperimentalMaterial3Api
20−import androidx.compose.material3.Icon
21−import androidx.compose.material3.IconButton
22−import androidx.compose.material3.MaterialTheme
23−import androidx.compose.material3.Scaffold
24−import androidx.compose.material3.Text
25−import androidx.compose.material3.TopAppBar
26−import androidx.compose.runtime.Composable
27−import androidx.compose.runtime.getValue
28−import androidx.compose.runtime.remember
29−import androidx.compose.ui.Alignment
30−import androidx.compose.ui.Modifier
31−import androidx.compose.ui.platform.LocalContext
32−import androidx.compose.ui.text.font.FontFamily
33−import androidx.compose.ui.text.style.TextOverflow
34−import androidx.compose.ui.unit.dp
35−import androidx.compose.ui.res.stringResource
36−import androidx.lifecycle.compose.collectAsStateWithLifecycle
37−import androidx.lifecycle.viewmodel.compose.viewModel
38−import soy.iko.opencode.di.AppContainer
39−import soy.iko.opencode.R
40−import soy.iko.opencode.ui.components.DiffView
41−import soy.iko.opencode.ui.components.copyToClipboard
42−import soy.iko.opencode.ui.vmFactory
43−
44−@OptIn(ExperimentalMaterial3Api::class)
45−@Composable
46−fun FileViewScreen(
47− container: AppContainer,
48− path: String,
49− onBack: () -> Unit,
50−) {
51− val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
52− val state by vm.state.collectAsStateWithLifecycle()
53− val context = LocalContext.current
54− val filename = remember(path) { path.substringAfterLast('/') }
55− val shareLabel = stringResource(R.string.share)
56−
57− Scaffold(
58− topBar = {
59− TopAppBar(
60− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
61− navigationIcon = {
62− IconButton(onClick = onBack) {
63− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
64− }
65− },
66− actions = {
67− val content = state.content?.content.orEmpty()
68− if (content.isNotEmpty()) {
69− IconButton(onClick = { copyToClipboard(context, filename, content) }) {
70− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
71− }
72− IconButton(onClick = {
73− val send = Intent(Intent.ACTION_SEND).apply {
74− type = "text/plain"
75− putExtra(Intent.EXTRA_SUBJECT, filename)
76− putExtra(Intent.EXTRA_TEXT, content)
77− }
78− runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
79− }) {
80− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
81− }
82− }
83− },
84− )
85− },
86− ) { padding ->
87− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
88− when {
89− state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
90− state.error != null -> Text(
91− state.error ?: "",
92− modifier = Modifier.align(Alignment.Center).padding(24.dp),
93− color = MaterialTheme.colorScheme.error,
94− )
95− state.content?.isBinary == true -> Text(
96− stringResource(R.string.binary_file),
97− modifier = Modifier.align(Alignment.Center).padding(24.dp),
98− color = MaterialTheme.colorScheme.onSurfaceVariant,
99− )
100− else -> {
101− val content = state.content
102− if (content?.diff != null && content.diff.isNotBlank()) {
103− DiffView(
104− diff = content.diff,
105− modifier = Modifier
106− .fillMaxSize()
107− .verticalScroll(rememberScrollState())
108− .padding(8.dp),
109− )
110− } else {
111− val text = content?.content.orEmpty()
112− if (text.isEmpty()) {
113− Text(
114− stringResource(R.string.empty_file),
115− modifier = Modifier.align(Alignment.Center).padding(24.dp),
116− color = MaterialTheme.colorScheme.onSurfaceVariant,
117− )
118− } else {
119− FileTextContent(text)
120− }
121− }
122− }
123− }
124− }
125− }
126−}
127−
128−@Composable
129−private fun FileTextContent(text: String) {
130− val lines = remember(text) { text.split("\n") }
131− val gutterWidth = remember(lines.size) {
132− (lines.size.toString().length.coerceAtLeast(3) * 10).dp
133− }
134− val hScrollState = rememberScrollState()
135− LazyColumn(
136− modifier = Modifier
137− .fillMaxSize()
138− .padding(8.dp),
139− ) {
140− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
141− Row(modifier = Modifier.horizontalScroll(hScrollState)) {
142− Text(
143− "${index + 1}",
144− modifier = Modifier.width(gutterWidth),
145− style = MaterialTheme.typography.bodySmall,
146− fontFamily = FontFamily.Monospace,
147− color = MaterialTheme.colorScheme.onSurfaceVariant,
148− )
149− Text(
150− line,
151− modifier = Modifier.padding(start = 8.dp),
152− style = MaterialTheme.typography.bodySmall,
153− fontFamily = FontFamily.Monospace,
154− )
155− }
156− }
157− }
158−}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+590
@@ -0,0 +1,590 @@
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.ktnew file+222
@@ -0,0 +1,222 @@
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.ktnew file+402
@@ -0,0 +1,402 @@
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.ktdeleted−146
@@ -1,146 +0,0 @@
1−package soy.iko.opencode.ui.server
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.fillMaxSize
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.imePadding
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11−import androidx.compose.foundation.layout.widthIn
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.automirrored.filled.ArrowBack
18−import androidx.compose.material3.Button
19−import androidx.compose.material3.CircularProgressIndicator
20−import androidx.compose.material3.ExperimentalMaterial3Api
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.input.ImeAction
33−import androidx.compose.ui.text.input.KeyboardType
34−import androidx.compose.ui.text.input.PasswordVisualTransformation
35−import androidx.compose.ui.res.stringResource
36−import androidx.compose.ui.unit.dp
37−import androidx.lifecycle.compose.collectAsStateWithLifecycle
38−import androidx.lifecycle.viewmodel.compose.viewModel
39−import soy.iko.opencode.di.AppContainer
40−import soy.iko.opencode.R
41−import soy.iko.opencode.ui.vmFactory
42−
43−@OptIn(ExperimentalMaterial3Api::class)
44−@Composable
45−fun ServerEditScreen(
46− container: AppContainer,
47− profileId: String?,
48− onDone: () -> Unit,
49−) {
50− val vm: ServerEditViewModel =
51− viewModel(factory = vmFactory { ServerEditViewModel(container, profileId) })
52− val state by vm.state.collectAsStateWithLifecycle()
53−
54− Scaffold(
55− topBar = {
56− TopAppBar(
57− title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
58− navigationIcon = {
59− IconButton(onClick = onDone) {
60− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
61− }
62− },
63− )
64− },
65− ) { padding ->
66− if (!state.loaded) {
67− Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
68− CircularProgressIndicator()
69− }
70− } else {
71− Column(
72− modifier = Modifier
73− .padding(padding)
74− .imePadding()
75− .widthIn(max = 600.dp)
76− .padding(16.dp)
77− .verticalScroll(rememberScrollState()),
78− verticalArrangement = Arrangement.spacedBy(12.dp),
79− ) {
80− OutlinedTextField(
81− value = state.label,
82− onValueChange = { v -> vm.update { it.copy(label = v) } },
83− label = { Text(stringResource(R.string.label_optional)) },
84− singleLine = true,
85− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
86− modifier = Modifier.fillMaxWidth(),
87− )
88− OutlinedTextField(
89− value = state.baseUrl,
90− onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
91− label = { Text(stringResource(R.string.base_url)) },
92− placeholder = { Text(stringResource(R.string.base_url_hint)) },
93− singleLine = true,
94− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
95− modifier = Modifier.fillMaxWidth(),
96− )
97− Text(
98− stringResource(R.string.auth_help),
99− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
100− )
101− OutlinedTextField(
102− value = state.username,
103− onValueChange = { v -> vm.update { it.copy(username = v) } },
104− label = { Text(stringResource(R.string.username_optional)) },
105− singleLine = true,
106− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
107− modifier = Modifier.fillMaxWidth(),
108− )
109− OutlinedTextField(
110− value = state.password,
111− onValueChange = { v -> vm.update { it.copy(password = v) } },
112− label = { Text(stringResource(R.string.password_optional)) },
113− singleLine = true,
114− visualTransformation = PasswordVisualTransformation(),
115− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
116− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
117− modifier = Modifier.fillMaxWidth(),
118− )
119− Button(
120− onClick = { vm.save(onDone) },
121− enabled = state.canSave && !state.saving,
122− modifier = Modifier.fillMaxWidth(),
123− ) {
124− if (state.saving) {
125− CircularProgressIndicator(
126− modifier = Modifier.size(20.dp),
127− color = MaterialTheme.colorScheme.onPrimary,
128− strokeWidth = 2.dp,
129− )
130− Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
131− } else {
132− Text(stringResource(R.string.save))
133− }
134− }
135− state.error?.let { message ->
136− Text(
137− message,
138− color = androidx.compose.material3.MaterialTheme.colorScheme.error,
139− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
140− modifier = Modifier.fillMaxWidth(),
141− )
142− }
143− }
144− }
145− }
146−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktdeleted−90
@@ -1,90 +0,0 @@
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 kotlinx.coroutines.withTimeoutOrNull
13−import java.util.UUID
14−
15−data class ServerEditState(
16− val id: String? = null,
17− val label: String = "",
18− val baseUrl: String = "",
19− val username: String = "",
20− val password: String = "",
21− val loaded: Boolean = false,
22− val saving: Boolean = false,
23− val error: String? = null,
24−) {
25− val canSave: Boolean get() = baseUrl.isNotBlank()
26− val isNew: Boolean get() = id == null
27−}
28−
29−class ServerEditViewModel(
30− private val container: AppContainer,
31− private val profileId: String?,
32−) : ViewModel() {
33−
34− private val _state = MutableStateFlow(ServerEditState(id = profileId))
35− val state: StateFlow<ServerEditState> = _state.asStateFlow()
36−
37− init {
38− viewModelScope.launch {
39− if (profileId != null) {
40− val existing = withTimeoutOrNull(5_000) {
41− container.profileStore.profiles.first().firstOrNull { it.id == profileId }
42− }
43− if (existing != null) {
44− _state.value = ServerEditState(
45− id = existing.id,
46− label = existing.label,
47− baseUrl = existing.baseUrl,
48− username = existing.username.orEmpty(),
49− password = existing.password.orEmpty(),
50− loaded = true,
51− )
52− return@launch
53− }
54− }
55− _state.value = _state.value.copy(loaded = true)
56− }
57− }
58−
59− fun update(transform: (ServerEditState) -> ServerEditState) {
60− _state.value = transform(_state.value)
61− }
62−
63− fun save(onDone: () -> Unit) {
64− val s = _state.value
65− if (!s.canSave || s.saving) return
66− _state.value = s.copy(saving = true)
67− viewModelScope.launch {
68− val result = runCatching {
69− val existingLastUsed = if (s.id != null) {
70− withTimeoutOrNull(5_000) {
71− container.profileStore.profiles.first()
72− .firstOrNull { it.id == s.id }?.lastUsed
73− } ?: 0L
74− } else 0L
75− container.profileStore.save(
76− ServerProfile(
77− id = s.id ?: UUID.randomUUID().toString(),
78− label = s.label.trim(),
79− baseUrl = s.baseUrl.trim(),
80− username = s.username.trim().takeIf { it.isNotBlank() },
81− password = s.password.trim().takeIf { it.isNotEmpty() },
82− lastUsed = existingLastUsed,
83− ),
84− )
85− }
86− result.onSuccess { onDone() }
87− .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to save", saving = false) }
88− }
89− }
90−}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktdeleted−224
@@ -1,224 +0,0 @@
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.Spacer
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.filled.Add
17−import androidx.compose.material.icons.filled.CheckCircle
18−import androidx.compose.material.icons.filled.Delete
19−import androidx.compose.material.icons.filled.Dns
20−import androidx.compose.material.icons.filled.Edit
21−import androidx.compose.material3.AlertDialog
22−import androidx.compose.material3.Card
23−import androidx.compose.material3.CircularProgressIndicator
24−import androidx.compose.material3.ExperimentalMaterial3Api
25−import androidx.compose.material3.FloatingActionButton
26−import androidx.compose.material3.Icon
27−import androidx.compose.material3.IconButton
28−import androidx.compose.material3.LinearProgressIndicator
29−import androidx.compose.material3.MaterialTheme
30−import androidx.compose.material3.Scaffold
31−import androidx.compose.material3.SnackbarHost
32−import androidx.compose.material3.SnackbarHostState
33−import androidx.compose.material3.Text
34−import androidx.compose.material3.TextButton
35−import androidx.compose.material3.TopAppBar
36−import androidx.compose.runtime.Composable
37−import androidx.compose.runtime.LaunchedEffect
38−import androidx.compose.runtime.getValue
39−import androidx.compose.runtime.mutableStateOf
40−import androidx.compose.runtime.remember
41−import androidx.compose.runtime.setValue
42−import androidx.compose.ui.Alignment
43−import androidx.compose.ui.Modifier
44−import androidx.compose.ui.platform.LocalHapticFeedback
45−import androidx.compose.ui.text.style.TextOverflow
46−import androidx.compose.ui.unit.dp
47−import androidx.compose.ui.res.stringResource
48−import androidx.lifecycle.compose.collectAsStateWithLifecycle
49−import androidx.lifecycle.viewmodel.compose.viewModel
50−import soy.iko.opencode.data.model.ServerProfile
51−import soy.iko.opencode.di.AppContainer
52−import soy.iko.opencode.R
53−import soy.iko.opencode.ui.vmFactory
54−
55−@OptIn(ExperimentalMaterial3Api::class)
56−@Composable
57−fun ServerListScreen(
58− container: AppContainer,
59− onConnected: () -> Unit,
60− onAddProfile: () -> Unit,
61− onEditProfile: (String) -> Unit,
62−) {
63− val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
64− val profiles by vm.profiles.collectAsStateWithLifecycle()
65− val connectingId by vm.connectingId.collectAsStateWithLifecycle()
66− val error by vm.error.collectAsStateWithLifecycle()
67− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
68− val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
69− val haptics = LocalHapticFeedback.current
70− val snackbar = remember { SnackbarHostState() }
71− var pendingDelete by remember { mutableStateOf<ServerProfile?>(null) }
72− val connectedId = activeConnection?.profile?.id
73−
74− LaunchedEffect(error) {
75− error?.let {
76− snackbar.showSnackbar(it)
77− vm.clearError()
78− }
79− }
80−
81− // On cold start the container auto-reconnects to the last server; once it lands,
82− // skip this screen and go straight to the session list. Consumed once so a later
83− // manual visit to this screen doesn't bounce the user back out.
84− LaunchedEffect(Unit) {
85− container.autoConnectDone.collect { succeeded ->
86− if (succeeded && container.consumeAutoConnect()) onConnected()
87− }
88− }
89−
90− Scaffold(
91− topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
92− snackbarHost = { SnackbarHost(snackbar) },
93− floatingActionButton = {
94− FloatingActionButton(onClick = onAddProfile) {
95− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
96− }
97− },
98− ) { padding ->
99− Column(modifier = Modifier.fillMaxSize()) {
100− if (reconnecting && profiles.isNotEmpty()) {
101− LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
102− }
103− if (profiles.isEmpty()) {
104− EmptyServers(
105− onAdd = onAddProfile,
106− modifier = Modifier.fillMaxSize().padding(padding),
107− )
108− } else {
109− LazyColumn(
110− modifier = Modifier.fillMaxSize().padding(padding),
111− contentPadding = androidx.compose.foundation.layout.PaddingValues(
112− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
113− ),
114− verticalArrangement = Arrangement.spacedBy(12.dp),
115− ) {
116− items(profiles, key = { it.id }) { profile ->
117− val isActive = profile.id == connectedId
118− Card(
119− modifier = Modifier
120− .fillMaxWidth()
121− .animateItem()
122− .clickable(enabled = connectingId == null) {
123− vm.connect(profile, onConnected)
124− },
125− ) {
126− Row(
127− modifier = Modifier.fillMaxWidth().padding(16.dp),
128− verticalAlignment = Alignment.CenterVertically,
129− ) {
130− Column(modifier = Modifier.weight(1f)) {
131− Text(
132− profile.displayLabel,
133− style = MaterialTheme.typography.titleMedium,
134− maxLines = 1,
135− overflow = TextOverflow.Ellipsis,
136− )
137− Text(
138− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
139− style = MaterialTheme.typography.bodySmall,
140− color = MaterialTheme.colorScheme.onSurfaceVariant,
141− maxLines = 1,
142− overflow = TextOverflow.Ellipsis,
143− )
144− if (isActive) {
145− Text(
146− stringResource(R.string.connected),
147− style = MaterialTheme.typography.labelSmall,
148− color = MaterialTheme.colorScheme.primary,
149− )
150− }
151− }
152− if (isActive) {
153− Icon(
154− Icons.Filled.CheckCircle,
155− contentDescription = stringResource(R.string.connected),
156− tint = MaterialTheme.colorScheme.primary,
157− modifier = Modifier.size(22.dp),
158− )
159− } else if (connectingId == profile.id) {
160− CircularProgressIndicator(modifier = Modifier.size(24.dp))
161− } else {
162− IconButton(onClick = { onEditProfile(profile.id) }) {
163− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
164− }
165− IconButton(onClick = { pendingDelete = profile }) {
166− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
167− }
168− }
169− }
170− }
171− }
172− }
173− }
174− }
175− }
176−
177− pendingDelete?.let { profile ->
178− AlertDialog(
179− onDismissRequest = { pendingDelete = null },
180− title = { Text(stringResource(R.string.remove_server_title)) },
181− text = { Text(stringResource(R.string.remove_server_text, profile.displayLabel)) },
182− confirmButton = {
183− TextButton(onClick = {
184− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
185− pendingDelete = null
186− vm.delete(profile)
187− }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
188− },
189− dismissButton = {
190− TextButton(onClick = { pendingDelete = null }) { Text(stringResource(R.string.cancel)) }
191− },
192− )
193− }
194−}
195−
196−@Composable
197−private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
198− Box(modifier = modifier, contentAlignment = Alignment.Center) {
199− Column(
200− modifier = Modifier.padding(32.dp),
201− horizontalAlignment = Alignment.CenterHorizontally,
202− ) {
203− Icon(
204− Icons.Filled.Dns,
205− contentDescription = null,
206− modifier = Modifier.size(56.dp),
207− tint = MaterialTheme.colorScheme.onSurfaceVariant,
208− )
209− Spacer(Modifier.size(16.dp))
210− Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
211− Spacer(Modifier.size(8.dp))
212− Text(
213− stringResource(R.string.no_servers_hint),
214− style = MaterialTheme.typography.bodyMedium,
215− color = MaterialTheme.colorScheme.onSurfaceVariant,
216− )
217− Spacer(Modifier.size(20.dp))
218− TextButton(onClick = onAdd) {
219− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
220− Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
221− }
222− }
223− }
224−}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktdeleted−60
@@ -1,60 +0,0 @@
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 soy.iko.opencode.R
8−import soy.iko.opencode.util.runCatchingCancellable
9−import kotlinx.coroutines.flow.MutableStateFlow
10−import kotlinx.coroutines.flow.SharingStarted
11−import kotlinx.coroutines.flow.StateFlow
12−import kotlinx.coroutines.flow.asStateFlow
13−import kotlinx.coroutines.flow.stateIn
14−import kotlinx.coroutines.launch
15−
16−class ServerListViewModel(private val container: AppContainer) : ViewModel() {
17−
18− val profiles: StateFlow<List<ServerProfile>> =
19− container.profileStore.profiles.stateIn(
20− scope = viewModelScope,
21− started = SharingStarted.WhileSubscribed(5_000),
22− initialValue = emptyList(),
23− )
24−
25− private val _connecting = MutableStateFlow<String?>(null)
26− val connectingId: StateFlow<String?> = _connecting.asStateFlow()
27−
28− private val _error = MutableStateFlow<String?>(null)
29− val error: StateFlow<String?> = _error.asStateFlow()
30−
31− fun clearError() { _error.value = null }
32−
33− fun connect(profile: ServerProfile, onConnected: () -> Unit) {
34− if (_connecting.value != null) return
35− _connecting.value = profile.id
36− _error.value = null
37− viewModelScope.launch {
38− try {
39− val result = runCatchingCancellable {
40− val conn = container.connect(profile)
41− conn.api.ping()
42− }
43− result.onSuccess { onConnected() }
44− .onFailure {
45− container.disconnect()
46− _error.value = container.friendlyError(it)
47− }
48− } finally {
49− _connecting.value = null
50− }
51− }
52− }
53−
54− fun delete(profile: ServerProfile) {
55− viewModelScope.launch {
56− runCatchingCancellable { container.profileStore.delete(profile.id) }
57− .onFailure { _error.value = container.friendlyError(it) }
58− }
59− }
60−}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktdeleted−445
@@ -1,445 +0,0 @@
1−package soy.iko.opencode.ui.session
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.clickable
5−import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Box
7−import androidx.compose.foundation.layout.Column
8−import androidx.compose.foundation.layout.PaddingValues
9−import androidx.compose.foundation.layout.Row
10−import androidx.compose.foundation.layout.Spacer
11−import androidx.compose.foundation.layout.fillMaxSize
12−import androidx.compose.foundation.layout.fillMaxWidth
13−import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.foundation.lazy.LazyColumn
16−import androidx.compose.foundation.text.KeyboardActions
17−import androidx.compose.foundation.text.KeyboardOptions
18−import androidx.compose.foundation.lazy.items
19−import androidx.compose.material.icons.Icons
20−import androidx.compose.material.icons.automirrored.filled.Logout
21−import androidx.compose.material.icons.filled.Add
22−import androidx.compose.material.icons.filled.ArrowDropDown
23−import androidx.compose.material.icons.filled.ChatBubbleOutline
24−import androidx.compose.material.icons.filled.Delete
25−import androidx.compose.material.icons.filled.Edit
26−import androidx.compose.material.icons.filled.Folder
27−import androidx.compose.material.icons.filled.Refresh
28−import androidx.compose.material.icons.filled.Search
29−import androidx.compose.material.icons.filled.Settings
30−import androidx.compose.material3.AlertDialog
31−import androidx.compose.material3.Button
32−import androidx.compose.material3.Card
33−import androidx.compose.material3.CircularProgressIndicator
34−import androidx.compose.material3.DropdownMenu
35−import androidx.compose.material3.DropdownMenuItem
36−import androidx.compose.material3.ExperimentalMaterial3Api
37−import androidx.compose.material3.ExtendedFloatingActionButton
38−import androidx.compose.material3.Icon
39−import androidx.compose.material3.IconButton
40−import androidx.compose.material3.MaterialTheme
41−import androidx.compose.material3.OutlinedTextField
42−import androidx.compose.material3.Scaffold
43−import androidx.compose.material3.SnackbarHost
44−import androidx.compose.material3.SnackbarHostState
45−import androidx.compose.material3.SwipeToDismissBox
46−import androidx.compose.material3.Text
47−import androidx.compose.material3.TextButton
48−import androidx.compose.material3.TopAppBar
49−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
50−import androidx.compose.material3.rememberSwipeToDismissBoxState
51−import androidx.compose.runtime.Composable
52−import androidx.compose.runtime.LaunchedEffect
53−import androidx.compose.runtime.getValue
54−import androidx.compose.runtime.mutableStateOf
55−import androidx.compose.runtime.remember
56−import androidx.compose.runtime.saveable.rememberSaveable
57−import androidx.compose.runtime.setValue
58−import androidx.compose.ui.Alignment
59−import androidx.compose.ui.Modifier
60−import androidx.compose.ui.draw.clip
61−import androidx.compose.ui.platform.LocalHapticFeedback
62−import androidx.compose.ui.semantics.Role
63−import androidx.compose.ui.semantics.contentDescription
64−import androidx.compose.ui.semantics.role
65−import androidx.compose.ui.semantics.semantics
66−import androidx.compose.ui.text.input.ImeAction
67−import androidx.compose.ui.text.style.TextOverflow
68−import androidx.compose.ui.unit.dp
69−import androidx.compose.ui.res.stringResource
70−import androidx.lifecycle.compose.collectAsStateWithLifecycle
71−import androidx.lifecycle.viewmodel.compose.viewModel
72−import soy.iko.opencode.data.model.Session
73−import soy.iko.opencode.di.AppContainer
74−import soy.iko.opencode.R
75−import soy.iko.opencode.ui.components.ConnectionBanner
76−import soy.iko.opencode.ui.components.rememberRelativeTime
77−import soy.iko.opencode.ui.vmFactory
78−
79−@OptIn(ExperimentalMaterial3Api::class)
80−@Composable
81−fun SessionListScreen(
82− container: AppContainer,
83− onOpenSession: (String) -> Unit,
84− onDisconnect: () -> Unit,
85− onOpenFiles: () -> Unit,
86− onOpenSettings: () -> Unit,
87−) {
88− val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
89− val state by vm.state.collectAsStateWithLifecycle()
90− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
91− val transientError by vm.transientError.collectAsStateWithLifecycle()
92− val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
93− val profiles by vm.profiles.collectAsStateWithLifecycle()
94− val switchingId by vm.switchingId.collectAsStateWithLifecycle()
95− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
96− val unread by vm.unread.collectAsStateWithLifecycle()
97− val haptics = LocalHapticFeedback.current
98− val snackbar = remember { SnackbarHostState() }
99− var showServerMenu by remember { mutableStateOf(false) }
100− var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
101− var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
102− val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
103− val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
104−
105− LaunchedEffect(transientError) {
106− val msg = transientError ?: return@LaunchedEffect
107− snackbar.showSnackbar(msg)
108− vm.clearTransientError()
109− }
110−
111− Scaffold(
112− topBar = {
113− TopAppBar(
114− title = {
115− Column {
116− Box {
117− Row(
118− modifier = Modifier
119− .clickable { showServerMenu = true }
120− .semantics { role = Role.Button },
121− verticalAlignment = Alignment.CenterVertically,
122− ) {
123− Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
124− Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
125− }
126− DropdownMenu(
127− expanded = showServerMenu,
128− onDismissRequest = { showServerMenu = false },
129− ) {
130− profiles.forEach { profile ->
131− DropdownMenuItem(
132− text = {
133− Row(verticalAlignment = Alignment.CenterVertically) {
134− Column(modifier = Modifier.weight(1f)) {
135− Text(
136− profile.displayLabel,
137− style = MaterialTheme.typography.bodyLarge,
138− maxLines = 1,
139− overflow = TextOverflow.Ellipsis,
140− )
141− Text(
142− profile.baseUrl,
143− style = MaterialTheme.typography.bodySmall,
144− color = MaterialTheme.colorScheme.onSurfaceVariant,
145− maxLines = 1,
146− overflow = TextOverflow.Ellipsis,
147− )
148− }
149− if (switchingId == profile.id) {
150− CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
151− }
152− }
153− },
154− onClick = {
155− showServerMenu = false
156− vm.switchServer(profile)
157− },
158− )
159− }
160− }
161− }
162− }
163− },
164− actions = {
165− IconButton(onClick = { vm.refresh() }) {
166− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
167− }
168− IconButton(onClick = onOpenFiles) {
169− Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
170− }
171− IconButton(onClick = onOpenSettings) {
172− Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
173− }
174− IconButton(onClick = onDisconnect) {
175− Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
176− }
177− },
178− )
179− },
180− snackbarHost = { SnackbarHost(snackbar) },
181− floatingActionButton = {
182− ExtendedFloatingActionButton(
183− onClick = { vm.createSession(onCreated = onOpenSession) },
184− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
185− text = { Text(stringResource(R.string.new_session)) },
186− )
187− },
188− ) { padding ->
189− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
190− ConnectionBanner(
191− state = connectionState,
192− modifier = Modifier.align(Alignment.TopCenter),
193− )
194− when {
195− state.loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
196− state.sessions.isEmpty() && state.error != null -> Text(
197− state.error ?: "",
198− modifier = Modifier.align(Alignment.Center).padding(24.dp),
199− color = MaterialTheme.colorScheme.error,
200− )
201− state.sessions.isEmpty() -> EmptySessions(
202− onCreate = { vm.createSession(onCreated = onOpenSession) },
203− modifier = Modifier.align(Alignment.Center),
204− )
205− else -> Column(modifier = Modifier.fillMaxSize()) {
206− OutlinedTextField(
207− value = state.query,
208− onValueChange = vm::setQuery,
209− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
210− placeholder = { Text(stringResource(R.string.search_sessions)) },
211− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
212− singleLine = true,
213− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
214− )
215− val sessions = state.filtered
216− if (sessions.isEmpty()) {
217− Text(
218− stringResource(R.string.no_sessions_match, state.query),
219− modifier = Modifier.padding(24.dp),
220− color = MaterialTheme.colorScheme.onSurfaceVariant,
221− )
222− } else {
223− PullToRefreshBox(
224− isRefreshing = refreshing,
225− onRefresh = { vm.refresh() },
226− modifier = Modifier.fillMaxSize(),
227− ) {
228− LazyColumn(
229− modifier = Modifier.fillMaxSize(),
230− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
231− verticalArrangement = Arrangement.spacedBy(12.dp),
232− ) {
233− items(sessions, key = { it.id }) { session ->
234− SessionCard(
235− session = session,
236− preview = state.previews[session.id],
237− unread = unread.contains(session.id),
238− onClick = { onOpenSession(session.id) },
239− onRename = { pendingRenameId = session.id },
240− onDelete = { pendingDeleteId = session.id },
241− modifier = Modifier.animateItem(),
242− )
243− }
244− }
245− }
246− }
247− }
248− }
249− }
250− }
251−
252− pendingDelete?.let { session ->
253− AlertDialog(
254− onDismissRequest = { pendingDeleteId = null },
255− title = { Text(stringResource(R.string.delete_session_title)) },
256− text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
257− confirmButton = {
258− TextButton(onClick = {
259− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
260− pendingDeleteId = null
261− vm.deleteSession(session)
262− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
263− },
264− dismissButton = {
265− TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
266− },
267− )
268− }
269−
270− pendingRename?.let { session ->
271− var title by remember(session.id) { mutableStateOf(session.title ?: "") }
272− RenameSessionDialog(
273− title = title,
274− onTitleChange = { title = it },
275− onConfirm = {
276− val newName = title.trim()
277− pendingRenameId = null
278− if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
279− },
280− onDismiss = { pendingRenameId = null },
281− )
282− }
283−}
284−
285−@Composable
286−private fun RenameSessionDialog(
287− title: String,
288− onTitleChange: (String) -> Unit,
289− onConfirm: () -> Unit,
290− onDismiss: () -> Unit,
291−) {
292− AlertDialog(
293− onDismissRequest = onDismiss,
294− title = { Text(stringResource(R.string.rename_session)) },
295− text = {
296− OutlinedTextField(
297− value = title,
298− onValueChange = onTitleChange,
299− modifier = Modifier.fillMaxWidth(),
300− placeholder = { Text(stringResource(R.string.session_title_hint)) },
301− singleLine = true,
302− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
303− keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
304− )
305− },
306− confirmButton = {
307− TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
308− },
309− dismissButton = {
310− TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
311− },
312− )
313−}
314−
315−@OptIn(ExperimentalMaterial3Api::class)
316−@Composable
317−private fun SessionCard(
318− session: Session,
319− preview: String?,
320− unread: Boolean,
321− onClick: () -> Unit,
322− onRename: () -> Unit,
323− onDelete: () -> Unit,
324− modifier: Modifier = Modifier,
325−) {
326− // Swipe toward the start reveals a delete affordance; releasing snaps back and opens
327− // the same confirmation dialog the trash icon uses, so the destructive action is
328− // always confirmed.
329− val dismissState = rememberSwipeToDismissBoxState(
330− confirmValueChange = {
331− onDelete()
332− false // snap back; the dialog owns the actual deletion
333− },
334− )
335− SwipeToDismissBox(
336− state = dismissState,
337− backgroundContent = { SwipeDeleteBackground() },
338− enableDismissFromStartToEnd = false,
339− modifier = modifier,
340− ) {
341− Card(
342− modifier = Modifier
343− .fillMaxWidth()
344− .clickable(role = Role.Button) { onClick() },
345− ) {
346− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
347− Row(
348− modifier = Modifier.fillMaxWidth(),
349− verticalAlignment = Alignment.CenterVertically,
350− ) {
351− if (unread) {
352− val unreadLabel = stringResource(R.string.unread)
353− Box(
354− modifier = Modifier
355− .padding(end = 8.dp)
356− .size(8.dp)
357− .clip(androidx.compose.foundation.shape.CircleShape)
358− .background(MaterialTheme.colorScheme.primary)
359− .semantics { contentDescription = unreadLabel },
360− )
361− }
362− Text(
363− session.displayTitle,
364− style = MaterialTheme.typography.titleMedium,
365− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
366− maxLines = 1,
367− overflow = TextOverflow.Ellipsis,
368− modifier = Modifier.weight(1f),
369− )
370− val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
371− if (time.isNotEmpty()) {
372− Text(
373− time,
374− style = MaterialTheme.typography.labelSmall,
375− color = MaterialTheme.colorScheme.onSurfaceVariant,
376− )
377− }
378− IconButton(onClick = onRename) {
379− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
380− }
381− IconButton(onClick = onDelete) {
382− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
383− }
384− }
385− if (!preview.isNullOrBlank()) {
386− Spacer(Modifier.size(4.dp))
387− Text(
388− preview,
389− style = MaterialTheme.typography.bodyMedium,
390− color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
391− maxLines = 2,
392− overflow = TextOverflow.Ellipsis,
393− )
394− }
395− }
396− }
397− }
398−}
399−
400−/** Red background with a trash icon shown behind a session card as it is swiped away. */
401−@Composable
402−private fun SwipeDeleteBackground() {
403− Box(
404− modifier = Modifier
405− .fillMaxSize()
406− .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp))
407− .background(MaterialTheme.colorScheme.errorContainer)
408− .padding(horizontal = 20.dp),
409− contentAlignment = Alignment.CenterEnd,
410− ) {
411− Icon(
412− Icons.Filled.Delete,
413− contentDescription = null,
414− tint = MaterialTheme.colorScheme.onErrorContainer,
415− )
416− }
417−}
418−
419−@Composable
420−private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
421− Column(
422− modifier = modifier.padding(32.dp),
423− horizontalAlignment = Alignment.CenterHorizontally,
424− ) {
425− Icon(
426− Icons.Filled.ChatBubbleOutline,
427− contentDescription = null,
428− modifier = Modifier.size(56.dp),
429− tint = MaterialTheme.colorScheme.onSurfaceVariant,
430− )
431− Spacer(Modifier.size(16.dp))
432− Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
433− Spacer(Modifier.size(8.dp))
434− Text(
435− stringResource(R.string.no_sessions_hint),
436− style = MaterialTheme.typography.bodyMedium,
437− color = MaterialTheme.colorScheme.onSurfaceVariant,
438− )
439− Spacer(Modifier.size(20.dp))
440− Button(onClick = onCreate) {
441− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
442− Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
443− }
444− }
445−}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktdeleted−309
@@ -1,309 +0,0 @@
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.data.model.SessionDeleted
8−import soy.iko.opencode.data.model.SessionUpdated
9−import soy.iko.opencode.data.model.TextPart
10−import soy.iko.opencode.data.network.EventStreamClient
11−import soy.iko.opencode.data.network.NetworkConfig
12−import soy.iko.opencode.di.AppContainer
13−import soy.iko.opencode.R
14−import soy.iko.opencode.util.runCatchingCancellable
15−import kotlinx.coroutines.ExperimentalCoroutinesApi
16−import kotlinx.coroutines.Job
17−import kotlinx.coroutines.flow.MutableStateFlow
18−import kotlinx.coroutines.flow.SharingStarted
19−import kotlinx.coroutines.flow.StateFlow
20−import kotlinx.coroutines.flow.asStateFlow
21−import kotlinx.coroutines.flow.collectLatest
22−import kotlinx.coroutines.flow.flatMapLatest
23−import kotlinx.coroutines.flow.flowOf
24−import kotlinx.coroutines.flow.stateIn
25−import kotlinx.coroutines.flow.update
26−import kotlinx.coroutines.joinAll
27−import kotlinx.coroutines.launch
28−import kotlinx.coroutines.sync.Semaphore
29−import kotlinx.coroutines.sync.withPermit
30−
31−data class SessionListState(
32− val sessions: List<Session> = emptyList(),
33− val query: String = "",
34− val previews: Map<String, String> = emptyMap(),
35− val loading: Boolean = true,
36− val error: String? = null,
37−) {
38− /** Sessions filtered by the current search query (title or preview text). */
39− val filtered: List<Session>
40− get() {
41− val q = query.trim()
42− if (q.isEmpty()) return sessions
43− return sessions.filter { session ->
44− session.displayTitle.contains(q, ignoreCase = true) ||
45− (previews[session.id]?.contains(q, ignoreCase = true) == true)
46− }
47− }
48−}
49−
50−class SessionListViewModel(private val container: AppContainer) : ViewModel() {
51−
52− /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
53− @OptIn(ExperimentalCoroutinesApi::class)
54− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
55− container.activeConnection
56− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
57− .stateIn(
58− scope = viewModelScope,
59− started = SharingStarted.WhileSubscribed(5_000),
60− initialValue = EventStreamClient.ConnectionState.Disconnected,
61− )
62−
63− /** Sessions with activity that arrived while not being viewed. */
64− val unread: StateFlow<Set<String>> = container.unread
65−
66− val profiles: StateFlow<List<ServerProfile>> =
67− container.profileStore.profiles.stateIn(
68− scope = viewModelScope,
69− started = SharingStarted.WhileSubscribed(5_000),
70− initialValue = emptyList(),
71− )
72−
73− private val _serverLabel = MutableStateFlow(
74− container.activeConnection.value?.profile?.displayLabel ?: "opencode"
75− )
76− val serverLabel: StateFlow<String> = _serverLabel.asStateFlow()
77−
78− private val _switchingId = MutableStateFlow<String?>(null)
79− val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
80−
81− private val _state = MutableStateFlow(SessionListState())
82− val state: StateFlow<SessionListState> = _state.asStateFlow()
83−
84− /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
85− private val _refreshing = MutableStateFlow(false)
86− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
87−
88− /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
89− private val _transientError = MutableStateFlow<String?>(null)
90− val transientError: StateFlow<String?> = _transientError.asStateFlow()
91− fun clearTransientError() { _transientError.value = null }
92−
93− private var previewJob: Job? = null
94− private var refreshJob: Job? = null
95−
96− /** Per-session in-flight preview loads, so rapid SSE events for the same session
97− * coalesce into a single request instead of firing one per event. Accessed from
98− * multiple coroutines (the SSE event handler and [loadPreview]), so the
99− * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
100− private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
101− private val previewLock = Any()
102−
103− private val activeProfileId: String?
104− get() = container.activeConnection.value?.profile?.id
105−
106− init { refresh(); observeSessionEvents() }
107−
108− /**
109− * Keep the session list live by reacting to SSE session events. New/renamed
110− * sessions upsert and re-sort; deleted sessions drop immediately — no manual
111− * refresh needed when activity happens server-side.
112− */
113− private fun observeSessionEvents() {
114− viewModelScope.launch {
115− container.activeConnection.collectLatest { conn ->
116− if (conn == null) return@collectLatest
117− _serverLabel.value = conn.profile.displayLabel
118− // Reload the session list when a connection becomes available so the
119− // initial load (which may have run before auto-reconnect finished and
120− // found no connection) doesn't leave a stale "not connected" error.
121− refresh()
122− conn.events.events.collect { event ->
123− when (event) {
124− is SessionUpdated -> {
125− val session = event.properties.info
126− _state.update { s ->
127− val updated = (s.sessions.filterNot { it.id == session.id } + session)
128− .sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
129− s.copy(sessions = updated)
130− }
131− loadPreview(session.id)
132− }
133− is SessionDeleted -> {
134− val id = event.properties.info?.id ?: event.properties.sessionID
135− if (id != null) {
136− livePreviewJobs.remove(id)?.cancel()
137− container.draftStore.remove(id)
138− _state.update { s ->
139− s.copy(
140− sessions = s.sessions.filterNot { it.id == id },
141− previews = s.previews - id,
142− )
143− }
144− }
145− }
146− else -> {}
147− }
148− }
149− }
150− }
151− }
152−
153− fun setQuery(query: String) {
154− _state.update { it.copy(query = query) }
155− }
156−
157− fun refresh() {
158− val conn = container.activeConnection.value
159− if (conn == null) {
160− if (_state.value.sessions.isEmpty()) {
161− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
162− } else {
163− _state.update { it.copy(loading = false) }
164− _transientError.value = container.string(R.string.not_connected)
165− }
166− return
167− }
168− _serverLabel.value = conn.profile.displayLabel
169− _state.update { it.copy(loading = true, error = null) }
170− _refreshing.value = true
171− refreshJob?.cancel()
172− refreshJob = viewModelScope.launch {
173− runCatchingCancellable { conn.repository.listSessions() }
174− .onSuccess { list ->
175− val sorted = list.sortedByDescending { it.time?.updated ?: it.time?.created ?: 0 }
176− _state.update { it.copy(sessions = sorted, loading = false, error = null) }
177− loadPreviews(sorted)
178− }
179− .onFailure {
180− if (_state.value.sessions.isNotEmpty()) {
181− _state.update { it.copy(loading = false) }
182− _transientError.value = container.friendlyError(it)
183− } else {
184− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
185− }
186− }
187− _refreshing.value = false
188− }
189− }
190−
191− /**
192− * Reload the preview for a single session (used by the live SSE handler).
193− * Cancels any prior in-flight load for the same session so a burst of
194− * [SessionUpdated] events coalesces into a single request.
195− */
196− private fun loadPreview(sessionId: String) {
197− val conn = container.activeConnection.value ?: return
198− val api = conn.api
199− synchronized(previewLock) {
200− livePreviewJobs.remove(sessionId)?.cancel()
201− livePreviewJobs[sessionId] = viewModelScope.launch {
202− val job = coroutineContext[kotlinx.coroutines.Job]
203− try {
204− val preview = fetchSessionPreview(api, sessionId) ?: return@launch
205− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
206− } finally {
207− if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
208− }
209− }
210− }
211− }
212−
213− /** Fetch the last text part of each session for list previews (best-effort, bounded). */
214− private fun loadPreviews(sessions: List<Session>) {
215− previewJob?.cancel()
216− val conn = container.activeConnection.value ?: return
217− val api = conn.api
218− // Keep previews for sessions still in the list; drop stale ones without
219− // clearing the whole map (which would cause a visual flash).
220− val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
221− _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
222− previewJob = viewModelScope.launch {
223− val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
224− sessions.take(NetworkConfig.maxPreviewSessions).map { session ->
225− launch {
226− semaphore.withPermit {
227− val preview = fetchSessionPreview(api, session.id) ?: return@withPermit
228− _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
229− }
230− }
231− }.joinAll()
232− }
233− }
234−
235− fun createSession(onCreated: (String) -> Unit) {
236− val conn = container.activeConnection.value ?: return
237− viewModelScope.launch {
238− runCatchingCancellable { conn.repository.createSession() }
239− .onSuccess { onCreated(it.id); refresh() }
240− .onFailure {
241− _state.update { it.copy(error = null) }
242− _transientError.value = container.friendlyError(it)
243− }
244− }
245− }
246−
247− fun deleteSession(session: Session) {
248− val conn = container.activeConnection.value ?: return
249− viewModelScope.launch {
250− runCatchingCancellable { conn.repository.deleteSession(session.id) }
251− .onSuccess {
252− container.draftStore.remove(session.id)
253− refresh()
254− }
255− .onFailure {
256− _state.update { it.copy(error = null) }
257− _transientError.value = container.friendlyError(it)
258− }
259− }
260− }
261−
262− fun renameSession(session: Session, newTitle: String) {
263− val conn = container.activeConnection.value ?: return
264− val title = newTitle.trim()
265− if (title.isEmpty() || title == session.title) return
266− viewModelScope.launch {
267− runCatchingCancellable { conn.api.updateSession(session.id, title) }
268− .onSuccess { refresh() }
269− .onFailure {
270− _state.update { it.copy(error = null) }
271− _transientError.value = container.friendlyError(it)
272− }
273− }
274− }
275−
276− /** Quick-switch to a different saved server without leaving the session list. */
277− fun switchServer(profile: ServerProfile) {
278− if (profile.id == activeProfileId) return
279− if (_switchingId.value != null) return
280− _switchingId.value = profile.id
281− _state.update { it.copy(loading = true, error = null) }
282− viewModelScope.launch {
283− val result = runCatchingCancellable {
284− val conn = container.connect(profile)
285− conn.api.ping()
286− }
287− _switchingId.value = null
288− result.onSuccess { refresh() }
289− .onFailure {
290− container.disconnect()
291− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
292− }
293− }
294− }
295−}
296−
297−/**
298− * Fetch the last text part of a session's most recent message, truncated to
299− * [NetworkConfig.previewTextMaxLength]. Returns null on failure or when the
300− * last message has no text part.
301− */
302−private suspend fun fetchSessionPreview(
303− api: soy.iko.opencode.data.network.OpencodeApiClient,
304− sessionId: String,
305−): String? = runCatchingCancellable {
306− api.listMessages(sessionId).lastOrNull()?.let { msg ->
307− msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
308− }
309−}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktdeleted−127
@@ -1,127 +0,0 @@
1−package soy.iko.opencode.ui.session
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.widthIn
10−import androidx.activity.compose.BackHandler
11−import androidx.compose.material.icons.Icons
12−import androidx.compose.material.icons.automirrored.filled.Chat
13−import androidx.compose.material3.Icon
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.Text
16−import androidx.compose.runtime.Composable
17−import androidx.compose.runtime.LaunchedEffect
18−import androidx.compose.runtime.getValue
19−import androidx.compose.runtime.key
20−import androidx.compose.runtime.mutableStateOf
21−import androidx.compose.runtime.saveable.rememberSaveable
22−import androidx.compose.runtime.setValue
23−import androidx.compose.ui.Alignment
24−import androidx.compose.ui.Modifier
25−import androidx.compose.ui.res.stringResource
26−import androidx.compose.ui.unit.dp
27−import androidx.lifecycle.compose.collectAsStateWithLifecycle
28−import soy.iko.opencode.R
29−import soy.iko.opencode.di.AppContainer
30−import soy.iko.opencode.ui.chat.ChatScreen
31−
32−/**
33− * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
34− * the session list on the left, the selected conversation on the right. On compact
35− * widths the app uses the single-pane [androidx.navigation] back stack instead.
36− *
37− * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
38− * two panes are self-contained. Selecting a session updates the detail pane rather than
39− * pushing a destination; the chat [onBack] clears the selection. The chat composition is
40− * keyed by session id so each conversation gets its own ViewModel.
41− */
42−@Composable
43−fun TwoPaneSessionChat(
44− container: AppContainer,
45− onOpenFiles: () -> Unit,
46− onOpenSettings: () -> Unit,
47− onDisconnect: () -> Unit,
48− modifier: Modifier = Modifier,
49−) {
50− var selected by rememberSaveable { mutableStateOf<String?>(null) }
51− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
52− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
53−
54− // A notification tap / deep link requests a session: open it in the detail pane.
55− LaunchedEffect(pendingOpenSession) {
56− pendingOpenSession?.let {
57− selected = it
58− container.consumePendingOpenSession()
59− }
60− }
61−
62− // Inject a pending share into the currently selected session's draft (if any).
63− // Unlike single-pane mode where the session list is navigated to specifically for
64− // the share, in two-pane mode the list is always visible — so we inject into the
65− // session the user is already viewing rather than whichever one they tap next.
66− LaunchedEffect(pendingShare, selected) {
67− val target = selected
68− if (pendingShare != null && target != null) {
69− container.consumePendingShare()?.let { container.draftStore.set(target, it) }
70− }
71− }
72−
73− Row(modifier = modifier.fillMaxSize()) {
74− Box(
75− modifier = Modifier
76− .weight(0.38f)
77− .widthIn(max = 460.dp),
78− ) {
79− SessionListScreen(
80− container = container,
81− onOpenSession = { id ->
82− selected = id
83− },
84− onDisconnect = onDisconnect,
85− onOpenFiles = onOpenFiles,
86− onOpenSettings = onOpenSettings,
87− )
88− }
89−
90− Box(modifier = Modifier.weight(0.62f).fillMaxSize()) {
91− val sessionId = selected
92− if (sessionId == null) {
93− EmptyDetail()
94− } else {
95− BackHandler { selected = null }
96− key(sessionId) {
97− ChatScreen(
98− container = container,
99− sessionId = sessionId,
100− onBack = { selected = null },
101− )
102− }
103− }
104− }
105− }
106−}
107−
108−@Composable
109−private fun EmptyDetail() {
110− Column(
111− modifier = Modifier.fillMaxSize().padding(32.dp),
112− verticalArrangement = Arrangement.Center,
113− horizontalAlignment = Alignment.CenterHorizontally,
114− ) {
115− Icon(
116− Icons.AutoMirrored.Filled.Chat,
117− contentDescription = null,
118− tint = MaterialTheme.colorScheme.outline,
119− )
120− Text(
121− stringResource(R.string.empty_detail_pane),
122− style = MaterialTheme.typography.bodyLarge,
123− color = MaterialTheme.colorScheme.onSurfaceVariant,
124− modifier = Modifier.padding(top = 12.dp),
125− )
126− }
127−}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktdeleted−194
@@ -1,194 +0,0 @@
1−package soy.iko.opencode.ui.settings
2−
3−import android.content.Intent
4−import androidx.compose.foundation.clickable
5−import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.Spacer
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.ArrowBack
17−import androidx.compose.material.icons.filled.BugReport
18−import androidx.compose.material.icons.filled.DeleteSweep
19−import androidx.compose.material.icons.filled.Share
20−import androidx.compose.material3.AlertDialog
21−import androidx.compose.material3.ExperimentalMaterial3Api
22−import androidx.compose.material3.HorizontalDivider
23−import androidx.compose.material3.Icon
24−import androidx.compose.material3.IconButton
25−import androidx.compose.material3.MaterialTheme
26−import androidx.compose.material3.Scaffold
27−import androidx.compose.material3.Text
28−import androidx.compose.material3.TextButton
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.mutableStateOf
34−import androidx.compose.runtime.remember
35−import androidx.compose.runtime.rememberCoroutineScope
36−import androidx.compose.runtime.setValue
37−import kotlinx.coroutines.Dispatchers
38−import kotlinx.coroutines.launch
39−import kotlinx.coroutines.withContext
40−import androidx.compose.ui.Alignment
41−import androidx.compose.ui.Modifier
42−import androidx.compose.ui.platform.LocalContext
43−import androidx.compose.ui.res.stringResource
44−import androidx.compose.ui.semantics.Role
45−import androidx.compose.ui.semantics.heading
46−import androidx.compose.ui.semantics.role
47−import androidx.compose.ui.semantics.semantics
48−import androidx.compose.ui.unit.dp
49−import androidx.lifecycle.compose.collectAsStateWithLifecycle
50−import soy.iko.opencode.R
51−import soy.iko.opencode.data.repo.CrashLogger
52−import soy.iko.opencode.ui.components.rememberRelativeTime
53−
54−@OptIn(ExperimentalMaterial3Api::class)
55−@Composable
56−fun DiagnosticsScreen(onBack: () -> Unit) {
57− val context = LocalContext.current
58− val logger = remember { CrashLogger.get(context) }
59− val reports by logger.reports.collectAsStateWithLifecycle()
60− var viewing by remember { mutableStateOf<String?>(null) }
61− val shareScope = rememberCoroutineScope()
62− val shareLabel = stringResource(R.string.share)
63−
64− Scaffold(
65− topBar = {
66− TopAppBar(
67− title = { Text(stringResource(R.string.diagnostics)) },
68− navigationIcon = {
69− IconButton(onClick = onBack) {
70− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
71− }
72− },
73− actions = {
74− if (reports.isNotEmpty()) {
75− IconButton(onClick = {
76− shareScope.launch(Dispatchers.IO) { runCatching { logger.clearAll() } }
77− }) {
78− Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
79− }
80− }
81− },
82− )
83− },
84− ) { padding ->
85− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
86− Text(
87− stringResource(R.string.diagnostics_desc),
88− style = MaterialTheme.typography.bodySmall,
89− color = MaterialTheme.colorScheme.onSurfaceVariant,
90− modifier = Modifier.padding(16.dp),
91− )
92− Text(
93− stringResource(R.string.crash_reports),
94− style = MaterialTheme.typography.titleSmall,
95− color = MaterialTheme.colorScheme.primary,
96− modifier = Modifier
97− .padding(start = 16.dp, bottom = 8.dp)
98− .semantics { heading() },
99− )
100− if (reports.isEmpty()) {
101− Text(
102− stringResource(R.string.no_crash_reports),
103− style = MaterialTheme.typography.bodyMedium,
104− color = MaterialTheme.colorScheme.onSurfaceVariant,
105− modifier = Modifier.padding(24.dp),
106− )
107− } else {
108− LazyColumn(modifier = Modifier.fillMaxSize()) {
109− items(reports, key = { it.fileName }) { report ->
110− Row(
111− modifier = Modifier
112− .fillMaxWidth()
113− .clickable(role = Role.Button) { viewing = report.fileName }
114− .padding(horizontal = 16.dp, vertical = 12.dp),
115− verticalAlignment = Alignment.CenterVertically,
116− horizontalArrangement = Arrangement.spacedBy(12.dp),
117− ) {
118− Icon(
119− Icons.Filled.BugReport,
120− contentDescription = null,
121− tint = MaterialTheme.colorScheme.error,
122− )
123− Column(modifier = Modifier.weight(1f)) {
124− Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
125− Text(
126− rememberRelativeTime(report.timestamp),
127− style = MaterialTheme.typography.bodySmall,
128− color = MaterialTheme.colorScheme.onSurfaceVariant,
129− )
130− }
131− IconButton(onClick = {
132− shareScope.launch {
133− val content = withContext(Dispatchers.IO) {
134− logger.readReport(report.fileName).orEmpty()
135− }
136− val send = Intent(Intent.ACTION_SEND).apply {
137− type = "text/plain"
138− putExtra(Intent.EXTRA_SUBJECT, shareLabel)
139− putExtra(Intent.EXTRA_TEXT, content)
140− }
141− runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
142− }
143− }) {
144− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
145− }
146− }
147− HorizontalDivider()
148− }
149− }
150− }
151− }
152− }
153−
154− val reportName = viewing
155− if (reportName != null) {
156− var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
157− LaunchedEffect(reportName) {
158− reportContent = withContext(Dispatchers.IO) { logger.readReport(reportName) }
159− }
160− val content = reportContent
161− AlertDialog(
162− onDismissRequest = { viewing = null },
163− confirmButton = {
164− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.back)) }
165− },
166− dismissButton = {
167− Row {
168− val shareLabel2 = stringResource(R.string.share)
169− TextButton(onClick = {
170− val send = Intent(Intent.ACTION_SEND).apply {
171− type = "text/plain"
172− putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
173− putExtra(Intent.EXTRA_TEXT, content.orEmpty())
174− }
175− runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
176− }) { Text(stringResource(R.string.share)) }
177− Spacer(Modifier.size(8.dp))
178− TextButton(onClick = {
179− shareScope.launch(Dispatchers.IO) { runCatching { logger.deleteReport(reportName) } }
180− viewing = null
181− }) { Text(stringResource(R.string.delete)) }
182− }
183− },
184− title = { Text(stringResource(R.string.crash_report)) },
185− text = {
186− Text(
187− content ?: "",
188− style = MaterialTheme.typography.bodySmall,
189− modifier = Modifier.fillMaxWidth(),
190− )
191− },
192− )
193− }
194−}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktdeleted−210
@@ -1,210 +0,0 @@
1−package soy.iko.opencode.ui.settings
2−
3−import android.content.pm.PackageManager
4−import android.os.Build
5−import androidx.compose.foundation.clickable
6−import androidx.compose.foundation.layout.Arrangement
7−import androidx.compose.foundation.layout.Column
8−import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.Spacer
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.layout.widthIn
14−import androidx.compose.foundation.selection.selectable
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.automirrored.filled.ArrowBack
17−import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
18−import androidx.compose.material.icons.filled.BugReport
19−import androidx.compose.material.icons.filled.Dns
20−import androidx.compose.material3.ExperimentalMaterial3Api
21−import androidx.compose.material3.HorizontalDivider
22−import androidx.compose.material3.Icon
23−import androidx.compose.material3.IconButton
24−import androidx.compose.material3.MaterialTheme
25−import androidx.compose.material3.RadioButton
26−import androidx.compose.material3.Scaffold
27−import androidx.compose.material3.Switch
28−import androidx.compose.material3.Text
29−import androidx.compose.material3.TextButton
30−import androidx.compose.material3.TopAppBar
31−import androidx.compose.runtime.Composable
32−import androidx.compose.runtime.getValue
33−import androidx.compose.runtime.remember
34−import androidx.compose.runtime.rememberCoroutineScope
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.platform.LocalContext
38−import androidx.compose.ui.res.stringResource
39−import androidx.compose.ui.semantics.Role
40−import androidx.compose.ui.semantics.heading
41−import androidx.compose.ui.semantics.role
42−import androidx.compose.ui.semantics.semantics
43−import androidx.compose.ui.unit.dp
44−import androidx.lifecycle.compose.collectAsStateWithLifecycle
45−import kotlinx.coroutines.launch
46−import soy.iko.opencode.data.repo.ThemeMode
47−import soy.iko.opencode.di.AppContainer
48−import soy.iko.opencode.R
49−
50−@OptIn(ExperimentalMaterial3Api::class)
51−@Composable
52−fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
53− val scope = rememberCoroutineScope()
54− val themeMode by container.settingsStore.themeMode.collectAsStateWithLifecycle(initialValue = ThemeMode.SYSTEM)
55− val dynamicColor by container.settingsStore.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
56− val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
57− val context = LocalContext.current
58− val unknownVersion = stringResource(R.string.unknown_version)
59− val versionName = remember {
60− runCatching {
61− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
62− context.packageManager.getPackageInfo(
63− context.packageName,
64− PackageManager.PackageInfoFlags.of(0),
65− ).versionName
66− } else {
67− @Suppress("DEPRECATION")
68− context.packageManager.getPackageInfo(context.packageName, 0).versionName
69− }
70− }.getOrNull() ?: unknownVersion
71− }
72−
73− Scaffold(
74− topBar = {
75− TopAppBar(
76− title = { Text(stringResource(R.string.settings)) },
77− navigationIcon = {
78− IconButton(onClick = onBack) {
79− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
80− }
81− },
82− )
83− },
84− ) { padding ->
85− Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).padding(16.dp)) {
86− Text(
87− stringResource(R.string.appearance),
88− style = MaterialTheme.typography.titleSmall,
89− color = MaterialTheme.colorScheme.primary,
90− modifier = Modifier.semantics { heading() },
91− )
92− ThemeMode.entries.forEach { mode ->
93− ThemeRow(
94− mode = mode,
95− selected = themeMode == mode,
96− onSelect = { scope.launch { runCatching { container.settingsStore.setThemeMode(mode) } } },
97− )
98− }
99−
100− Spacer(Modifier.size(8.dp))
101− Row(
102− modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
103− verticalAlignment = Alignment.CenterVertically,
104− ) {
105− Column(modifier = Modifier.weight(1f)) {
106− Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
107− Text(
108− stringResource(R.string.dynamic_color_desc),
109− style = MaterialTheme.typography.bodySmall,
110− color = MaterialTheme.colorScheme.onSurfaceVariant,
111− )
112− }
113− Switch(
114− checked = dynamicColor,
115− onCheckedChange = { scope.launch { runCatching { container.settingsStore.setDynamicColor(it) } } },
116− )
117− }
118−
119− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
120−
121− Text(
122− stringResource(R.string.connection),
123− style = MaterialTheme.typography.titleSmall,
124− color = MaterialTheme.colorScheme.primary,
125− modifier = Modifier.semantics { heading() },
126− )
127− if (activeProfile != null) {
128− Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
129− Text(
130− activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
131− style = MaterialTheme.typography.bodySmall,
132− color = MaterialTheme.colorScheme.onSurfaceVariant,
133− )
134− } else {
135− Text(
136− stringResource(R.string.not_connected),
137− modifier = Modifier.padding(top = 8.dp),
138− color = MaterialTheme.colorScheme.onSurfaceVariant,
139− )
140− }
141− Row(
142− modifier = Modifier
143− .fillMaxWidth()
144− .padding(top = 8.dp)
145− .clickable(role = Role.Button) { onManageServers() }
146− .padding(vertical = 8.dp),
147− verticalAlignment = Alignment.CenterVertically,
148− ) {
149− Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
150− Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
151− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
152− }
153−
154− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
155−
156− Text(
157− stringResource(R.string.about),
158− style = MaterialTheme.typography.titleSmall,
159− color = MaterialTheme.colorScheme.primary,
160− modifier = Modifier.semantics { heading() },
161− )
162− Text(
163− stringResource(R.string.about_version, versionName),
164− style = MaterialTheme.typography.bodyMedium,
165− modifier = Modifier.padding(top = 8.dp),
166− )
167− Text(
168− stringResource(R.string.about_desc),
169− style = MaterialTheme.typography.bodySmall,
170− color = MaterialTheme.colorScheme.onSurfaceVariant,
171− modifier = Modifier.padding(top = 4.dp),
172− )
173−
174− Spacer(Modifier.size(16.dp))
175− Row(
176− modifier = Modifier
177− .fillMaxWidth()
178− .clickable(role = Role.Button) { onOpenDiagnostics() }
179− .padding(vertical = 8.dp),
180− verticalAlignment = Alignment.CenterVertically,
181− ) {
182− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
183− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
184− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
185− }
186− }
187− }
188−}
189−
190−@Composable
191−private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
192− Row(
193− modifier = Modifier
194− .fillMaxWidth()
195− .selectable(selected = selected, onClick = onSelect)
196− .padding(vertical = 8.dp),
197− horizontalArrangement = Arrangement.spacedBy(8.dp),
198− verticalAlignment = Alignment.CenterVertically,
199− ) {
200− RadioButton(selected = selected, onClick = onSelect)
201− Text(
202− when (mode) {
203− ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
204− ThemeMode.LIGHT -> stringResource(R.string.theme_light)
205− ThemeMode.DARK -> stringResource(R.string.theme_dark)
206− },
207− style = MaterialTheme.typography.bodyLarge,
208− )
209− }
210−}
app/src/main/java/soy/iko/opencode/ui/theme/Color.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.graphics.Color
4+
5+// A calm, terminal-adjacent palette. Used when dynamic color isn't available.
6+val Emerald = Color(0xFF3DDC84)
7+val EmeraldDark = Color(0xFF1B8F52)
8+
9+val DarkBackground = Color(0xFF0F1115)
10+val DarkSurface = Color(0xFF171A21)
11+val DarkSurfaceVariant = Color(0xFF232833)
12+val DarkOnSurface = Color(0xFFE6E9EF)
13+val DarkOutline = Color(0xFF3A4150)
14+
15+val LightBackground = Color(0xFFF7F8FA)
16+val LightSurface = Color(0xFFFFFFFF)
17+val LightSurfaceVariant = Color(0xFFEBEDF2)
18+val LightOnSurface = Color(0xFF1A1C1F)
19+val LightOutline = Color(0xFFC4C9D4)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+39−27
@@ -1,8 +1,5 @@
1package soy.iko.opencode.ui.theme
1package soy.iko.opencode.ui.theme
2
2
3−import android.app.Activity
4−import android.content.Context
5−import android.content.ContextWrapper
6import android.os.Build
3import android.os.Build
7import androidx.compose.foundation.isSystemInDarkTheme
4import androidx.compose.foundation.isSystemInDarkTheme
8import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.MaterialTheme
@@ -11,24 +8,50 @@ import androidx.compose.material3.dynamicDarkColorScheme
11import androidx.compose.material3.dynamicLightColorScheme
8import androidx.compose.material3.dynamicLightColorScheme
12import androidx.compose.material3.lightColorScheme
9import androidx.compose.material3.lightColorScheme
13import androidx.compose.runtime.Composable
10import androidx.compose.runtime.Composable
14−import androidx.compose.runtime.SideEffect
15import androidx.compose.ui.graphics.Color
11import androidx.compose.ui.graphics.Color
16import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.platform.LocalContext
17−import androidx.compose.ui.platform.LocalView
18−import androidx.core.view.WindowCompat
19
13
20private val DarkColors = darkColorScheme(
14private val DarkColors = darkColorScheme(
21− primary = Color(0xFF7AA2F7),
15+ primary = Emerald,
16+ onPrimary = Color(0xFF0F1115),
17+ primaryContainer = Color(0xFF1A3D2A),
18+ onPrimaryContainer = Color(0xFFA8E6C1),
22− secondary = Color(0xFF9ECE6A),
19+ secondary = EmeraldDark,
20+ onSecondary = Color(0xFFE6E9EF),
21+ secondaryContainer = Color(0xFF1A2A3D),
22+ onSecondaryContainer = Color(0xFFA8C5E6),
23− tertiary = Color(0xFFBB9AF7),
23+ tertiary = Color(0xFF6B7280),
24+ onTertiary = Color(0xFFE6E9EF),
25+ tertiaryContainer = Color(0xFF2A2A3D),
26+ onTertiaryContainer = Color(0xFFC5C5E6),
27+ errorContainer = Color(0xFF4D2626),
28+ onErrorContainer = Color(0xFFFFB4B4),
24− background = Color(0xFF1A1B26),
29+ background = DarkBackground,
25− surface = Color(0xFF1F2335),
30+ surface = DarkSurface,
31+ surfaceVariant = DarkSurfaceVariant,
32+ onSurface = DarkOnSurface,
33+ outline = DarkOutline,
26)
34)
27
35
28private val LightColors = lightColorScheme(
36private val LightColors = lightColorScheme(
29− primary = Color(0xFF34548A),
37+ primary = EmeraldDark,
38+ primaryContainer = Color(0xFFD7F5E3),
39+ onPrimaryContainer = Color(0xFF0A3D22),
30− secondary = Color(0xFF485E30),
40+ secondary = Emerald,
41+ onSecondary = Color(0xFF1A1C1F),
42+ secondaryContainer = Color(0xFFD7E8F5),
43+ onSecondaryContainer = Color(0xFF0A2A4D),
31− tertiary = Color(0xFF5A4A78),
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,
32)
55)
33
56
34@Composable
57@Composable
@@ -44,21 +67,10 @@ fun OpencodeTheme(
44 darkTheme -> DarkColors
67 darkTheme -> DarkColors
45 else -> LightColors
68 else -> LightColors
46 }
69 }
47− val view = LocalView.current
70+
48− if (!view.isInEditMode) {
49− SideEffect {
50− val window = view.context.findActivity()?.window ?: return@SideEffect
51− WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
52− }
53− }
54 MaterialTheme(
71 MaterialTheme(
55 colorScheme = colorScheme,
72 colorScheme = colorScheme,
73+ typography = AppTypography,
56 content = content,
74 content = content,
57 )
75 )
58}
76}
59−
60−private tailrec fun Context.findActivity(): Activity? = when (this) {
61− is Activity -> this
62− is ContextWrapper -> baseContext.findActivity()
63− else -> null
64−}
app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+15
@@ -0,0 +1,15 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.material3.Typography
4+import androidx.compose.ui.text.TextStyle
5+import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.unit.sp
7+
8+private val base = Typography()
9+
10+val AppTypography = base.copy(
11+ bodyLarge = base.bodyLarge.copy(lineHeight = 22.sp),
12+ // Slightly monospaced feel for message bodies is applied inline where needed.
13+)
14+
15+val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/java/soy/iko/opencode/util/Coroutines.ktdeleted−17
@@ -1,17 +0,0 @@
1−package soy.iko.opencode.util
2−
3−import kotlinx.coroutines.CancellationException
4−
5−/**
6− * Like [runCatching] but rethrows [CancellationException] so structured cancellation
7− * works correctly inside coroutines. Using plain [runCatching] in a coroutine swallows
8− * cancellation, causing [Result.onFailure] to run on a dying scope and mutate state
9− * that may never be observed (and defeating cooperative cancellation).
10− */
11−inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
12− Result.success(block())
13−} catch (e: CancellationException) {
14− throw e
15−} catch (e: Throwable) {
16− Result.failure(e)
17−}
app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−13
@@ -1,13 +0,0 @@
1−<vector xmlns:android="http://schemas.android.com/apk/res/android"
2− android:width="108dp"
3− android:height="108dp"
4− android:viewportWidth="108"
5− android:viewportHeight="108">
6− <!-- A terminal prompt chevron + underscore cursor, the opencode motif. -->
7− <path
8− android:fillColor="#FFFFFF"
9− android:pathData="M34,38 L52,54 L34,70 L40,70 L58,54 L40,38 Z" />
10− <path
11− android:fillColor="#FFFFFF"
12− android:pathData="M62,66 L78,66 L78,72 L62,72 Z" />
13−</vector>
app/src/main/res/mipmap-anydpi/ic_launcher.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@color/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_foreground" />
6−</adaptive-icon>
app/src/main/res/values-night/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="window_background">#0F1115</color>
4+</resources>
app/src/main/res/values-night/themes.xmlnew file+8
@@ -0,0 +1,8 @@
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.xml+1−2
@@ -1,5 +1,4 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3− <color name="splash_background">#34548A</color>
3+ <color name="window_background">#F7F8FA</color>
4− <color name="ic_launcher_background">#34548A</color>
5</resources>
4</resources>
app/src/main/res/values/strings.xml−166
@@ -1,170 +1,4 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2<resources>
2<resources>
3 <string name="app_name">opencode</string>
3 <string name="app_name">opencode</string>
4− <string name="unknown_version">0.1.0</string>
5−
6− <!-- Common -->
7− <string name="back">Back</string>
8− <string name="cancel">Cancel</string>
9− <string name="save">Save</string>
10− <string name="delete">Delete</string>
11− <string name="edit">Edit</string>
12− <string name="remove">Remove</string>
13− <string name="copy">Copy</string>
14− <string name="copied">Copied</string>
15− <string name="retry">Retry</string>
16− <string name="refresh">Refresh</string>
17− <string name="settings">Settings</string>
18− <string name="files">Files</string>
19− <string name="share">Share</string>
20− <string name="share_subject">opencode session</string>
21− <string name="send">Send</string>
22− <string name="stop">Stop</string>
23− <string name="rename">Rename</string>
24− <string name="disconnect">Disconnect</string>
25−
26− <!-- Theme modes -->
27− <string name="theme_system">System</string>
28− <string name="theme_light">Light</string>
29− <string name="theme_dark">Dark</string>
30−
31− <!-- Connection banner -->
32− <string name="connecting">Connecting…</string>
33− <string name="reconnecting">Reconnecting…</string>
34− <string name="reconnect">Reconnect</string>
35− <string name="not_connected">Not connected.</string>
36−
37− <!-- Server list -->
38− <string name="servers_title">opencode servers</string>
39− <string name="add_server">Add server</string>
40− <string name="edit_server">Edit server</string>
41− <string name="connected">Connected</string>
42− <string name="no_servers_yet">No servers yet</string>
43− <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
44− <string name="remove_server_title">Remove server?</string>
45− <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
46− <string name="server_auth_short"> • auth</string>
47− <string name="server_auth_basic"> • Basic auth</string>
48−
49− <!-- Server edit -->
50− <string name="label_optional">Label (optional)</string>
51− <string name="base_url">Base URL</string>
52− <string name="base_url_hint">http://192.168.1.10:4096</string>
53− <string name="auth_help">Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. Username defaults to \"opencode\".</string>
54− <string name="username_optional">Username (optional)</string>
55− <string name="password_optional">Password (optional)</string>
56−
57− <!-- Session list -->
58− <string name="new_session">New session</string>
59− <string name="unread">Unread</string>
60− <string name="no_sessions_yet">No sessions yet</string>
61− <string name="no_sessions_hint">Start a conversation with opencode.</string>
62− <string name="search_sessions">Search sessions…</string>
63− <string name="no_sessions_match">No sessions match “%1$s”.</string>
64− <string name="switch_server">Switch server</string>
65− <string name="delete_session_title">Delete session?</string>
66− <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
67− <string name="rename_session">Rename session</string>
68− <string name="session_title_hint">Session title</string>
69−
70− <!-- Chat -->
71− <string name="session">Session</string>
72− <string name="default_model">default model</string>
73− <string name="message_placeholder">Message opencode…</string>
74− <string name="empty_chat_title">Start a conversation</string>
75− <string name="empty_chat_hint">Type a message below to begin.</string>
76− <string name="empty_detail_pane">Select a session to view the conversation.</string>
77− <string name="working">working…</string>
78− <string name="latest">Latest</string>
79− <string name="stop_and_exit_title">Stop and exit?</string>
80− <string name="stop_and_exit_text">The agent is still working. Exit anyway?</string>
81− <string name="stop_and_exit">Stop &amp; exit</string>
82− <string name="stay">Stay</string>
83− <string name="commands">Commands</string>
84− <string name="choose_agent">Choose agent</string>
85− <string name="choose_model">Choose model</string>
86− <string name="share_conversation">Share conversation</string>
87−
88− <!-- Errors (view models) -->
89− <string name="error_generic">Something went wrong.</string>
90− <string name="error_client_status">Request failed (%1$d).</string>
91− <string name="error_agent_reported">The agent reported an error.</string>
92− <string name="error_not_reachable">Could not reach %1$s</string>
93− <string name="error_timeout">The request timed out. Check the connection and try again.</string>
94− <string name="error_server">The server reported an error. Try again later.</string>
95−
96− <!-- Part renderer -->
97− <string name="thinking">Thinking…</string>
98− <string name="thoughts">Thoughts</string>
99− <string name="show_more">Show more</string>
100− <string name="show_less">Show less</string>
101− <string name="tool_completed">Completed</string>
102− <string name="tool_error">Error</string>
103− <string name="state_expanded">Expanded</string>
104− <string name="state_collapsed">Collapsed</string>
105−
106− <!-- Permission dialog -->
107− <string name="permission_title">Permission requested</string>
108− <string name="permission_text">Allow this tool to run?</string>
109− <string name="allow_once">Allow once</string>
110− <string name="always_allow">Always allow</string>
111− <string name="reject">Reject</string>
112−
113− <!-- Pickers -->
114− <string name="model">Model</string>
115− <string name="no_models">No models reported by the server.</string>
116− <string name="agent">Agent</string>
117− <string name="no_agents">No agents reported by the server.</string>
118− <string name="default_agent">Default</string>
119− <string name="default_agent_desc">Server default agent</string>
120− <string name="no_commands">No commands reported by the server.</string>
121− <string name="saving">Saving…</string>
122− <string name="image_failed">Failed to load image</string>
123−
124− <!-- File browser -->
125− <string name="search_files">Search files…</string>
126− <string name="root">Root</string>
127− <string name="no_matches">No matches.</string>
128− <string name="git_added">Added</string>
129− <string name="git_modified">Modified</string>
130− <string name="git_deleted">Deleted</string>
131− <string name="binary_file">Binary file — preview not supported.</string>
132− <string name="empty_folder">This folder is empty</string>
133− <string name="empty_file">This file is empty</string>
134− <string name="parent_dir">Parent directory</string>
135− <string name="folder">Folder: %1$s</string>
136− <string name="file_label">File: %1$s</string>
137− <string name="file">File</string>
138−
139− <!-- Message metadata -->
140− <string name="image">image</string>
141− <string name="tokens_in_out">%1$s in · %2$s out</string>
142−
143− <!-- Settings -->
144− <string name="appearance">Appearance</string>
145− <string name="dynamic_color">Dynamic color</string>
146− <string name="dynamic_color_desc">Use Material You wallpaper colors (Android 12+)</string>
147− <string name="connection">Connection</string>
148− <string name="manage_servers">Manage servers</string>
149− <string name="about">About</string>
150− <string name="about_version">opencode-android %1$s</string>
151− <string name="about_desc">A native Android client for the opencode serve HTTP API.</string>
152−
153− <!-- Diagnostics -->
154− <string name="diagnostics">Diagnostics</string>
155− <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
156− <string name="crash_reports">Crash reports</string>
157− <string name="no_crash_reports">No crashes recorded. \ud83d\udc4d</string>
158− <string name="clear_all">Clear all</string>
159− <string name="crash_report">Crash report</string>
160−
161− <!-- Notifications -->
162− <string name="notif_channel_status">Agent activity</string>
163− <string name="notif_channel_status_desc">Shown while an opencode agent is running.</string>
164− <string name="notif_channel_completed">Session completed</string>
165− <string name="notif_channel_completed_desc">Shown when a background session finishes a run.</string>
166− <string name="notif_running_title">opencode</string>
167− <string name="notif_running_text">Agent is working…</string>
168− <string name="notif_completed_title">Session ready</string>
169− <string name="notif_completed_text">opencode replied in “%1$s”.</string>
170</resources>
4</resources>
app/src/main/res/values/themes.xml+9−10
@@ -1,14 +1,13 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<!--
3− The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
4− theme only governs the window before Compose and hosts the SplashScreen API.
5−-->
6<resources>
2<resources>
7− <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
3+ <!--
8−
4+ Minimal platform theme. All real theming is done in Compose (OpencodeTheme);
9− <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
5+ this only removes the action bar and gives a neutral window background so
6+ there's no flash before the first frame.
7+ -->
10− <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
8+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.Light.NoActionBar">
11− <item name="windowSplashScreenBackground">@color/splash_background</item>
9+ <item name="android:windowBackground">@color/window_background</item>
12− <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
10+ <item name="android:statusBarColor">@android:color/transparent</item>
11+ <item name="android:navigationBarColor">@android:color/transparent</item>
13 </style>
12 </style>
14</resources>
13</resources>
app/src/main/res/xml/data_extraction_rules.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<data-extraction-rules>
3− <cloud-backup>
4− <exclude domain="root" />
5− <exclude domain="file" />
6− <exclude domain="database" />
7− <exclude domain="sharedpref" />
8− </cloud-backup>
9− <device-transfer>
10− <exclude domain="root" />
11− <exclude domain="file" />
12− <exclude domain="database" />
13− <exclude domain="sharedpref" />
14− </device-transfer>
15−</data-extraction-rules>
app/src/main/res/xml/network_security_config.xmldeleted−15
@@ -1,15 +0,0 @@
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 xmlns:tools="http://schemas.android.com/tools">
8− <base-config
9− cleartextTrafficPermitted="true"
10− tools:ignore="InsecureBaseConfiguration">
11− <trust-anchors>
12− <certificates src="system" />
13− </trust-anchors>
14− </base-config>
15−</network-security-config>
app/src/test/java/soy/iko/opencode/M5DecodingTest.ktdeleted−107
@@ -1,107 +0,0 @@
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.ktdeleted−102
@@ -1,102 +0,0 @@
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.ktdeleted−88
@@ -1,88 +0,0 @@
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/UxTest.ktdeleted−91
@@ -1,91 +0,0 @@
1−package soy.iko.opencode
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Assert.assertTrue
5−import org.junit.Test
6−import soy.iko.opencode.data.model.Session
7−import soy.iko.opencode.data.model.TimeInfo
8−import soy.iko.opencode.ui.components.relativeTime
9−import soy.iko.opencode.ui.session.SessionListState
10−import java.util.concurrent.TimeUnit
11−
12−class UxTest {
13−
14− // --- relativeTime ---
15−
16− @Test
17− fun relativeTimeHandlesNullAndZero() {
18− assertEquals("", relativeTime(null))
19− assertEquals("", relativeTime(0))
20− assertEquals("", relativeTime(-1))
21− }
22−
23− @Test
24− fun relativeTimeFormatsMinutes() {
25− val now = System.currentTimeMillis()
26− assertEquals("now", relativeTime(now))
27− assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
28− }
29−
30− @Test
31− fun relativeTimeFormatsHoursAndDays() {
32− val now = System.currentTimeMillis()
33− assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
34− assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
35− assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
36− }
37−
38− // --- Session search/filter ---
39−
40− private fun session(id: String, title: String) =
41− Session(id = id, title = title)
42−
43− @Test
44− fun emptyQueryReturnsAllSessions() {
45− val state = SessionListState(
46− sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
47− )
48− assertEquals(2, state.filtered.size)
49− }
50−
51− @Test
52− fun queryFiltersByTitle() {
53− val state = SessionListState(
54− sessions = listOf(session("1", "Refactor tests"), session("2", "Add docs")),
55− query = "refactor",
56− )
57− val result = state.filtered
58− assertEquals(1, result.size)
59− assertEquals("1", result.first().id)
60− }
61−
62− @Test
63− fun queryFiltersByPreviewText() {
64− val state = SessionListState(
65− sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
66− previews = mapOf("2" to "discuss the database migration"),
67− query = "database",
68− )
69− val result = state.filtered
70− assertEquals(1, result.size)
71− assertEquals("2", result.first().id)
72− }
73−
74− @Test
75− fun queryIsCaseInsensitive() {
76− val state = SessionListState(
77− sessions = listOf(session("1", "Build Pipeline")),
78− query = "build",
79− )
80− assertEquals(1, state.filtered.size)
81− }
82−
83− @Test
84− fun noMatchReturnsEmpty() {
85− val state = SessionListState(
86− sessions = listOf(session("1", "Alpha")),
87− query = "zzz",
88− )
89− assertTrue(state.filtered.isEmpty())
90− }
91−}
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktnew file+69
@@ -0,0 +1,69 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.test.runTest
6+import okhttp3.OkHttpClient
7+import okhttp3.Interceptor
8+import org.junit.Assert.assertEquals
9+import org.junit.Assert.assertTrue
10+import org.junit.Test
11+import java.io.IOException
12+
13+/**
14+ * Covers two related connect-screen bugs:
15+ * - the URL field must stay editable — normalizing per keystroke made it
16+ * "impossible to input" (it ate the "://" as you typed);
17+ * - a scheme-less URL must still be normalized to http:// on connect, and the
18+ * HTTP client must never crash on a malformed URL.
19+ */
20+class ConnectUrlTest {
21+ private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
22+
23+ /** A client that never touches the network, so Connect's probe can't hang. */
24+ private fun offlineCore(): OpencodeCore {
25+ val offline = OkHttpClient.Builder()
26+ .addInterceptor(Interceptor { throw IOException("offline in test") })
27+ .build()
28+ return OpencodeCore(CoroutineScope(Dispatchers.Unconfined), HttpClient(offline))
29+ }
30+
31+ @Test
32+ fun `typing preserves raw input verbatim`() {
33+ val core = core()
34+ // Mid-scheme text the user is still typing must not be rewritten.
35+ core.dispatch(Event.ServerUrlChanged("http:/"))
36+ assertEquals("http:/", core.view.value.serverUrl)
37+ }
38+
39+ @Test
40+ fun `connect normalizes a scheme-less url to http`() {
41+ val core = offlineCore()
42+ core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
43+ assertEquals("192.168.1.10:4096", core.view.value.serverUrl) // raw while typing
44+ core.dispatch(Event.Connect)
45+ assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl) // normalized on connect
46+ }
47+
48+ @Test
49+ fun `connect trims and keeps an explicit scheme`() {
50+ val core = offlineCore()
51+ core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
52+ core.dispatch(Event.Connect)
53+ assertEquals("https://host:4096", core.view.value.serverUrl)
54+ }
55+
56+ @Test
57+ fun `connect on a blank url reports an error`() {
58+ val core = core()
59+ core.dispatch(Event.ServerUrlChanged(" "))
60+ core.dispatch(Event.Connect)
61+ assertEquals("Enter a server URL", core.view.value.error)
62+ }
63+
64+ @Test
65+ fun `http client returns failure rather than throwing on an unparseable url`() = runTest {
66+ val result = HttpClient().get("not a valid url", auth = null)
67+ assertTrue("expected a Result.failure, got $result", result.isFailure)
68+ }
69+}
app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import io.ktor.client.HttpClient
4−import io.ktor.client.engine.mock.MockEngine
5−import io.ktor.client.engine.mock.respondError
6−import io.ktor.client.plugins.sse.SSE
7−import io.ktor.http.HttpStatusCode
8−import kotlinx.coroutines.ExperimentalCoroutinesApi
9−import kotlinx.coroutines.cancel
10−import org.junit.Assert.assertEquals
11−import org.junit.Test
12−
13−@OptIn(ExperimentalCoroutinesApi::class)
14−class EventStreamClientTest {
15−
16− /**
17− * The SSE plugin requires a streaming response body which MockEngine can't provide,
18− * so full SSE integration isn't testable in pure JVM tests. The reconnect/backoff
19− * logic is covered by [WithRetryTest] (same exponential-backoff formula) and the
20− * event decoding is covered by [OpencodeJsonTest]. Here we verify only the initial
21− * state — the connection-state machine starts in Disconnected.
22− */
23− @Test
24− fun stateIsDisconnectedBeforeSubscribing() {
25− val engine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
26− val client = HttpClient(engine) { install(SSE) }
27− val scope = kotlinx.coroutines.CoroutineScope(
28− kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined,
29− )
30− val sse = EventStreamClient(client, scope)
31−
32− assertEquals(EventStreamClient.ConnectionState.Disconnected, sse.state.value)
33−
34− scope.cancel()
35− client.close()
36− }
37−}
app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktdeleted−42
@@ -1,42 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Test
5−
6−class NormalizeBaseUrlTest {
7−
8− @Test
9− fun addsHttpSchemeWhenAbsent() {
10− assertEquals("https://192.168.1.10:4096/", HttpClientFactory.normalizeBaseUrl("192.168.1.10:4096"))
11− }
12−
13− @Test
14− fun preservesHttpScheme() {
15− assertEquals("http://localhost:4096/", HttpClientFactory.normalizeBaseUrl("http://localhost:4096"))
16− }
17−
18− @Test
19− fun preservesHttpsScheme() {
20− assertEquals("https://example.com/", HttpClientFactory.normalizeBaseUrl("https://example.com"))
21− }
22−
23− @Test
24− fun appendsTrailingSlash() {
25− assertEquals("https://host/", HttpClientFactory.normalizeBaseUrl("host"))
26− }
27−
28− @Test
29− fun keepsSingleTrailingSlash() {
30− assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl("http://host/"))
31− }
32−
33− @Test
34− fun trimsSurroundingWhitespace() {
35− assertEquals("http://host/", HttpClientFactory.normalizeBaseUrl(" http://host "))
36− }
37−
38− @Test
39− fun keepsPathAfterHost() {
40− assertEquals("https://host:4096/opencode/", HttpClientFactory.normalizeBaseUrl("host:4096/opencode"))
41− }
42−}
app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktdeleted−66
@@ -1,66 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import io.ktor.client.HttpClient
4−import io.ktor.client.engine.mock.MockEngine
5−import io.ktor.client.engine.mock.respond
6−import io.ktor.client.engine.mock.respondError
7−import io.ktor.client.plugins.ClientRequestException
8−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9−import io.ktor.http.HttpStatusCode
10−import io.ktor.http.headersOf
11−import io.ktor.serialization.kotlinx.json.json
12−import kotlinx.coroutines.test.runTest
13−import org.junit.Assert.assertEquals
14−import org.junit.Assert.assertTrue
15−import org.junit.Test
16−import soy.iko.opencode.data.model.PermissionResponse
17−import java.io.IOException
18−
19−class OpencodeApiClientTest {
20−
21− private fun makeClient(engine: MockEngine): HttpClient =
22− HttpClient(engine) {
23− expectSuccess = true
24− install(ContentNegotiation) { json(OpencodeJson) }
25− }
26−
27− @Test
28− fun respondPermissionRetriesOnTransientFailure() = runTest {
29− var calls = 0
30− val engine = MockEngine {
31− calls++
32− if (calls < 3) throw IOException("transient")
33− respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
34− }
35− val api = OpencodeApiClient(makeClient(engine))
36− api.respondPermission("s1", "p1", PermissionResponse.ALWAYS)
37− assertEquals("transient failures should be retried", 3, calls)
38− }
39−
40− @Test
41− fun respondPermissionDoesNotRetryClientError() = runTest {
42− var calls = 0
43− val engine = MockEngine {
44− calls++
45− respondError(HttpStatusCode.BadRequest)
46− }
47− val api = OpencodeApiClient(makeClient(engine))
48− val error = runCatching {
49− api.respondPermission("s1", "p1", PermissionResponse.REJECT)
50− }.exceptionOrNull()!!
51− assertEquals("4xx must not be retried", 1, calls)
52− assertTrue("expected ClientRequestException, got $error", error is ClientRequestException)
53− }
54−
55− @Test
56− fun respondPermissionSucceedsOnFirstTry() = runTest {
57− var calls = 0
58− val engine = MockEngine {
59− calls++
60− respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
61− }
62− val api = OpencodeApiClient(makeClient(engine))
63− api.respondPermission("s1", "p1", PermissionResponse.ONCE)
64− assertEquals(1, calls)
65− }
66−}
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktdeleted−161
@@ -1,161 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import io.ktor.client.HttpClient
4−import io.ktor.client.engine.mock.MockEngine
5−import io.ktor.client.engine.mock.respond
6−import io.ktor.client.plugins.ClientRequestException
7−import io.ktor.client.plugins.ServerResponseException
8−import io.ktor.client.request.get
9−import io.ktor.http.HttpStatusCode
10−import io.ktor.http.headersOf
11−import kotlinx.coroutines.CancellationException
12−import kotlinx.coroutines.test.runTest
13−import org.junit.Assert.assertEquals
14−import org.junit.Assert.assertTrue
15−import org.junit.Test
16−import java.io.IOException
17−
18−class WithRetryTest {
19−
20− /** Produce the real exception Ktor throws for a status when expectSuccess = true. */
21− private suspend fun httpError(status: HttpStatusCode): Throwable {
22− val client = HttpClient(MockEngine { respond("", status, headersOf("Content-Length", "0")) }) {
23− expectSuccess = true
24− }
25− return client.use { runCatching { it.get("test") }.exceptionOrNull()!! }
26− }
27−
28− @Test
29− fun returnsValueOnFirstAttempt() = runTest {
30− var calls = 0
31− val result = withRetryInternal { calls++; "ok" }
32− assertEquals("ok", result)
33− assertEquals(1, calls)
34− }
35−
36− @Test
37− fun retriesTransientFailureThenSucceeds() = runTest {
38− var calls = 0
39− val result = withRetryInternal(initialDelayMs = 0) {
40− calls++
41− if (calls < 3) throw IOException("boom")
42− "ok"
43− }
44− assertEquals("ok", result)
45− assertEquals(3, calls)
46− }
47−
48− @Test
49− fun throwsAfterExhaustingAttempts() = runTest {
50− var calls = 0
51− val error = runCatching {
52− withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
53− calls++
54− throw IOException("always")
55− }
56− }.exceptionOrNull()!!
57− assertTrue("expected IOException, got $error", error is IOException)
58− assertEquals(3, calls)
59− }
60−
61− @Test
62− fun clientErrorIsNotRetried() = runTest {
63− val notFound = httpError(HttpStatusCode.NotFound)
64− assertTrue("expected ClientRequestException, got $notFound", notFound is ClientRequestException)
65− var calls = 0
66− val error = runCatching {
67− withRetryInternal(initialDelayMs = 0) {
68− calls++
69− throw notFound
70− }
71− }.exceptionOrNull()!!
72− assertEquals("4xx must not be retried", 1, calls)
73− assertTrue(error is ClientRequestException)
74− }
75−
76− @Test
77− fun serverErrorIsRetried() = runTest {
78− val serverError = httpError(HttpStatusCode.InternalServerError)
79− assertTrue("expected ServerResponseException, got $serverError", serverError is ServerResponseException)
80− var calls = 0
81− runCatching {
82− withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
83− calls++
84− throw serverError
85− }
86− }
87− assertEquals("5xx must be retried", 3, calls)
88− }
89−
90− @Test
91− fun cancellationIsRethrownImmediately() = runTest {
92− var calls = 0
93− val error = runCatching {
94− withRetryInternal(initialDelayMs = 0) {
95− calls++
96− throw CancellationException("cancel")
97− }
98− }.exceptionOrNull()
99− assertEquals("cancellation must not be retried", 1, calls)
100− assertTrue("expected CancellationException, got $error", error is CancellationException)
101− }
102−
103− @Test
104− fun maxAttemptsOneDoesNotRetry() = runTest {
105− var calls = 0
106− val error = runCatching {
107− withRetryInternal(maxAttempts = 1, initialDelayMs = 0) {
108− calls++
109− throw IOException("boom")
110− }
111− }.exceptionOrNull()!!
112− assertEquals("maxAttempts=1 means a single attempt only", 1, calls)
113− assertTrue(error is IOException)
114− }
115−
116− @Test
117− fun succeedsOnLastAllowedAttempt() = runTest {
118− var calls = 0
119− val result = withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
120− calls++
121− if (calls < 3) throw IOException("transient")
122− "ok"
123− }
124− assertEquals("ok", result)
125− assertEquals(3, calls)
126− }
127−
128− @Test
129− fun exponentialBackoffDoublesDelayBetweenAttempts() = runTest {
130− var calls = 0
131− val start = testScheduler.currentTime
132− runCatching {
133− withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
134− calls++
135− throw IOException("always")
136− }
137− }
138− // Delays: attempt 1→2 waits 100ms (100 * 2^0), attempt 2→3 waits 200ms
139− // (100 * 2^1). Total virtual time elapsed = 300ms.
140− assertEquals(3, calls)
141− assertEquals(300L, testScheduler.currentTime - start)
142− }
143−
144− @Test
145− fun backoffDelayFormulaIsInitialTimesTwoToAttemptMinusOne() = runTest {
146− val delays = mutableListOf<Long>()
147− var calls = 0
148− var lastTime = testScheduler.currentTime
149− runCatching {
150− withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
151− calls++
152− val now = testScheduler.currentTime
153− if (calls > 1) delays.add(now - lastTime)
154− lastTime = now
155− throw IOException("always")
156− }
157− }
158− // Expected delays: 50, 100, 200 (50 * 2^0, 50 * 2^1, 50 * 2^2)
159− assertEquals(listOf(50L, 100L, 200L), delays)
160− }
161−}
app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktdeleted−60
@@ -1,60 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.HttpRequestTimeoutException
4−import io.ktor.client.request.HttpRequestBuilder
5−import org.junit.Assert.assertEquals
6−import org.junit.Test
7−import java.io.IOException
8−import java.net.ConnectException
9−import java.net.SocketTimeoutException
10−import java.net.UnknownHostException
11−
12−class ErrorKindTest {
13−
14− @Test
15− fun unknownHostIsNotReachable() {
16− assertEquals(ErrorKind.NOT_REACHABLE, classifyError(UnknownHostException("nope")))
17− }
18−
19− @Test
20− fun connectRefusedIsNotReachable() {
21− assertEquals(ErrorKind.NOT_REACHABLE, classifyError(ConnectException("refused")))
22− }
23−
24− @Test
25− fun socketTimeoutIsTimeout() {
26− assertEquals(ErrorKind.TIMEOUT, classifyError(SocketTimeoutException("read timed out")))
27− }
28−
29− @Test
30− fun httpRequestTimeoutIsTimeout() {
31− assertEquals(ErrorKind.TIMEOUT, classifyError(HttpRequestTimeoutException(HttpRequestBuilder())))
32− }
33−
34− @Test
35− fun genericIoIsNetwork() {
36− assertEquals(ErrorKind.NETWORK, classifyError(IOException("broken pipe")))
37− }
38−
39− @Test
40− fun unrelatedThrowableIsUnknown() {
41− assertEquals(ErrorKind.UNKNOWN, classifyError(IllegalStateException("boom")))
42− }
43−
44− @Test
45− fun unwrapsCauseChainToClassifyRoot() {
46− // A library may surface a network failure wrapped in a non-IO exception.
47− val wrapped = RuntimeException("request failed", UnknownHostException("nested"))
48− assertEquals(ErrorKind.NOT_REACHABLE, classifyError(wrapped))
49− }
50−
51− @Test
52− fun doesNotLoopInfinitelyOnCyclicCause() {
53− // A self-referential cause chain must be terminated by the bounded hop count,
54− // not followed into a stack overflow.
55− val cyclic: Throwable = object : Throwable("cyclic") {
56− override val cause: Throwable? get() = this
57− }
58− assertEquals(ErrorKind.UNKNOWN, classifyError(cyclic))
59− }
60−}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktdeleted−298
@@ -1,298 +0,0 @@
1−package soy.iko.opencode.data.repo
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.AssistantMessage
8−import soy.iko.opencode.data.model.MessagePartRemoved
9−import soy.iko.opencode.data.model.MessagePartUpdated
10−import soy.iko.opencode.data.model.MessageRemoved
11−import soy.iko.opencode.data.model.MessageUpdated
12−import soy.iko.opencode.data.model.MessageWithParts
13−import soy.iko.opencode.data.model.SessionError
14−import soy.iko.opencode.data.model.SessionIdle
15−import soy.iko.opencode.data.model.TextPart
16−import soy.iko.opencode.data.model.UnknownMessage
17−import soy.iko.opencode.data.model.UserMessage
18−
19−class MessageStoreTest {
20−
21− private val session = "s1"
22−
23− private fun msgUpdated(id: String, sid: String = session) =
24− MessageUpdated(MessageUpdated.Props(AssistantMessage(id = id, sessionID = sid)))
25−
26− private fun partUpdated(messageId: String, partId: String, text: String, sid: String = session) =
27− MessagePartUpdated(
28− MessagePartUpdated.Props(
29− part = TextPart(id = partId, messageID = messageId, sessionID = sid, text = text),
30− sessionID = sid,
31− messageID = messageId,
32− ),
33− )
34−
35− private fun partRemoved(messageId: String, partId: String, sid: String = session) =
36− MessagePartRemoved(
37− MessagePartRemoved.Props(sessionID = sid, messageID = messageId, partID = partId),
38− )
39−
40− private fun messageRemoved(messageId: String, sid: String = session) =
41− MessageRemoved(MessageRemoved.Props(sessionID = sid, messageID = messageId))
42−
43− // --- seed ---
44−
45− @Test
46− fun seedPopulatesSnapshot() {
47− val store = MessageStore()
48− store.seed(
49− listOf(
50− MessageWithParts(UserMessage("u1", session)),
51− MessageWithParts(AssistantMessage("a1", session)),
52− ),
53− )
54− assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
55− }
56−
57− // --- seed merge (race between streamed part and initial load) ---
58−
59− @Test
60− fun seedMergesStreamedPartsWithSnapshotInsteadOfOverwriting() {
61− val store = MessageStore()
62− // A part streams in before the initial load completes, creating a synthetic
63− // placeholder message.
64− store.reduce(session, partUpdated("a1", "t1", "streamed"))
65− // The REST snapshot for the same message arrives.
66− store.seed(
67− listOf(
68− MessageWithParts(
69− AssistantMessage(id = "a1", sessionID = session),
70− parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
71− ),
72− ),
73− )
74− val msg = store.snapshot().single()
75− assertTrue("info should be the real one", msg.info is AssistantMessage)
76− // The streamed part (newer) must win over the snapshot's duplicate.
77− assertEquals("streamed", (msg.parts.single() as TextPart).text)
78− }
79−
80− @Test
81− fun seedAppendsSnapshotOnlyPartsAfterStreamedOnes() {
82− val store = MessageStore()
83− store.reduce(session, partUpdated("a1", "t2", "streamed-t2"))
84− store.seed(
85− listOf(
86− MessageWithParts(
87− AssistantMessage(id = "a1", sessionID = session),
88− parts = listOf(
89− TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snap-t1"),
90− TextPart(id = "t2", messageID = "a1", sessionID = session, text = "snap-t2"),
91− ),
92− ),
93− ),
94− )
95− val msg = store.snapshot().single()
96− val parts = msg.parts
97− assertEquals(listOf("t1", "t2"), parts.map { it.id })
98− assertEquals("snap-t1", (parts[0] as TextPart).text)
99− assertEquals("streamed-t2", (parts[1] as TextPart).text)
100− }
101−
102− @Test
103− fun seedKeepsEventResolvedInfoOverSyntheticSnapshot() {
104− val store = MessageStore()
105− // A MessageUpdated event resolved the real info, then a part streamed in.
106− store.reduce(session, msgUpdated("a1"))
107− store.reduce(session, partUpdated("a1", "t1", "streamed"))
108− // A stale snapshot arrives whose info is synthetic; it must not downgrade.
109− store.seed(
110− listOf(
111− MessageWithParts(
112− UnknownMessage(id = "a1", sessionID = session),
113− parts = listOf(TextPart(id = "t1", messageID = "a1", sessionID = session, text = "snapshot")),
114− ),
115− ),
116− )
117− val msg = store.snapshot().single()
118− assertTrue("event-resolved info must be kept", msg.info is AssistantMessage)
119− assertEquals("streamed", (msg.parts.first() as TextPart).text)
120− }
121−
122− // --- MessageUpdated ---
123−
124− @Test
125− fun messageUpdatedInsertsNewMessage() {
126− val store = MessageStore()
127− val changed = store.reduce(session, msgUpdated("m1"))
128− assertTrue(changed)
129− assertEquals("m1", store.snapshot().single().info.id)
130− }
131−
132− @Test
133− fun messageUpdatedOverwritesExistingInfo() {
134− val store = MessageStore()
135− store.reduce(session, msgUpdated("m1"))
136− val changed = store.reduce(session, msgUpdated("m1"))
137− assertTrue(changed)
138− assertEquals(1, store.snapshot().size)
139− }
140−
141− @Test
142− fun messageUpdatedIgnoredForOtherSession() {
143− val store = MessageStore()
144− val changed = store.reduce(session, msgUpdated("m1", sid = "other"))
145− assertFalse(changed)
146− assertTrue(store.snapshot().isEmpty())
147− }
148−
149− // --- MessagePartUpdated ---
150−
151− @Test
152− fun partUpdatedAppendsToExistingMessage() {
153− val store = MessageStore()
154− store.reduce(session, msgUpdated("m1"))
155− val changed = store.reduce(session, partUpdated("m1", "p1", "hello"))
156− assertTrue(changed)
157− val parts = store.snapshot().single().parts
158− assertEquals(1, parts.size)
159− assertEquals("hello", (parts[0] as TextPart).text)
160− }
161−
162− @Test
163− fun partUpdatedCreatesSyntheticMessageWhenAbsent() {
164− val store = MessageStore()
165− store.reduce(session, partUpdated("m1", "p1", "hello"))
166− val msg = store.snapshot().single()
167− assertEquals("m1", msg.info.id)
168− assertEquals("hello", (msg.parts.single() as TextPart).text)
169− }
170−
171− @Test
172− fun partUpdatedReplacesPartWithSameId() {
173− val store = MessageStore()
174− store.reduce(session, partUpdated("m1", "p1", "hello"))
175− store.reduce(session, partUpdated("m1", "p1", "hello world"))
176− val parts = store.snapshot().single().parts
177− assertEquals(1, parts.size)
178− assertEquals("hello world", (parts[0] as TextPart).text)
179− }
180−
181− @Test
182− fun partUpdatedPreservesInsertionOrderAcrossParts() {
183− val store = MessageStore()
184− store.reduce(session, partUpdated("m1", "p1", "one"))
185− store.reduce(session, partUpdated("m1", "p2", "two"))
186− store.reduce(session, partUpdated("m1", "p3", "three"))
187− val parts = store.snapshot().single().parts
188− assertEquals(listOf("p1", "p2", "p3"), parts.map { it.id })
189− }
190−
191− @Test
192− fun partUpdatedIgnoredForOtherSession() {
193− val store = MessageStore()
194− val changed = store.reduce(session, partUpdated("m1", "p1", "x", sid = "other"))
195− assertFalse(changed)
196− assertTrue(store.snapshot().isEmpty())
197− }
198−
199− // --- MessagePartRemoved ---
200−
201− @Test
202− fun partRemovedDeletesMatchingPart() {
203− val store = MessageStore()
204− store.reduce(session, partUpdated("m1", "p1", "one"))
205− store.reduce(session, partUpdated("m1", "p2", "two"))
206− val changed = store.reduce(session, partRemoved("m1", "p1"))
207− assertTrue(changed)
208− val parts = store.snapshot().single().parts
209− assertEquals(listOf("p2"), parts.map { it.id })
210− }
211−
212− @Test
213− fun partRemovedReturnsFalseWhenPartNotFound() {
214− val store = MessageStore()
215− store.reduce(session, partUpdated("m1", "p1", "one"))
216− val changed = store.reduce(session, partRemoved("m1", "p2"))
217− assertFalse(changed)
218− }
219−
220− // --- MessageRemoved ---
221−
222− @Test
223− fun messageRemovedDeletesWholeMessage() {
224− val store = MessageStore()
225− store.reduce(session, msgUpdated("m1"))
226− store.reduce(session, msgUpdated("m2"))
227− val changed = store.reduce(session, messageRemoved("m1"))
228− assertTrue(changed)
229− assertEquals(listOf("m2"), store.snapshot().map { it.info.id })
230− }
231−
232− @Test
233− fun messageRemovedReturnsFalseWhenMissing() {
234− val store = MessageStore()
235− val changed = store.reduce(session, messageRemoved("nope"))
236− assertFalse(changed)
237− }
238−
239− // --- unrelated events ---
240−
241− @Test
242− fun unrelatedEventsDoNotMutateState() {
243− val store = MessageStore()
244− store.reduce(session, msgUpdated("m1"))
245− val idle = store.reduce(session, SessionIdle(SessionIdle.Props(sessionID = session)))
246− val error = store.reduce(session, SessionError(SessionError.Props(sessionID = session)))
247− assertFalse(idle)
248− assertFalse(error)
249− assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
250− }
251−
252− // --- seed prune (re-seed after SSE reconnect) ---
253−
254− @Test
255− fun seedPruneRemovesMessagesNotInSnapshot() {
256− val store = MessageStore()
257− store.seed(listOf(
258− MessageWithParts(UserMessage("u1", session)),
259− MessageWithParts(AssistantMessage("a1", session)),
260− ))
261− // Re-seed with only u1 — a1 was deleted on the server during the gap.
262− store.seed(
263− listOf(MessageWithParts(UserMessage("u1", session))),
264− prune = true,
265− )
266− assertEquals(listOf("u1"), store.snapshot().map { it.info.id })
267− }
268−
269− @Test
270− fun seedWithoutPruneKeepsAllMessages() {
271− val store = MessageStore()
272− store.seed(listOf(
273− MessageWithParts(UserMessage("u1", session)),
274− MessageWithParts(AssistantMessage("a1", session)),
275− ))
276− // Without prune, messages absent from the snapshot are retained.
277− store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
278− assertEquals(listOf("u1", "a1"), store.snapshot().map { it.info.id })
279− }
280−
281− // --- end-to-end ordering ---
282−
283− @Test
284− fun fullReductionProducesOrderedMessagesWithParts() {
285− val store = MessageStore()
286− store.reduce(session, msgUpdated("u1"))
287− store.reduce(session, msgUpdated("a1"))
288− store.reduce(session, partUpdated("a1", "t1", "draft"))
289− store.reduce(session, partUpdated("a1", "t1", "final"))
290− store.reduce(session, partUpdated("a1", "t2", "second"))
291−
292− val snapshot = store.snapshot()
293− assertEquals(listOf("u1", "a1"), snapshot.map { it.info.id })
294− val a1 = snapshot[1]
295− assertEquals(listOf("t1", "t2"), a1.parts.map { it.id })
296− assertEquals("final", (a1.parts[0] as TextPart).text)
297− }
298−}
app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktdeleted−73
@@ -1,73 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.HttpClient
4−import io.ktor.client.engine.mock.MockEngine
5−import io.ktor.client.engine.mock.respond
6−import io.ktor.client.engine.mock.respondError
7−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
8−import io.ktor.client.plugins.sse.SSE
9−import io.ktor.http.ContentType
10−import io.ktor.http.HttpHeaders
11−import io.ktor.http.HttpStatusCode
12−import io.ktor.http.headersOf
13−import io.ktor.serialization.kotlinx.json.json
14−import kotlinx.coroutines.CoroutineScope
15−import kotlinx.coroutines.Dispatchers
16−import kotlinx.coroutines.ExperimentalCoroutinesApi
17−import kotlinx.coroutines.SupervisorJob
18−import kotlinx.coroutines.cancel
19−import kotlinx.coroutines.flow.first
20−import kotlinx.coroutines.runBlocking
21−import org.junit.Assert.assertEquals
22−import org.junit.Test
23−import soy.iko.opencode.data.network.EventStreamClient
24−import soy.iko.opencode.data.network.OpencodeApiClient
25−import soy.iko.opencode.data.network.OpencodeJson
26−
27−@OptIn(ExperimentalCoroutinesApi::class)
28−class SessionRepositoryTest {
29−
30− private val session = "s1"
31− private val jsonHeaders = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
32−
33− /**
34− * Verify that observeMessages seeds from the REST endpoint. The SSE stream uses
35− * a MockEngine that always errors (MockEngine can't produce a streaming SSE body),
36− * so only the REST seed path is exercised here. The SSE→REST merge logic is
37− * unit-tested in [MessageStoreTest].
38− */
39− @Test
40− fun seedsFromRestWithSseErrors() = runBlocking {
41− val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
42−
43− // REST: listMessages returns one user message.
44− val restEngine = MockEngine {
45− respond(
46− """[{"info":{"role":"user","id":"u1","sessionID":"$session"},"parts":[]}]""",
47− HttpStatusCode.OK,
48− jsonHeaders,
49− )
50− }
51− val restClient = HttpClient(restEngine) {
52− install(ContentNegotiation) { json(OpencodeJson) }
53− }
54− val api = OpencodeApiClient(restClient)
55−
56− // SSE: always errors (we only test the REST seed path here).
57− val sseEngine = MockEngine { respondError(HttpStatusCode.InternalServerError) }
58− val sseClient = HttpClient(sseEngine) { install(SSE) }
59− val eventStream = EventStreamClient(sseClient, scope)
60−
61− val repo = SessionRepository(api, eventStream)
62−
63− // The first non-empty emission should be the REST seed.
64− val result = repo.observeMessages(session).first { it.isNotEmpty() }
65−
66− assertEquals(1, result.size)
67− assertEquals("u1", result[0].info.id)
68−
69− scope.cancel()
70− restClient.close()
71− sseClient.close()
72− }
73−}
app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktdeleted−56
@@ -1,56 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Assert.assertNull
5−import org.junit.Test
6−import soy.iko.opencode.data.model.AssistantMessage
7−import soy.iko.opencode.data.model.MessagePartRemoved
8−import soy.iko.opencode.data.model.MessagePartUpdated
9−import soy.iko.opencode.data.model.MessageUpdated
10−import soy.iko.opencode.data.model.SessionIdle
11−import soy.iko.opencode.data.model.TextPart
12−
13−class UnreadTrackingTest {
14−
15− private val session = "s1"
16−
17− @Test
18− fun extractsSessionFromMessageUpdated() {
19− val event = MessageUpdated(MessageUpdated.Props(AssistantMessage(id = "a1", sessionID = session)))
20− assertEquals(session, sessionOfEvent(event))
21− }
22−
23− @Test
24− fun extractsSessionFromPartSessionId() {
25− val event = MessagePartUpdated(
26− MessagePartUpdated.Props(
27− part = TextPart(id = "p1", messageID = "m1", sessionID = session, text = "hi"),
28− sessionID = session,
29− messageID = "m1",
30− ),
31− )
32− assertEquals(session, sessionOfEvent(event))
33− }
34−
35− @Test
36− fun fallsBackToEnvelopeSessionIdWhenPartSessionIdMissing() {
37− // Some events omit the session id on the nested part; the envelope value must
38− // still badge the right session.
39− val event = MessagePartUpdated(
40− MessagePartUpdated.Props(
41− part = TextPart(id = "p1", messageID = "m1", sessionID = null, text = "hi"),
42− sessionID = session,
43− messageID = "m1",
44− ),
45− )
46− assertEquals(session, sessionOfEvent(event))
47− }
48−
49− @Test
50− fun returnsNullForNonMessageEvents() {
51− // Idle/error/etc. events describe session lifecycle, not message activity, so
52− // they must not badge a session.
53− assertNull(sessionOfEvent(SessionIdle(SessionIdle.Props(sessionID = session))))
54− assertNull(sessionOfEvent(MessagePartRemoved(MessagePartRemoved.Props(sessionID = session, messageID = "m1", partID = "p1"))))
55− }
56−}
app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktdeleted−56
@@ -1,56 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Assert.assertTrue
5−import org.junit.Test
6−import soy.iko.opencode.data.model.AssistantMessage
7−import soy.iko.opencode.data.model.MessageWithParts
8−import soy.iko.opencode.data.model.ReasoningPart
9−import soy.iko.opencode.data.model.TextPart
10−import soy.iko.opencode.data.model.UserMessage
11−
12−class ConversationExportTest {
13−
14− private val sid = "s1"
15−
16− private fun user(id: String, text: String) =
17− MessageWithParts(UserMessage(id, sid), listOf(TextPart(id = "p$id", text = text)))
18−
19− private fun assistant(id: String, text: String) =
20− MessageWithParts(AssistantMessage(id, sid), listOf(TextPart(id = "p$id", text = text)))
21−
22− @Test
23− fun rendersRoleHeadersAndBodies() {
24− val md = buildConversationMarkdown(
25− listOf(user("u1", "Hello"), assistant("a1", "Hi there!")),
26− title = "My chat",
27− )
28− assertTrue(md.startsWith("# My chat"))
29− assertTrue(md.contains("## You\n\nHello"))
30− assertTrue(md.contains("## opencode\n\nHi there!"))
31− }
32−
33− @Test
34− fun omitsTitleWhenBlank() {
35− val md = buildConversationMarkdown(listOf(user("u1", "hi")), title = null)
36− assertTrue(md.startsWith("## You"))
37− }
38−
39− @Test
40− fun skipsMessagesWithoutText() {
41− val onlyReasoning = MessageWithParts(
42− AssistantMessage("a1", sid),
43− listOf(ReasoningPart(id = "r1", text = "hmm")),
44− )
45− val md = buildConversationMarkdown(listOf(onlyReasoning), title = null)
46− // Reasoning is exported in italics, so it still appears.
47− assertTrue(md.contains("## opencode"))
48− assertTrue(md.contains("_hmm_"))
49− }
50−
51− @Test
52− fun emptyConversationProducesJustTheTitle() {
53− // No message bodies to export, but the title header is still emitted.
54− assertEquals("# x", buildConversationMarkdown(emptyList(), title = "x"))
55− }
56−}
app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktdeleted−55
@@ -1,55 +0,0 @@
1−package soy.iko.opencode.ui.components
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−
8−class DiffParseTest {
9−
10− @Test
11− fun parsesAddedRemovedContextAndHunks() {
12− val diff = """
13− --- a/foo.txt
14− +++ b/foo.txt
15− @@ -1,2 +1,2 @@
16− context line
17− -removed line
18− +added line
19− """.trimIndent()
20−
21− val lines = parseDiff(diff)
22− assertEquals(6, lines.size)
23− assertTrue(lines[0] is DiffLine.FileHeader) // ---
24− assertTrue(lines[1] is DiffLine.FileHeader) // +++
25− assertTrue(lines[2] is DiffLine.Hunk) // @@
26− assertTrue(lines[3] is DiffLine.Context)
27− assertTrue(lines[4] is DiffLine.Remove)
28− assertEquals("removed line", (lines[4] as DiffLine.Remove).text)
29− assertTrue(lines[5] is DiffLine.Add)
30− assertEquals("added line", (lines[5] as DiffLine.Add).text)
31− }
32−
33− @Test
34− fun stripsPrefixFromAddAndRemove() {
35− val lines = parseDiff("-gone\n+new")
36− assertEquals("gone", (lines[0] as DiffLine.Remove).text)
37− assertEquals("new", (lines[1] as DiffLine.Add).text)
38− }
39−
40− @Test
41− fun looksLikeDiffTrueForUnifiedDiff() {
42− assertTrue(looksLikeDiff("@@ -1,1 +1,1 @@\n-old\n+new"))
43− }
44−
45− @Test
46− fun looksLikeDiffFalseForPlainText() {
47− assertFalse(looksLikeDiff("This is just some prose.\nNo diff here."))
48− assertFalse(looksLikeDiff(""))
49− }
50−
51− @Test
52− fun emptyInputParsesToEmpty() {
53− assertTrue(parseDiff("").isEmpty())
54− }
55−}
build.gradle.kts+1−1
@@ -1,6 +1,6 @@
1plugins {
1plugins {
2 alias(libs.plugins.android.application) apply false
2 alias(libs.plugins.android.application) apply false
3 alias(libs.plugins.kotlin.android) apply false
3 alias(libs.plugins.kotlin.android) apply false
4− alias(libs.plugins.kotlin.serialization) apply false
5 alias(libs.plugins.kotlin.compose) apply false
4 alias(libs.plugins.kotlin.compose) apply false
5+ alias(libs.plugins.kotlin.serialization) apply false
6}
6}
config/detekt/detekt.ymldeleted−54
@@ -1,54 +0,0 @@
1−# detekt override config.
2−# buildUponDefaultConfig = true, so only the rules below differ from the defaults.
3−# The defaults are intentionally strict; this file relaxes rules that fight Jetpack
4−# Compose / Android idioms (lots of magic dp numbers, long @Composable functions,
5−# long parameter lists) while keeping the potential-bug and correctness rules on.
6−build:
7− maxIssues: 0
8− excludeCorrectable: false
9−
10−complexity:
11− active: true
12− LongMethod:
13− active: false
14− LongParameterList:
15− active: false
16− ComplexCondition:
17− active: true
18− CyclomaticComplexMethod:
19− active: true
20− threshold: 20
21− NestedBlockDepth:
22− active: true
23− threshold: 6
24−
25−style:
26− active: true
27− MagicNumber:
28− active: false
29− ReturnCount:
30− active: false
31− ThrowsCount:
32− active: false
33− LoopWithTooManyJumpStatements:
34− active: false
35− UseCheckOrError:
36− active: false
37− MaxLineLength:
38− active: false
39− WildcardImport:
40− active: false
41− FunctionOnlyReturningConstant:
42− active: false
43−
44−exceptions:
45− active: true
46− TooGenericExceptionCaught:
47− active: false # runCatching { } is idiomatic in this codebase
48−
49−naming:
50− active: true
51− FunctionNaming:
52− active: false # @Composable functions use PascalCase
53− TopLevelPropertyNaming:
54− active: false
flake.lock+3−3
@@ -20,11 +20,11 @@
20 },
20 },
21 "nixpkgs": {
21 "nixpkgs": {
22 "locked": {
22 "locked": {
23− "lastModified": 1782467914,
23+ "lastModified": 1783224372,
24− "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
24+ "narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
25 "owner": "NixOS",
25 "owner": "NixOS",
26 "repo": "nixpkgs",
26 "repo": "nixpkgs",
27− "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
27+ "rev": "d407951447dcd00442e97087bf374aad70c04cea",
28 "type": "github"
28 "type": "github"
29 },
29 },
30 "original": {
30 "original": {
flake.nix+27−17
@@ -1,5 +1,5 @@
1{
1{
2− description = "Dev environment for opencode-android (Kotlin / Jetpack Compose client for the opencode serve API)";
2+ description = "Dev environment for opencode-android — a Crux (Rust) core + Kotlin / Jetpack Compose shell";
3
3
4 inputs = {
4 inputs = {
5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
@@ -9,14 +9,17 @@
9 outputs = { self, nixpkgs, flake-utils }:
9 outputs = { self, nixpkgs, flake-utils }:
10 flake-utils.lib.eachDefaultSystem (system:
10 flake-utils.lib.eachDefaultSystem (system:
11 let
11 let
12− # Versions kept in sync with the Gradle project (compileSdk / build-tools).
12+ # Kept in sync with the Gradle project (see gradle/libs.versions.toml and
13+ # app/build.gradle.kts). These are the versions actually provisioned below,
14+ # so `compileSdk` / `buildToolsVersion` must match or Gradle will try to
15+ # download SDK components at build time.
13 platformVersion = "35";
16 platformVersion = "35";
14 buildToolsVersion = "35.0.0";
17 buildToolsVersion = "35.0.0";
15
18
16 pkgs = import nixpkgs {
19 pkgs = import nixpkgs {
17 inherit system;
20 inherit system;
18 config = {
21 config = {
19− allowUnfree = true; # Android SDK is unfree
22+ allowUnfree = true; # the Android SDK is unfree
20 android_sdk.accept_license = true;
23 android_sdk.accept_license = true;
21 };
24 };
22 };
25 };
@@ -26,45 +29,52 @@
26 buildToolsVersions = [ buildToolsVersion ];
29 buildToolsVersions = [ buildToolsVersion ];
27 cmdLineToolsVersion = "latest";
30 cmdLineToolsVersion = "latest";
28
31
29− # Flip these on if the host has /dev/kvm and you want an AVD.
30 includeEmulator = false;
32 includeEmulator = false;
31 includeSystemImages = false;
33 includeSystemImages = false;
32− systemImageTypes = [ "google_apis" ];
33− abiVersions = [ "x86_64" ];
34
34
35+ # The Rust core is NOT cross-compiled to a JNI .so in this setup: nixpkgs'
36+ # `rustc` ships no Android std and no NDK is provisioned. The core is built
37+ # and unit-tested natively (`cargo test`), and the Android shell runs a
38+ # faithful Kotlin port of the reducer. Flip `includeNDK = true` and add
39+ # `cargo-ndk` below to build the real .so once a cross-toolchain is present.
35 includeNDK = false;
40 includeNDK = false;
36 };
41 };
37
42
38− sdk = android.androidsdk;
43+ sdk = android.androidsdk;
39− sdkRoot = "${sdk}/libexec/android-sdk";
44+ sdkRoot = "${sdk}/libexec/android-sdk";
40−
41− # Gradle's bundled aapt2 binary is dynamically linked against a loader
42− # path that does not exist on NixOS, so it segfaults. Point Gradle and
43− # Android Studio at the Nix-built aapt2 instead.
44 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45 aapt2Path = "${sdkRoot}/build-tools/${buildToolsVersion}/aapt2";
45 in {
46 in {
46 devShells.default = pkgs.mkShell {
47 devShells.default = pkgs.mkShell {
47 buildInputs = [
48 buildInputs = [
49+ # JVM / Android toolchain
48 pkgs.jdk17
50 pkgs.jdk17
49 pkgs.gradle
51 pkgs.gradle
50 sdk
52 sdk
53+
54+ # Rust toolchain for the Crux core (build, test, typegen codegen)
55+ pkgs.rustc
56+ pkgs.cargo
51 ];
57 ];
52
58
53 ANDROID_HOME = sdkRoot;
59 ANDROID_HOME = sdkRoot;
54 ANDROID_SDK_ROOT = sdkRoot;
60 ANDROID_SDK_ROOT = sdkRoot;
55 JAVA_HOME = "${pkgs.jdk17}";
61 JAVA_HOME = "${pkgs.jdk17}";
56
62
57− # Override Gradle's bundled aapt2 (see note above).
63+ # AGP resolves aapt2 from a Maven artifact by default; point it at the
64+ # SDK-provided binary so the build works fully offline / hermetically.
58 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
65 GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${aapt2Path}";
59
66
60 shellHook = ''
67 shellHook = ''
61 echo "opencode-android dev shell"
68 echo "opencode-android dev shell"
62− echo " JDK: $(java -version 2>&1 | head -1)"
69+ echo " JDK: $(java -version 2>&1 | head -1)"
70+ echo " Rust: $(rustc --version)"
71+ echo " Gradle: $(gradle --version 2>/dev/null | awk '/^Gradle/{print $2}')"
63 echo " ANDROID_HOME: $ANDROID_HOME"
72 echo " ANDROID_HOME: $ANDROID_HOME"
64− echo " aapt2: ${aapt2Path}"
65 echo ""
73 echo ""
74+ echo " Core: (cd shared && cargo test) # Crux core unit tests"
75+ echo " Types: (cd shared && cargo run --bin codegen)"
66− echo "Build: ./gradlew assembleDebug"
76+ echo " App: ./gradlew assembleDebug # build debug APK"
67− echo "Device: adb devices"
77+ echo " Unit: ./gradlew testDebugUnitTest"
68 '';
78 '';
69 };
79 };
70 });
80 });
generated/com/novi/bincode/BincodeDeserializer.javanew file+38
@@ -0,0 +1,38 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode;
5+
6+import com.novi.serde.DeserializationError;
7+import com.novi.serde.Slice;
8+import com.novi.serde.BinaryDeserializer;
9+
10+public class BincodeDeserializer extends BinaryDeserializer {
11+ public BincodeDeserializer(byte[] input) {
12+ super(input, Long.MAX_VALUE);
13+ }
14+
15+ public Float deserialize_f32() throws DeserializationError {
16+ return Float.valueOf(getFloat());
17+ }
18+
19+ public Double deserialize_f64() throws DeserializationError {
20+ return Double.valueOf(getDouble());
21+ }
22+
23+ public long deserialize_len() throws DeserializationError {
24+ long value = getLong();
25+ if (value < 0 || value > Integer.MAX_VALUE) {
26+ throw new DeserializationError("Incorrect length value");
27+ }
28+ return value;
29+ }
30+
31+ public int deserialize_variant_index() throws DeserializationError {
32+ return getInt();
33+ }
34+
35+ public void check_that_key_slices_are_increasing(Slice key1, Slice key2) throws DeserializationError {
36+ // Not required by the format.
37+ }
38+}
generated/com/novi/bincode/BincodeSerializer.javanew file+33
@@ -0,0 +1,33 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.bincode;
5+
6+import com.novi.serde.SerializationError;
7+import com.novi.serde.BinarySerializer;
8+
9+public class BincodeSerializer extends BinarySerializer {
10+ public BincodeSerializer() {
11+ super(Long.MAX_VALUE);
12+ }
13+
14+ public void serialize_f32(Float value) throws SerializationError {
15+ serialize_i32(Integer.valueOf(Float.floatToRawIntBits(value.floatValue())));
16+ }
17+
18+ public void serialize_f64(Double value) throws SerializationError {
19+ serialize_i64(Long.valueOf(Double.doubleToRawLongBits(value.doubleValue())));
20+ }
21+
22+ public void serialize_len(long value) throws SerializationError {
23+ serialize_u64(value);
24+ }
25+
26+ public void serialize_variant_index(int value) throws SerializationError {
27+ serialize_u32(value);
28+ }
29+
30+ public void sort_map_entries(int[] offsets) {
31+ // Not required by the format.
32+ }
33+}
generated/com/novi/serde/ArrayLen.javanew file+12
@@ -0,0 +1,12 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface ArrayLen {
11+ int length();
12+}
generated/com/novi/serde/BinaryDeserializer.javanew file+195
@@ -0,0 +1,195 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.nio.ByteBuffer;
7+import java.nio.ByteOrder;
8+import java.nio.charset.CharsetDecoder;
9+import java.nio.charset.StandardCharsets;
10+import java.nio.charset.CharacterCodingException;
11+import java.math.BigInteger;
12+
13+public abstract class BinaryDeserializer implements Deserializer {
14+ protected ByteBuffer input;
15+ private long containerDepthBudget;
16+
17+ public BinaryDeserializer(byte[] input, long maxContainerDepth) {
18+ this.input = ByteBuffer.wrap(input);
19+ this.input.order(ByteOrder.LITTLE_ENDIAN);
20+ containerDepthBudget = maxContainerDepth;
21+ }
22+
23+ public void increase_container_depth() throws DeserializationError {
24+ if (containerDepthBudget == 0) {
25+ throw new DeserializationError("Exceeded maximum container depth");
26+ }
27+ containerDepthBudget -= 1;
28+ }
29+
30+ public void decrease_container_depth() {
31+ containerDepthBudget += 1;
32+ }
33+
34+ public String deserialize_str() throws DeserializationError {
35+ long len = deserialize_len();
36+ if (len < 0 || len > Integer.MAX_VALUE) {
37+ throw new DeserializationError("Incorrect length value for Java string");
38+ }
39+ byte[] content = new byte[(int) len];
40+ read(content);
41+ CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
42+ try {
43+ decoder.decode(ByteBuffer.wrap(content));
44+ } catch (CharacterCodingException ex) {
45+ throw new DeserializationError("Incorrect UTF8 string");
46+ }
47+ return new String(content);
48+ }
49+
50+ public Bytes deserialize_bytes() throws DeserializationError {
51+ long len = deserialize_len();
52+ if (len < 0 || len > Integer.MAX_VALUE) {
53+ throw new DeserializationError("Incorrect length value for Java array");
54+ }
55+ byte[] content = new byte[(int) len];
56+ read(content);
57+ return new Bytes(content);
58+ }
59+
60+ public Boolean deserialize_bool() throws DeserializationError {
61+ byte value = getByte();
62+ if (value == 0) {
63+ return Boolean.valueOf(false);
64+ }
65+ if (value == 1) {
66+ return Boolean.valueOf(true);
67+ }
68+ throw new DeserializationError("Incorrect boolean value");
69+ }
70+
71+ public Unit deserialize_unit() throws DeserializationError {
72+ return new Unit();
73+ }
74+
75+ public Character deserialize_char() throws DeserializationError {
76+ throw new DeserializationError("Not implemented: deserialize_char");
77+ }
78+
79+ public @Unsigned Byte deserialize_u8() throws DeserializationError {
80+ return Byte.valueOf(getByte());
81+ }
82+
83+ public @Unsigned Short deserialize_u16() throws DeserializationError {
84+ return Short.valueOf(getShort());
85+ }
86+
87+ public @Unsigned Integer deserialize_u32() throws DeserializationError {
88+ return Integer.valueOf(getInt());
89+ }
90+
91+ public @Unsigned Long deserialize_u64() throws DeserializationError {
92+ return Long.valueOf(getLong());
93+ }
94+
95+ public @Unsigned @Int128 BigInteger deserialize_u128() throws DeserializationError {
96+ BigInteger signed = deserialize_i128();
97+ if (signed.compareTo(BigInteger.ZERO) >= 0) {
98+ return signed;
99+ } else {
100+ return signed.add(BigInteger.ONE.shiftLeft(128));
101+ }
102+ }
103+
104+ public Byte deserialize_i8() throws DeserializationError {
105+ return Byte.valueOf(getByte());
106+ }
107+
108+ public Short deserialize_i16() throws DeserializationError {
109+ return Short.valueOf(getShort());
110+ }
111+
112+ public Integer deserialize_i32() throws DeserializationError {
113+ return Integer.valueOf(getInt());
114+ }
115+
116+ public Long deserialize_i64() throws DeserializationError {
117+ return Long.valueOf(getLong());
118+ }
119+
120+ public @Int128 BigInteger deserialize_i128() throws DeserializationError {
121+ byte[] content = new byte[16];
122+ read(content);
123+ byte[] reversed = new byte[16];
124+ for (int i = 0; i < 16; i++) {
125+ reversed[i] = content[15 - i];
126+ }
127+ return new BigInteger(reversed);
128+ }
129+
130+ public boolean deserialize_option_tag() throws DeserializationError {
131+ return deserialize_bool().booleanValue();
132+ }
133+
134+ public int get_buffer_offset() {
135+ return input.position();
136+ }
137+
138+ static final String INPUT_NOT_LARGE_ENOUGH = "Input is not large enough";
139+
140+ protected byte getByte() throws DeserializationError {
141+ try {
142+ return input.get();
143+ } catch (java.nio.BufferUnderflowException e) {
144+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
145+ }
146+ }
147+
148+ protected short getShort() throws DeserializationError {
149+ try {
150+ return input.getShort();
151+ } catch (java.nio.BufferUnderflowException e) {
152+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
153+ }
154+ }
155+
156+ protected int getInt() throws DeserializationError {
157+ try {
158+ return input.getInt();
159+ } catch (java.nio.BufferUnderflowException e) {
160+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
161+ }
162+ }
163+
164+ protected long getLong() throws DeserializationError {
165+ try {
166+ return input.getLong();
167+ } catch (java.nio.BufferUnderflowException e) {
168+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
169+ }
170+ }
171+
172+ protected float getFloat() throws DeserializationError {
173+ try {
174+ return input.getFloat();
175+ } catch (java.nio.BufferUnderflowException e) {
176+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
177+ }
178+ }
179+
180+ protected double getDouble() throws DeserializationError {
181+ try {
182+ return input.getDouble();
183+ } catch (java.nio.BufferUnderflowException e) {
184+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
185+ }
186+ }
187+
188+ protected void read(byte[] content) throws DeserializationError {
189+ try {
190+ input.get(content);
191+ } catch (java.nio.BufferUnderflowException e) {
192+ throw new DeserializationError(INPUT_NOT_LARGE_ENOUGH);
193+ }
194+ }
195+}
generated/com/novi/serde/BinarySerializer.javanew file+146
@@ -0,0 +1,146 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public abstract class BinarySerializer implements Serializer {
9+ protected MyByteArrayOutputStream output;
10+ private long containerDepthBudget;
11+
12+ public BinarySerializer(long maxContainerDepth) {
13+ output = new BinarySerializer.MyByteArrayOutputStream();
14+ containerDepthBudget = maxContainerDepth;
15+ }
16+
17+ public void increase_container_depth() throws SerializationError {
18+ if (containerDepthBudget == 0) {
19+ throw new SerializationError("Exceeded maximum container depth");
20+ }
21+ containerDepthBudget -= 1;
22+ }
23+
24+ public void decrease_container_depth() {
25+ containerDepthBudget += 1;
26+ }
27+
28+ public void serialize_str(String value) throws SerializationError {
29+ serialize_bytes(new Bytes(value.getBytes()));
30+ }
31+
32+ public void serialize_bytes(Bytes value) throws SerializationError {
33+ byte[] content = value.content();
34+ serialize_len(content.length);
35+ output.write(content, 0, content.length);
36+ }
37+
38+ public void serialize_bool(Boolean value) throws SerializationError {
39+ output.write((value.booleanValue() ? 1 : 0));
40+ }
41+
42+ public void serialize_unit(Unit value) throws SerializationError {
43+ }
44+
45+ public void serialize_char(Character value) throws SerializationError {
46+ throw new SerializationError("Not implemented: serialize_char");
47+ }
48+
49+ public void serialize_u8(@Unsigned Byte value) throws SerializationError {
50+ output.write(value.byteValue());
51+ }
52+
53+ public void serialize_u16(@Unsigned Short value) throws SerializationError {
54+ short val = value.shortValue();
55+ output.write((byte) (val >>> 0));
56+ output.write((byte) (val >>> 8));
57+ }
58+
59+ public void serialize_u32(@Unsigned Integer value) throws SerializationError {
60+ int val = value.intValue();
61+ output.write((byte) (val >>> 0));
62+ output.write((byte) (val >>> 8));
63+ output.write((byte) (val >>> 16));
64+ output.write((byte) (val >>> 24));
65+ }
66+
67+ public void serialize_u64(@Unsigned Long value) throws SerializationError {
68+ long val = value.longValue();
69+ output.write((byte) (val >>> 0));
70+ output.write((byte) (val >>> 8));
71+ output.write((byte) (val >>> 16));
72+ output.write((byte) (val >>> 24));
73+ output.write((byte) (val >>> 32));
74+ output.write((byte) (val >>> 40));
75+ output.write((byte) (val >>> 48));
76+ output.write((byte) (val >>> 56));
77+ }
78+
79+ public void serialize_u128(@Unsigned @Int128 BigInteger value) throws SerializationError {
80+ if (value.compareTo(BigInteger.ZERO) < 0 || !value.shiftRight(128).equals(BigInteger.ZERO)) {
81+ throw new java.lang.IllegalArgumentException("Invalid value for an unsigned int128");
82+ }
83+ byte[] content = value.toByteArray();
84+ // BigInteger.toByteArray() may add a most-significant zero
85+ // byte for signing purpose: ignore it.
86+ assert content.length <= 16 || content[0] == 0;
87+ int len = Math.min(content.length, 16);
88+ // Write content in little-endian order.
89+ for (int i = 0; i < len; i++) {
90+ output.write(content[content.length - 1 - i]);
91+ }
92+ // Complete with zeros if needed.
93+ for (int i = len; i < 16; i++) {
94+ output.write(0);
95+ }
96+ }
97+
98+ public void serialize_i8(Byte value) throws SerializationError {
99+ serialize_u8(value);
100+ }
101+
102+ public void serialize_i16(Short value) throws SerializationError {
103+ serialize_u16(value);
104+ }
105+
106+ public void serialize_i32(Integer value) throws SerializationError {
107+ serialize_u32(value);
108+ }
109+
110+ public void serialize_i64(Long value) throws SerializationError {
111+ serialize_u64(value);
112+ }
113+
114+ public void serialize_i128(@Int128 BigInteger value) throws SerializationError {
115+ if (value.compareTo(BigInteger.ZERO) >= 0) {
116+ if (!value.shiftRight(127).equals(BigInteger.ZERO)) {
117+ throw new java.lang.IllegalArgumentException("Invalid value for a signed int128");
118+ }
119+ serialize_u128(value);
120+ } else {
121+ if (!value.add(BigInteger.ONE).negate().shiftRight(127).equals(BigInteger.ZERO)) {
122+ throw new java.lang.IllegalArgumentException("Invalid value for a signed int128");
123+ }
124+ serialize_u128(value.add(BigInteger.ONE.shiftLeft(128)));
125+ }
126+ }
127+
128+ public void serialize_option_tag(boolean value) throws SerializationError {
129+ output.write((value ? (byte) 1 : (byte) 0));
130+ }
131+
132+ public int get_buffer_offset() {
133+ return output.size();
134+ }
135+
136+ public byte[] get_bytes() {
137+ return output.toByteArray();
138+ }
139+
140+ // Local extension to provide access to the underlying buffer.
141+ static public class MyByteArrayOutputStream extends java.io.ByteArrayOutputStream {
142+ public byte[] getBuffer() {
143+ return buf;
144+ }
145+ }
146+}
generated/com/novi/serde/Bytes.javanew file+54
@@ -0,0 +1,54 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Arrays;
7+import java.util.Objects;
8+
9+/**
10+ * Immutable wrapper class around byte[].
11+ *
12+ * Enforces value-semantice for `equals` and `hashCode`.
13+ */
14+public final class Bytes {
15+ private final byte[] content;
16+
17+ private static final Bytes EMPTY = new Bytes(new byte[0]);
18+
19+ /// Low-level constructor (use with care).
20+ public Bytes(byte[] content) {
21+ Objects.requireNonNull(content, "content must not be null");
22+ this.content = content;
23+ }
24+
25+ public static Bytes empty() {
26+ return EMPTY;
27+ }
28+
29+ public static Bytes valueOf(byte[] content) {
30+ Objects.requireNonNull(content, "content must not be null");
31+ if (content.length == 0) {
32+ return EMPTY;
33+ } else {
34+ return new Bytes(content.clone());
35+ }
36+ }
37+
38+ public byte[] content() {
39+ return this.content.clone();
40+ }
41+
42+ public boolean equals(Object obj) {
43+ if (this == obj) return true;
44+ if (obj == null) return false;
45+ if (getClass() != obj.getClass()) return false;
46+ Bytes other = (Bytes) obj;
47+ return Arrays.equals(this.content, other.content);
48+ }
49+
50+ public int hashCode() {
51+ return Arrays.hashCode(content);
52+ }
53+
54+}
generated/com/novi/serde/DeserializationError.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+@SuppressWarnings("serial")
7+public final class DeserializationError extends Exception {
8+ public DeserializationError(String s) {
9+ super(s);
10+ }
11+}
generated/com/novi/serde/Deserializer.javanew file+56
@@ -0,0 +1,56 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public interface Deserializer {
9+ String deserialize_str() throws DeserializationError;
10+
11+ Bytes deserialize_bytes() throws DeserializationError;
12+
13+ Boolean deserialize_bool() throws DeserializationError;
14+
15+ Unit deserialize_unit() throws DeserializationError;
16+
17+ Character deserialize_char() throws DeserializationError;
18+
19+ Float deserialize_f32() throws DeserializationError;
20+
21+ Double deserialize_f64() throws DeserializationError;
22+
23+ @Unsigned Byte deserialize_u8() throws DeserializationError;
24+
25+ @Unsigned Short deserialize_u16() throws DeserializationError;
26+
27+ @Unsigned Integer deserialize_u32() throws DeserializationError;
28+
29+ @Unsigned Long deserialize_u64() throws DeserializationError;
30+
31+ @Unsigned @Int128 BigInteger deserialize_u128() throws DeserializationError;
32+
33+ Byte deserialize_i8() throws DeserializationError;
34+
35+ Short deserialize_i16() throws DeserializationError;
36+
37+ Integer deserialize_i32() throws DeserializationError;
38+
39+ Long deserialize_i64() throws DeserializationError;
40+
41+ @Int128 BigInteger deserialize_i128() throws DeserializationError;
42+
43+ long deserialize_len() throws DeserializationError;
44+
45+ int deserialize_variant_index() throws DeserializationError;
46+
47+ boolean deserialize_option_tag() throws DeserializationError;
48+
49+ void increase_container_depth() throws DeserializationError;
50+
51+ void decrease_container_depth();
52+
53+ int get_buffer_offset();
54+
55+ void check_that_key_slices_are_increasing(Slice key1, Slice key2) throws DeserializationError;
56+}
generated/com/novi/serde/Int128.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface Int128 {
11+}
generated/com/novi/serde/SerializationError.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+@SuppressWarnings("serial")
7+public final class SerializationError extends Exception {
8+ public SerializationError(String s) {
9+ super(s);
10+ }
11+}
generated/com/novi/serde/Serializer.javanew file+58
@@ -0,0 +1,58 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.math.BigInteger;
7+
8+public interface Serializer {
9+ void serialize_str(String value) throws SerializationError;
10+
11+ void serialize_bytes(Bytes value) throws SerializationError;
12+
13+ void serialize_bool(Boolean value) throws SerializationError;
14+
15+ void serialize_unit(Unit value) throws SerializationError;
16+
17+ void serialize_char(Character value) throws SerializationError;
18+
19+ void serialize_f32(Float value) throws SerializationError;
20+
21+ void serialize_f64(Double value) throws SerializationError;
22+
23+ void serialize_u8(@Unsigned Byte value) throws SerializationError;
24+
25+ void serialize_u16(@Unsigned Short value) throws SerializationError;
26+
27+ void serialize_u32(@Unsigned Integer value) throws SerializationError;
28+
29+ void serialize_u64(@Unsigned Long value) throws SerializationError;
30+
31+ void serialize_u128(@Unsigned @Int128 BigInteger value) throws SerializationError;
32+
33+ void serialize_i8(Byte value) throws SerializationError;
34+
35+ void serialize_i16(Short value) throws SerializationError;
36+
37+ void serialize_i32(Integer value) throws SerializationError;
38+
39+ void serialize_i64(Long value) throws SerializationError;
40+
41+ void serialize_i128(@Int128 BigInteger value) throws SerializationError;
42+
43+ void serialize_len(long value) throws SerializationError;
44+
45+ void serialize_variant_index(int value) throws SerializationError;
46+
47+ void serialize_option_tag(boolean value) throws SerializationError;
48+
49+ void increase_container_depth() throws SerializationError;
50+
51+ void decrease_container_depth();
52+
53+ int get_buffer_offset();
54+
55+ void sort_map_entries(int[] offsets);
56+
57+ byte[] get_bytes();
58+}
generated/com/novi/serde/Slice.javanew file+40
@@ -0,0 +1,40 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+public final class Slice {
7+ public final int start;
8+ public final int end;
9+
10+ public Slice(int start, int end) {
11+ this.start = start;
12+ this.end = end;
13+ }
14+
15+ // Lexicographic comparison between the (unsigned!) bytes referenced by `slice1` and `slice2`
16+ // into `content`.
17+ public static int compare_bytes(byte[] content, Slice slice1, Slice slice2) {
18+ int start1 = slice1.start;
19+ int end1 = slice1.end;
20+ int start2 = slice2.start;
21+ int end2 = slice2.end;
22+ for (int i = 0; i < end1 - start1; i++) {
23+ int byte1 = content[start1 + i] & 0xFF;
24+ if (start2 + i >= end2) {
25+ return 1;
26+ }
27+ int byte2 = content[start2 + i] & 0xFF;
28+ if (byte1 > byte2) {
29+ return 1;
30+ }
31+ if (byte1 < byte2) {
32+ return -1;
33+ }
34+ }
35+ if (end2 - start2 > end1 - start1) {
36+ return -1;
37+ }
38+ return 0;
39+ }
40+}
generated/com/novi/serde/Tuple2.javanew file+40
@@ -0,0 +1,40 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple2<T0, T1> {
9+ public final T0 field0;
10+ public final T1 field1;
11+
12+ public Tuple2(T0 f0, T1 f1) {
13+ Objects.requireNonNull(f0, "f0 must not be null");
14+ Objects.requireNonNull(f1, "f1 must not be null");
15+ this.field0 = f0;
16+ this.field1 = f1;
17+ }
18+
19+ public boolean equals(Object obj) {
20+ if (this == obj) return true;
21+ if (obj == null) return false;
22+ if (getClass() != obj.getClass()) return false;
23+ Tuple2<?,?> other = (Tuple2) obj;
24+ if (!Objects.equals(this.field0, other.field0)) {
25+ return false;
26+ }
27+ if (!Objects.equals(this.field1, other.field1)) {
28+ return false;
29+ }
30+ return true;
31+ }
32+
33+ public int hashCode() {
34+ int value = 7;
35+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
36+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
37+ return value;
38+ }
39+
40+}
generated/com/novi/serde/Tuple3.javanew file+46
@@ -0,0 +1,46 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple3<T0, T1, T2> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+
13+ public Tuple3(T0 f0, T1 f1, T2 f2) {
14+ Objects.requireNonNull(f0, "f0 must not be null");
15+ Objects.requireNonNull(f1, "f1 must not be null");
16+ Objects.requireNonNull(f2, "f2 must not be null");
17+ this.field0 = f0;
18+ this.field1 = f1;
19+ this.field2 = f2;
20+ }
21+
22+ public boolean equals(Object obj) {
23+ if (this == obj) return true;
24+ if (obj == null) return false;
25+ if (getClass() != obj.getClass()) return false;
26+ Tuple3<?,?,?> other = (Tuple3) obj;
27+ if (!Objects.equals(this.field0, other.field0)) {
28+ return false;
29+ }
30+ if (!Objects.equals(this.field1, other.field1)) {
31+ return false;
32+ }
33+ if (!Objects.equals(this.field2, other.field2)) {
34+ return false;
35+ }
36+ return true;
37+ }
38+
39+ public int hashCode() {
40+ int value = 7;
41+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
42+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
43+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
44+ return value;
45+ }
46+}
generated/com/novi/serde/Tuple4.javanew file+54
@@ -0,0 +1,54 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple4<T0, T1, T2, T3> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+
14+ public Tuple4(T0 f0, T1 f1, T2 f2, T3 f3) {
15+ Objects.requireNonNull(f0, "f0 must not be null");
16+ Objects.requireNonNull(f1, "f1 must not be null");
17+ Objects.requireNonNull(f2, "f2 must not be null");
18+ Objects.requireNonNull(f3, "f3 must not be null");
19+ this.field0 = f0;
20+ this.field1 = f1;
21+ this.field2 = f2;
22+ this.field3 = f3;
23+ }
24+
25+ public boolean equals(Object obj) {
26+ if (this == obj) return true;
27+ if (obj == null) return false;
28+ if (getClass() != obj.getClass()) return false;
29+ Tuple4<?,?,?,?> other = (Tuple4) obj;
30+ if (!Objects.equals(this.field0, other.field0)) {
31+ return false;
32+ }
33+ if (!Objects.equals(this.field1, other.field1)) {
34+ return false;
35+ }
36+ if (!Objects.equals(this.field2, other.field2)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field3, other.field3)) {
40+ return false;
41+ }
42+ return true;
43+ }
44+
45+ public int hashCode() {
46+ int value = 7;
47+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
48+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
49+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
50+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
51+ return value;
52+ }
53+
54+}
generated/com/novi/serde/Tuple5.javanew file+61
@@ -0,0 +1,61 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple5<T0, T1, T2, T3, T4> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+ public final T4 field4;
14+
15+ public Tuple5(T0 f0, T1 f1, T2 f2, T3 f3, T4 f4) {
16+ Objects.requireNonNull(f0, "f0 must not be null");
17+ Objects.requireNonNull(f1, "f1 must not be null");
18+ Objects.requireNonNull(f2, "f2 must not be null");
19+ Objects.requireNonNull(f3, "f3 must not be null");
20+ Objects.requireNonNull(f4, "f4 must not be null");
21+ this.field0 = f0;
22+ this.field1 = f1;
23+ this.field2 = f2;
24+ this.field3 = f3;
25+ this.field4 = f4;
26+ }
27+
28+ public boolean equals(Object obj) {
29+ if (this == obj) return true;
30+ if (obj == null) return false;
31+ if (getClass() != obj.getClass()) return false;
32+ Tuple5<?,?,?,?,?> other = (Tuple5) obj;
33+ if (!Objects.equals(this.field0, other.field0)) {
34+ return false;
35+ }
36+ if (!Objects.equals(this.field1, other.field1)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field2, other.field2)) {
40+ return false;
41+ }
42+ if (!Objects.equals(this.field3, other.field3)) {
43+ return false;
44+ }
45+ if (!Objects.equals(this.field4, other.field4)) {
46+ return false;
47+ }
48+ return true;
49+ }
50+
51+ public int hashCode() {
52+ int value = 7;
53+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
54+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
55+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
56+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
57+ value = 31 * value + (this.field4 != null ? this.field4.hashCode() : 0);
58+ return value;
59+ }
60+
61+}
generated/com/novi/serde/Tuple6.javanew file+67
@@ -0,0 +1,67 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.util.Objects;
7+
8+public final class Tuple6<T0, T1, T2, T3, T4, T5> {
9+ public final T0 field0;
10+ public final T1 field1;
11+ public final T2 field2;
12+ public final T3 field3;
13+ public final T4 field4;
14+ public final T5 field5;
15+
16+ public Tuple6(T0 f0, T1 f1, T2 f2, T3 f3, T4 f4, T5 f5) {
17+ Objects.requireNonNull(f0, "f0 must not be null");
18+ Objects.requireNonNull(f1, "f1 must not be null");
19+ Objects.requireNonNull(f2, "f2 must not be null");
20+ Objects.requireNonNull(f3, "f3 must not be null");
21+ Objects.requireNonNull(f4, "f4 must not be null");
22+ Objects.requireNonNull(f5, "f5 must not be null");
23+ this.field0 = f0;
24+ this.field1 = f1;
25+ this.field2 = f2;
26+ this.field3 = f3;
27+ this.field4 = f4;
28+ this.field5 = f5;
29+ }
30+
31+ public boolean equals(Object obj) {
32+ if (this == obj) return true;
33+ if (obj == null) return false;
34+ if (getClass() != obj.getClass()) return false;
35+ Tuple6<?,?,?,?,?,?> other = (Tuple6) obj;
36+ if (!Objects.equals(this.field0, other.field0)) {
37+ return false;
38+ }
39+ if (!Objects.equals(this.field1, other.field1)) {
40+ return false;
41+ }
42+ if (!Objects.equals(this.field2, other.field2)) {
43+ return false;
44+ }
45+ if (!Objects.equals(this.field3, other.field3)) {
46+ return false;
47+ }
48+ if (!Objects.equals(this.field4, other.field4)) {
49+ return false;
50+ }
51+ if (!Objects.equals(this.field5, other.field5)) {
52+ return false;
53+ }
54+ return true;
55+ }
56+
57+ public int hashCode() {
58+ int value = 7;
59+ value = 31 * value + (this.field0 != null ? this.field0.hashCode() : 0);
60+ value = 31 * value + (this.field1 != null ? this.field1.hashCode() : 0);
61+ value = 31 * value + (this.field2 != null ? this.field2.hashCode() : 0);
62+ value = 31 * value + (this.field3 != null ? this.field3.hashCode() : 0);
63+ value = 31 * value + (this.field4 != null ? this.field4.hashCode() : 0);
64+ value = 31 * value + (this.field5 != null ? this.field5.hashCode() : 0);
65+ return value;
66+ }
67+}
generated/com/novi/serde/Unit.javanew file+20
@@ -0,0 +1,20 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+public final class Unit {
7+ public Unit() {}
8+
9+ public boolean equals(Object obj) {
10+ if (this == obj) return true;
11+ if (obj == null) return false;
12+ if (getClass() != obj.getClass()) return false;
13+ return true;
14+ }
15+
16+ public int hashCode() {
17+ return 7;
18+ }
19+
20+}
generated/com/novi/serde/Unsigned.javanew file+11
@@ -0,0 +1,11 @@
1+// Copyright (c) Facebook, Inc. and its affiliates
2+// SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+package com.novi.serde;
5+
6+import java.lang.annotation.ElementType;
7+import java.lang.annotation.Target;
8+
9+@Target({ElementType.TYPE_USE})
10+public @interface Unsigned {
11+}
generated/soy/iko/opencode/shared/MessageStatus.javanew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public abstract class MessageStatus {
5+
6+ abstract public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError;
7+
8+ public static MessageStatus deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
9+ int index = deserializer.deserialize_variant_index();
10+ switch (index) {
11+ case 0: return Sent.load(deserializer);
12+ case 1: return Pending.load(deserializer);
13+ case 2: return Failed.load(deserializer);
14+ default: throw new com.novi.serde.DeserializationError("Unknown variant index for MessageStatus: " + index);
15+ }
16+ }
17+
18+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
19+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
20+ serialize(serializer);
21+ return serializer.get_bytes();
22+ }
23+
24+ public static MessageStatus bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
25+ if (input == null) {
26+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
27+ }
28+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
29+ MessageStatus value = deserialize(deserializer);
30+ if (deserializer.get_buffer_offset() < input.length) {
31+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
32+ }
33+ return value;
34+ }
35+
36+ public static final class Sent extends MessageStatus {
37+ public Sent() {
38+ }
39+
40+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
41+ serializer.increase_container_depth();
42+ serializer.serialize_variant_index(0);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ static Sent load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
47+ deserializer.increase_container_depth();
48+ Builder builder = new Builder();
49+ deserializer.decrease_container_depth();
50+ return builder.build();
51+ }
52+
53+ public boolean equals(Object obj) {
54+ if (this == obj) return true;
55+ if (obj == null) return false;
56+ if (getClass() != obj.getClass()) return false;
57+ Sent other = (Sent) obj;
58+ return true;
59+ }
60+
61+ public int hashCode() {
62+ int value = 7;
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public Sent build() {
68+ return new Sent(
69+ );
70+ }
71+ }
72+ }
73+
74+ public static final class Pending extends MessageStatus {
75+ public Pending() {
76+ }
77+
78+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
79+ serializer.increase_container_depth();
80+ serializer.serialize_variant_index(1);
81+ serializer.decrease_container_depth();
82+ }
83+
84+ static Pending load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
85+ deserializer.increase_container_depth();
86+ Builder builder = new Builder();
87+ deserializer.decrease_container_depth();
88+ return builder.build();
89+ }
90+
91+ public boolean equals(Object obj) {
92+ if (this == obj) return true;
93+ if (obj == null) return false;
94+ if (getClass() != obj.getClass()) return false;
95+ Pending other = (Pending) obj;
96+ return true;
97+ }
98+
99+ public int hashCode() {
100+ int value = 7;
101+ return value;
102+ }
103+
104+ public static final class Builder {
105+ public Pending build() {
106+ return new Pending(
107+ );
108+ }
109+ }
110+ }
111+
112+ public static final class Failed extends MessageStatus {
113+ public Failed() {
114+ }
115+
116+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
117+ serializer.increase_container_depth();
118+ serializer.serialize_variant_index(2);
119+ serializer.decrease_container_depth();
120+ }
121+
122+ static Failed load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
123+ deserializer.increase_container_depth();
124+ Builder builder = new Builder();
125+ deserializer.decrease_container_depth();
126+ return builder.build();
127+ }
128+
129+ public boolean equals(Object obj) {
130+ if (this == obj) return true;
131+ if (obj == null) return false;
132+ if (getClass() != obj.getClass()) return false;
133+ Failed other = (Failed) obj;
134+ return true;
135+ }
136+
137+ public int hashCode() {
138+ int value = 7;
139+ return value;
140+ }
141+
142+ public static final class Builder {
143+ public Failed build() {
144+ return new Failed(
145+ );
146+ }
147+ }
148+ }
149+}
150+
generated/soy/iko/opencode/shared/MessageView.javanew file+131
@@ -0,0 +1,131 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class MessageView {
5+ public final String id;
6+ public final String role;
7+ public final String text;
8+ public final java.util.Optional<String> reasoning;
9+ public final java.util.List<ToolView> tools;
10+ public final @com.novi.serde.Unsigned Long time;
11+ public final MessageStatus status;
12+ public final Boolean streaming;
13+
14+ public MessageView(String id, String role, String text, java.util.Optional<String> reasoning, java.util.List<ToolView> tools, @com.novi.serde.Unsigned Long time, MessageStatus status, Boolean streaming) {
15+ java.util.Objects.requireNonNull(id, "id must not be null");
16+ java.util.Objects.requireNonNull(role, "role must not be null");
17+ java.util.Objects.requireNonNull(text, "text must not be null");
18+ java.util.Objects.requireNonNull(reasoning, "reasoning must not be null");
19+ java.util.Objects.requireNonNull(tools, "tools must not be null");
20+ java.util.Objects.requireNonNull(time, "time must not be null");
21+ java.util.Objects.requireNonNull(status, "status must not be null");
22+ java.util.Objects.requireNonNull(streaming, "streaming must not be null");
23+ this.id = id;
24+ this.role = role;
25+ this.text = text;
26+ this.reasoning = reasoning;
27+ this.tools = tools;
28+ this.time = time;
29+ this.status = status;
30+ this.streaming = streaming;
31+ }
32+
33+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
34+ serializer.increase_container_depth();
35+ serializer.serialize_str(id);
36+ serializer.serialize_str(role);
37+ serializer.serialize_str(text);
38+ TraitHelpers.serialize_option_str(reasoning, serializer);
39+ TraitHelpers.serialize_vector_ToolView(tools, serializer);
40+ serializer.serialize_u64(time);
41+ status.serialize(serializer);
42+ serializer.serialize_bool(streaming);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
47+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
48+ serialize(serializer);
49+ return serializer.get_bytes();
50+ }
51+
52+ public static MessageView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
53+ deserializer.increase_container_depth();
54+ Builder builder = new Builder();
55+ builder.id = deserializer.deserialize_str();
56+ builder.role = deserializer.deserialize_str();
57+ builder.text = deserializer.deserialize_str();
58+ builder.reasoning = TraitHelpers.deserialize_option_str(deserializer);
59+ builder.tools = TraitHelpers.deserialize_vector_ToolView(deserializer);
60+ builder.time = deserializer.deserialize_u64();
61+ builder.status = MessageStatus.deserialize(deserializer);
62+ builder.streaming = deserializer.deserialize_bool();
63+ deserializer.decrease_container_depth();
64+ return builder.build();
65+ }
66+
67+ public static MessageView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
68+ if (input == null) {
69+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
70+ }
71+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
72+ MessageView value = deserialize(deserializer);
73+ if (deserializer.get_buffer_offset() < input.length) {
74+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
75+ }
76+ return value;
77+ }
78+
79+ public boolean equals(Object obj) {
80+ if (this == obj) return true;
81+ if (obj == null) return false;
82+ if (getClass() != obj.getClass()) return false;
83+ MessageView other = (MessageView) obj;
84+ if (!java.util.Objects.equals(this.id, other.id)) { return false; }
85+ if (!java.util.Objects.equals(this.role, other.role)) { return false; }
86+ if (!java.util.Objects.equals(this.text, other.text)) { return false; }
87+ if (!java.util.Objects.equals(this.reasoning, other.reasoning)) { return false; }
88+ if (!java.util.Objects.equals(this.tools, other.tools)) { return false; }
89+ if (!java.util.Objects.equals(this.time, other.time)) { return false; }
90+ if (!java.util.Objects.equals(this.status, other.status)) { return false; }
91+ if (!java.util.Objects.equals(this.streaming, other.streaming)) { return false; }
92+ return true;
93+ }
94+
95+ public int hashCode() {
96+ int value = 7;
97+ value = 31 * value + (this.id != null ? this.id.hashCode() : 0);
98+ value = 31 * value + (this.role != null ? this.role.hashCode() : 0);
99+ value = 31 * value + (this.text != null ? this.text.hashCode() : 0);
100+ value = 31 * value + (this.reasoning != null ? this.reasoning.hashCode() : 0);
101+ value = 31 * value + (this.tools != null ? this.tools.hashCode() : 0);
102+ value = 31 * value + (this.time != null ? this.time.hashCode() : 0);
103+ value = 31 * value + (this.status != null ? this.status.hashCode() : 0);
104+ value = 31 * value + (this.streaming != null ? this.streaming.hashCode() : 0);
105+ return value;
106+ }
107+
108+ public static final class Builder {
109+ public String id;
110+ public String role;
111+ public String text;
112+ public java.util.Optional<String> reasoning;
113+ public java.util.List<ToolView> tools;
114+ public @com.novi.serde.Unsigned Long time;
115+ public MessageStatus status;
116+ public Boolean streaming;
117+
118+ public MessageView build() {
119+ return new MessageView(
120+ id,
121+ role,
122+ text,
123+ reasoning,
124+ tools,
125+ time,
126+ status,
127+ streaming
128+ );
129+ }
130+ }
131+}
generated/soy/iko/opencode/shared/Requests.javanew file+27
@@ -0,0 +1,27 @@
1+package soy.iko.opencode.shared;
2+
3+public final class Requests {
4+
5+ public static java.util.List<Request> bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
6+ if (input == null) {
7+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
8+ }
9+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
10+ deserializer.increase_container_depth();
11+
12+ long length = deserializer.deserialize_len();
13+
14+ java.util.List<Request> value = new java.util.ArrayList<>();
15+
16+ for (int i = 0; i < length; i++) {
17+ value.add(Request.deserialize(deserializer));
18+ }
19+
20+ deserializer.decrease_container_depth();
21+
22+ if (deserializer.get_buffer_offset() < input.length) {
23+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
24+ }
25+ return value;
26+ }
27+}
generated/soy/iko/opencode/shared/Screen.javanew file+150
@@ -0,0 +1,150 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public abstract class Screen {
5+
6+ abstract public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError;
7+
8+ public static Screen deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
9+ int index = deserializer.deserialize_variant_index();
10+ switch (index) {
11+ case 0: return Connect.load(deserializer);
12+ case 1: return Sessions.load(deserializer);
13+ case 2: return Chat.load(deserializer);
14+ default: throw new com.novi.serde.DeserializationError("Unknown variant index for Screen: " + index);
15+ }
16+ }
17+
18+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
19+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
20+ serialize(serializer);
21+ return serializer.get_bytes();
22+ }
23+
24+ public static Screen bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
25+ if (input == null) {
26+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
27+ }
28+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
29+ Screen value = deserialize(deserializer);
30+ if (deserializer.get_buffer_offset() < input.length) {
31+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
32+ }
33+ return value;
34+ }
35+
36+ public static final class Connect extends Screen {
37+ public Connect() {
38+ }
39+
40+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
41+ serializer.increase_container_depth();
42+ serializer.serialize_variant_index(0);
43+ serializer.decrease_container_depth();
44+ }
45+
46+ static Connect load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
47+ deserializer.increase_container_depth();
48+ Builder builder = new Builder();
49+ deserializer.decrease_container_depth();
50+ return builder.build();
51+ }
52+
53+ public boolean equals(Object obj) {
54+ if (this == obj) return true;
55+ if (obj == null) return false;
56+ if (getClass() != obj.getClass()) return false;
57+ Connect other = (Connect) obj;
58+ return true;
59+ }
60+
61+ public int hashCode() {
62+ int value = 7;
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public Connect build() {
68+ return new Connect(
69+ );
70+ }
71+ }
72+ }
73+
74+ public static final class Sessions extends Screen {
75+ public Sessions() {
76+ }
77+
78+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
79+ serializer.increase_container_depth();
80+ serializer.serialize_variant_index(1);
81+ serializer.decrease_container_depth();
82+ }
83+
84+ static Sessions load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
85+ deserializer.increase_container_depth();
86+ Builder builder = new Builder();
87+ deserializer.decrease_container_depth();
88+ return builder.build();
89+ }
90+
91+ public boolean equals(Object obj) {
92+ if (this == obj) return true;
93+ if (obj == null) return false;
94+ if (getClass() != obj.getClass()) return false;
95+ Sessions other = (Sessions) obj;
96+ return true;
97+ }
98+
99+ public int hashCode() {
100+ int value = 7;
101+ return value;
102+ }
103+
104+ public static final class Builder {
105+ public Sessions build() {
106+ return new Sessions(
107+ );
108+ }
109+ }
110+ }
111+
112+ public static final class Chat extends Screen {
113+ public Chat() {
114+ }
115+
116+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
117+ serializer.increase_container_depth();
118+ serializer.serialize_variant_index(2);
119+ serializer.decrease_container_depth();
120+ }
121+
122+ static Chat load(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
123+ deserializer.increase_container_depth();
124+ Builder builder = new Builder();
125+ deserializer.decrease_container_depth();
126+ return builder.build();
127+ }
128+
129+ public boolean equals(Object obj) {
130+ if (this == obj) return true;
131+ if (obj == null) return false;
132+ if (getClass() != obj.getClass()) return false;
133+ Chat other = (Chat) obj;
134+ return true;
135+ }
136+
137+ public int hashCode() {
138+ int value = 7;
139+ return value;
140+ }
141+
142+ public static final class Builder {
143+ public Chat build() {
144+ return new Chat(
145+ );
146+ }
147+ }
148+ }
149+}
150+
generated/soy/iko/opencode/shared/SessionView.javanew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class SessionView {
5+ public final String id;
6+ public final String title;
7+
8+ public SessionView(String id, String title) {
9+ java.util.Objects.requireNonNull(id, "id must not be null");
10+ java.util.Objects.requireNonNull(title, "title must not be null");
11+ this.id = id;
12+ this.title = title;
13+ }
14+
15+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
16+ serializer.increase_container_depth();
17+ serializer.serialize_str(id);
18+ serializer.serialize_str(title);
19+ serializer.decrease_container_depth();
20+ }
21+
22+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
23+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
24+ serialize(serializer);
25+ return serializer.get_bytes();
26+ }
27+
28+ public static SessionView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
29+ deserializer.increase_container_depth();
30+ Builder builder = new Builder();
31+ builder.id = deserializer.deserialize_str();
32+ builder.title = deserializer.deserialize_str();
33+ deserializer.decrease_container_depth();
34+ return builder.build();
35+ }
36+
37+ public static SessionView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
38+ if (input == null) {
39+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
40+ }
41+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
42+ SessionView value = deserialize(deserializer);
43+ if (deserializer.get_buffer_offset() < input.length) {
44+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
45+ }
46+ return value;
47+ }
48+
49+ public boolean equals(Object obj) {
50+ if (this == obj) return true;
51+ if (obj == null) return false;
52+ if (getClass() != obj.getClass()) return false;
53+ SessionView other = (SessionView) obj;
54+ if (!java.util.Objects.equals(this.id, other.id)) { return false; }
55+ if (!java.util.Objects.equals(this.title, other.title)) { return false; }
56+ return true;
57+ }
58+
59+ public int hashCode() {
60+ int value = 7;
61+ value = 31 * value + (this.id != null ? this.id.hashCode() : 0);
62+ value = 31 * value + (this.title != null ? this.title.hashCode() : 0);
63+ return value;
64+ }
65+
66+ public static final class Builder {
67+ public String id;
68+ public String title;
69+
70+ public SessionView build() {
71+ return new SessionView(
72+ id,
73+ title
74+ );
75+ }
76+ }
77+}
generated/soy/iko/opencode/shared/ToolView.javanew file+86
@@ -0,0 +1,86 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class ToolView {
5+ public final String name;
6+ public final String status;
7+ public final java.util.Optional<String> title;
8+
9+ public ToolView(String name, String status, java.util.Optional<String> title) {
10+ java.util.Objects.requireNonNull(name, "name must not be null");
11+ java.util.Objects.requireNonNull(status, "status must not be null");
12+ java.util.Objects.requireNonNull(title, "title must not be null");
13+ this.name = name;
14+ this.status = status;
15+ this.title = title;
16+ }
17+
18+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
19+ serializer.increase_container_depth();
20+ serializer.serialize_str(name);
21+ serializer.serialize_str(status);
22+ TraitHelpers.serialize_option_str(title, serializer);
23+ serializer.decrease_container_depth();
24+ }
25+
26+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
27+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
28+ serialize(serializer);
29+ return serializer.get_bytes();
30+ }
31+
32+ public static ToolView deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
33+ deserializer.increase_container_depth();
34+ Builder builder = new Builder();
35+ builder.name = deserializer.deserialize_str();
36+ builder.status = deserializer.deserialize_str();
37+ builder.title = TraitHelpers.deserialize_option_str(deserializer);
38+ deserializer.decrease_container_depth();
39+ return builder.build();
40+ }
41+
42+ public static ToolView bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
43+ if (input == null) {
44+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
45+ }
46+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
47+ ToolView value = deserialize(deserializer);
48+ if (deserializer.get_buffer_offset() < input.length) {
49+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
50+ }
51+ return value;
52+ }
53+
54+ public boolean equals(Object obj) {
55+ if (this == obj) return true;
56+ if (obj == null) return false;
57+ if (getClass() != obj.getClass()) return false;
58+ ToolView other = (ToolView) obj;
59+ if (!java.util.Objects.equals(this.name, other.name)) { return false; }
60+ if (!java.util.Objects.equals(this.status, other.status)) { return false; }
61+ if (!java.util.Objects.equals(this.title, other.title)) { return false; }
62+ return true;
63+ }
64+
65+ public int hashCode() {
66+ int value = 7;
67+ value = 31 * value + (this.name != null ? this.name.hashCode() : 0);
68+ value = 31 * value + (this.status != null ? this.status.hashCode() : 0);
69+ value = 31 * value + (this.title != null ? this.title.hashCode() : 0);
70+ return value;
71+ }
72+
73+ public static final class Builder {
74+ public String name;
75+ public String status;
76+ public java.util.Optional<String> title;
77+
78+ public ToolView build() {
79+ return new ToolView(
80+ name,
81+ status,
82+ title
83+ );
84+ }
85+ }
86+}
generated/soy/iko/opencode/shared/TraitHelpers.javanew file+71
@@ -0,0 +1,71 @@
1+package soy.iko.opencode.shared;
2+
3+final class TraitHelpers {
4+ static void serialize_option_str(java.util.Optional<String> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
5+ if (value.isPresent()) {
6+ serializer.serialize_option_tag(true);
7+ serializer.serialize_str(value.get());
8+ } else {
9+ serializer.serialize_option_tag(false);
10+ }
11+ }
12+
13+ static java.util.Optional<String> deserialize_option_str(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
14+ boolean tag = deserializer.deserialize_option_tag();
15+ if (!tag) {
16+ return java.util.Optional.empty();
17+ } else {
18+ return java.util.Optional.of(deserializer.deserialize_str());
19+ }
20+ }
21+
22+ static void serialize_vector_MessageView(java.util.List<MessageView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
23+ serializer.serialize_len(value.size());
24+ for (MessageView item : value) {
25+ item.serialize(serializer);
26+ }
27+ }
28+
29+ static java.util.List<MessageView> deserialize_vector_MessageView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
30+ long length = deserializer.deserialize_len();
31+ java.util.List<MessageView> obj = new java.util.ArrayList<MessageView>((int) length);
32+ for (long i = 0; i < length; i++) {
33+ obj.add(MessageView.deserialize(deserializer));
34+ }
35+ return obj;
36+ }
37+
38+ static void serialize_vector_SessionView(java.util.List<SessionView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
39+ serializer.serialize_len(value.size());
40+ for (SessionView item : value) {
41+ item.serialize(serializer);
42+ }
43+ }
44+
45+ static java.util.List<SessionView> deserialize_vector_SessionView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
46+ long length = deserializer.deserialize_len();
47+ java.util.List<SessionView> obj = new java.util.ArrayList<SessionView>((int) length);
48+ for (long i = 0; i < length; i++) {
49+ obj.add(SessionView.deserialize(deserializer));
50+ }
51+ return obj;
52+ }
53+
54+ static void serialize_vector_ToolView(java.util.List<ToolView> value, com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
55+ serializer.serialize_len(value.size());
56+ for (ToolView item : value) {
57+ item.serialize(serializer);
58+ }
59+ }
60+
61+ static java.util.List<ToolView> deserialize_vector_ToolView(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
62+ long length = deserializer.deserialize_len();
63+ java.util.List<ToolView> obj = new java.util.ArrayList<ToolView>((int) length);
64+ for (long i = 0; i < length; i++) {
65+ obj.add(ToolView.deserialize(deserializer));
66+ }
67+ return obj;
68+ }
69+
70+}
71+
generated/soy/iko/opencode/shared/ViewModel.javanew file+185
@@ -0,0 +1,185 @@
1+package soy.iko.opencode.shared;
2+
3+
4+public final class ViewModel {
5+ public final Screen screen;
6+ public final String server_url;
7+ public final String username;
8+ public final String password;
9+ public final Boolean auth_required;
10+ public final Boolean connected;
11+ public final Boolean loading;
12+ public final java.util.Optional<String> error;
13+ public final java.util.List<SessionView> sessions;
14+ public final java.util.Optional<String> current_session_id;
15+ public final String current_session_title;
16+ public final java.util.List<MessageView> messages;
17+ public final String draft;
18+ public final Boolean generating;
19+
20+ public ViewModel(Screen screen, String server_url, String username, String password, Boolean auth_required, Boolean connected, Boolean loading, java.util.Optional<String> error, java.util.List<SessionView> sessions, java.util.Optional<String> current_session_id, String current_session_title, java.util.List<MessageView> messages, String draft, Boolean generating) {
21+ java.util.Objects.requireNonNull(screen, "screen must not be null");
22+ java.util.Objects.requireNonNull(server_url, "server_url must not be null");
23+ java.util.Objects.requireNonNull(username, "username must not be null");
24+ java.util.Objects.requireNonNull(password, "password must not be null");
25+ java.util.Objects.requireNonNull(auth_required, "auth_required must not be null");
26+ java.util.Objects.requireNonNull(connected, "connected must not be null");
27+ java.util.Objects.requireNonNull(loading, "loading must not be null");
28+ java.util.Objects.requireNonNull(error, "error must not be null");
29+ java.util.Objects.requireNonNull(sessions, "sessions must not be null");
30+ java.util.Objects.requireNonNull(current_session_id, "current_session_id must not be null");
31+ java.util.Objects.requireNonNull(current_session_title, "current_session_title must not be null");
32+ java.util.Objects.requireNonNull(messages, "messages must not be null");
33+ java.util.Objects.requireNonNull(draft, "draft must not be null");
34+ java.util.Objects.requireNonNull(generating, "generating must not be null");
35+ this.screen = screen;
36+ this.server_url = server_url;
37+ this.username = username;
38+ this.password = password;
39+ this.auth_required = auth_required;
40+ this.connected = connected;
41+ this.loading = loading;
42+ this.error = error;
43+ this.sessions = sessions;
44+ this.current_session_id = current_session_id;
45+ this.current_session_title = current_session_title;
46+ this.messages = messages;
47+ this.draft = draft;
48+ this.generating = generating;
49+ }
50+
51+ public void serialize(com.novi.serde.Serializer serializer) throws com.novi.serde.SerializationError {
52+ serializer.increase_container_depth();
53+ screen.serialize(serializer);
54+ serializer.serialize_str(server_url);
55+ serializer.serialize_str(username);
56+ serializer.serialize_str(password);
57+ serializer.serialize_bool(auth_required);
58+ serializer.serialize_bool(connected);
59+ serializer.serialize_bool(loading);
60+ TraitHelpers.serialize_option_str(error, serializer);
61+ TraitHelpers.serialize_vector_SessionView(sessions, serializer);
62+ TraitHelpers.serialize_option_str(current_session_id, serializer);
63+ serializer.serialize_str(current_session_title);
64+ TraitHelpers.serialize_vector_MessageView(messages, serializer);
65+ serializer.serialize_str(draft);
66+ serializer.serialize_bool(generating);
67+ serializer.decrease_container_depth();
68+ }
69+
70+ public byte[] bincodeSerialize() throws com.novi.serde.SerializationError {
71+ com.novi.serde.Serializer serializer = new com.novi.bincode.BincodeSerializer();
72+ serialize(serializer);
73+ return serializer.get_bytes();
74+ }
75+
76+ public static ViewModel deserialize(com.novi.serde.Deserializer deserializer) throws com.novi.serde.DeserializationError {
77+ deserializer.increase_container_depth();
78+ Builder builder = new Builder();
79+ builder.screen = Screen.deserialize(deserializer);
80+ builder.server_url = deserializer.deserialize_str();
81+ builder.username = deserializer.deserialize_str();
82+ builder.password = deserializer.deserialize_str();
83+ builder.auth_required = deserializer.deserialize_bool();
84+ builder.connected = deserializer.deserialize_bool();
85+ builder.loading = deserializer.deserialize_bool();
86+ builder.error = TraitHelpers.deserialize_option_str(deserializer);
87+ builder.sessions = TraitHelpers.deserialize_vector_SessionView(deserializer);
88+ builder.current_session_id = TraitHelpers.deserialize_option_str(deserializer);
89+ builder.current_session_title = deserializer.deserialize_str();
90+ builder.messages = TraitHelpers.deserialize_vector_MessageView(deserializer);
91+ builder.draft = deserializer.deserialize_str();
92+ builder.generating = deserializer.deserialize_bool();
93+ deserializer.decrease_container_depth();
94+ return builder.build();
95+ }
96+
97+ public static ViewModel bincodeDeserialize(byte[] input) throws com.novi.serde.DeserializationError {
98+ if (input == null) {
99+ throw new com.novi.serde.DeserializationError("Cannot deserialize null array");
100+ }
101+ com.novi.serde.Deserializer deserializer = new com.novi.bincode.BincodeDeserializer(input);
102+ ViewModel value = deserialize(deserializer);
103+ if (deserializer.get_buffer_offset() < input.length) {
104+ throw new com.novi.serde.DeserializationError("Some input bytes were not read");
105+ }
106+ return value;
107+ }
108+
109+ public boolean equals(Object obj) {
110+ if (this == obj) return true;
111+ if (obj == null) return false;
112+ if (getClass() != obj.getClass()) return false;
113+ ViewModel other = (ViewModel) obj;
114+ if (!java.util.Objects.equals(this.screen, other.screen)) { return false; }
115+ if (!java.util.Objects.equals(this.server_url, other.server_url)) { return false; }
116+ if (!java.util.Objects.equals(this.username, other.username)) { return false; }
117+ if (!java.util.Objects.equals(this.password, other.password)) { return false; }
118+ if (!java.util.Objects.equals(this.auth_required, other.auth_required)) { return false; }
119+ if (!java.util.Objects.equals(this.connected, other.connected)) { return false; }
120+ if (!java.util.Objects.equals(this.loading, other.loading)) { return false; }
121+ if (!java.util.Objects.equals(this.error, other.error)) { return false; }
122+ if (!java.util.Objects.equals(this.sessions, other.sessions)) { return false; }
123+ if (!java.util.Objects.equals(this.current_session_id, other.current_session_id)) { return false; }
124+ if (!java.util.Objects.equals(this.current_session_title, other.current_session_title)) { return false; }
125+ if (!java.util.Objects.equals(this.messages, other.messages)) { return false; }
126+ if (!java.util.Objects.equals(this.draft, other.draft)) { return false; }
127+ if (!java.util.Objects.equals(this.generating, other.generating)) { return false; }
128+ return true;
129+ }
130+
131+ public int hashCode() {
132+ int value = 7;
133+ value = 31 * value + (this.screen != null ? this.screen.hashCode() : 0);
134+ value = 31 * value + (this.server_url != null ? this.server_url.hashCode() : 0);
135+ value = 31 * value + (this.username != null ? this.username.hashCode() : 0);
136+ value = 31 * value + (this.password != null ? this.password.hashCode() : 0);
137+ value = 31 * value + (this.auth_required != null ? this.auth_required.hashCode() : 0);
138+ value = 31 * value + (this.connected != null ? this.connected.hashCode() : 0);
139+ value = 31 * value + (this.loading != null ? this.loading.hashCode() : 0);
140+ value = 31 * value + (this.error != null ? this.error.hashCode() : 0);
141+ value = 31 * value + (this.sessions != null ? this.sessions.hashCode() : 0);
142+ value = 31 * value + (this.current_session_id != null ? this.current_session_id.hashCode() : 0);
143+ value = 31 * value + (this.current_session_title != null ? this.current_session_title.hashCode() : 0);
144+ value = 31 * value + (this.messages != null ? this.messages.hashCode() : 0);
145+ value = 31 * value + (this.draft != null ? this.draft.hashCode() : 0);
146+ value = 31 * value + (this.generating != null ? this.generating.hashCode() : 0);
147+ return value;
148+ }
149+
150+ public static final class Builder {
151+ public Screen screen;
152+ public String server_url;
153+ public String username;
154+ public String password;
155+ public Boolean auth_required;
156+ public Boolean connected;
157+ public Boolean loading;
158+ public java.util.Optional<String> error;
159+ public java.util.List<SessionView> sessions;
160+ public java.util.Optional<String> current_session_id;
161+ public String current_session_title;
162+ public java.util.List<MessageView> messages;
163+ public String draft;
164+ public Boolean generating;
165+
166+ public ViewModel build() {
167+ return new ViewModel(
168+ screen,
169+ server_url,
170+ username,
171+ password,
172+ auth_required,
173+ connected,
174+ loading,
175+ error,
176+ sessions,
177+ current_session_id,
178+ current_session_title,
179+ messages,
180+ draft,
181+ generating
182+ );
183+ }
184+ }
185+}
gradle.properties+5−2
@@ -1,7 +1,10 @@
1−org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
1+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
2org.gradle.caching=true
2org.gradle.caching=true
3−org.gradle.parallel=true
4org.gradle.configuration-cache=true
3org.gradle.configuration-cache=true
4+
5+# AndroidX
5android.useAndroidX=true
6android.useAndroidX=true
6android.nonTransitiveRClass=true
7android.nonTransitiveRClass=true
8+
9+# Kotlin
7kotlin.code.style=official
10kotlin.code.style=official
gradle/libs.versions.toml+25−50
@@ -1,69 +1,44 @@
1[versions]
1[versions]
2agp = "8.7.3"
2agp = "8.7.3"
3kotlin = "2.0.21"
3kotlin = "2.0.21"
4−ktor = "3.0.3"
4+coreKtx = "1.15.0"
5−kotlinxSerialization = "1.7.3"
6−kotlinxCoroutines = "1.9.0"
7−composeBom = "2024.12.01"
8−activityCompose = "1.9.3"
9lifecycle = "2.8.7"
5lifecycle = "2.8.7"
10−navigationCompose = "2.8.5"
6+activityCompose = "1.9.3"
11−datastore = "1.1.1"
7+composeBom = "2024.10.01"
12−securityCrypto = "1.1.0-alpha06"
8+coroutines = "1.9.0"
13−coil = "2.7.0"
9+serialization = "1.7.3"
14−markdownRenderer = "0.27.0"
10+okhttp = "4.12.0"
15−coreKtx = "1.13.1"
16−splashscreen = "1.0.1"
17junit = "4.13.2"
11junit = "4.13.2"
18−detekt = "1.23.8"
12+androidxJunit = "1.2.1"
19−androidxTestExt = "1.2.1"
20−androidxTestRunner = "1.6.2"
21−androidxTestCore = "1.6.1"
22−espresso = "3.6.1"
23
13
24[libraries]
14[libraries]
25androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
15androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
26−androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
27−androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
28−androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
16+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
29androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
17androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
30−androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
18+androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
31−
19+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
32−compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
20+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
33−compose-ui = { group = "androidx.compose.ui", name = "ui" }
34−compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
35−compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
36−compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
37−compose-material3 = { group = "androidx.compose.material3", name = "material3" }
38−compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
39
21
40−ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
22+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
41−ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
23+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
42−ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
24+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
43−ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
25+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
44−ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" }
26+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
45−ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
27+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
46−ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
28+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
47
29
48−kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
49−kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
30+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
50−kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
31+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
51
32
52−androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
33+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
53−androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
34+okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" }
54−coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
55−markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
56−markdown-renderer = { group = "com.mikepenz", name = "multiplatform-markdown-renderer", version.ref = "markdownRenderer" }
57
35
58junit = { group = "junit", name = "junit", version.ref = "junit" }
36junit = { group = "junit", name = "junit", version.ref = "junit" }
59−androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExt" }
37+kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
60−androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" }
38+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
61−androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
62−androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
63
39
64[plugins]
40[plugins]
65android-application = { id = "com.android.application", version.ref = "agp" }
41android-application = { id = "com.android.application", version.ref = "agp" }
66kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
42kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
67−kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
68kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
43kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
69−detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
44+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
renovate.jsondeleted−32
@@ -1,32 +0,0 @@
1−{
2− "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3− "extends": ["config:recommended"],
4− "labels": ["dependencies"],
5− "dependencyDashboard": true,
6− "prConcurrentLimit": 5,
7− "commitMessagePrefix": "deps:",
8− "schedule": ["before 6am on Monday"],
9− "lockFileMaintenance": {
10− "enabled": true,
11− "schedule": ["before 6am on Monday"]
12− },
13− "packageRules": [
14− {
15− "groupName": "Kotlin and Compose compiler",
16− "matchPackagePatterns": ["^org.jetbrains.kotlin", "^com.android.tools.build"]
17− },
18− {
19− "groupName": "Jetpack Compose",
20− "matchPackagePatterns": ["^androidx.compose"]
21− },
22− {
23− "groupName": "Ktor",
24− "matchPackagePatterns": ["^io.ktor"]
25− },
26− {
27− "description": "Pin AndroidX to the set the app targets (compileSdk 35); review major bumps manually.",
28− "matchPackagePatterns": ["^androidx."],
29− "major": { "enabled": false }
30− }
31− ]
32−}
settings.gradle.kts+3
@@ -21,4 +21,7 @@ dependencyResolutionManagement {
21}
21}
22
22
23rootProject.name = "opencode-android"
23rootProject.name = "opencode-android"
24+
25+// The Crux core lives in `shared/` and is a pure Rust crate (built and tested with
26+// cargo, not Gradle), so the only Gradle module is the Android app shell.
24include(":app")
27include(":app")
shared/Cargo.locknew file+1358
@@ -0,0 +1,1358 @@
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.tomlnew file+31
@@ -0,0 +1,31 @@
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.rsnew file+1051
@@ -0,0 +1,1051 @@
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.rsnew file+35
@@ -0,0 +1,35 @@
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.rsnew file+17
@@ -0,0 +1,17 @@
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.rsnew file+248

This diff is too big to show here.