git.iko.soy/opencode-android
on ea52e62 help sign in
.direnv/bin/nix-direnv-reloadnew file+19
since splitsnapshots
unifiedsplit
@@ -0,0 +1,19 @@
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-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfanew file+1
@@ -0,0 +1 @@
1+/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcnew file+2250
@@ -0,0 +1,2250 @@
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+16βˆ’18
@@ -5,26 +5,8 @@ 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βˆ’
14jobs:
8jobs:
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βˆ’
26 build:
9 build:
27βˆ’ name: Android app
28 runs-on: ubuntu-latest
10 runs-on: ubuntu-latest
29
11
30 steps:
12 steps:
@@ -42,15 +24,31 @@ jobs:
42 - name: Run unit tests
24 - name: Run unit tests
43 run: ./gradlew testDebugUnitTest --no-daemon
25 run: ./gradlew testDebugUnitTest --no-daemon
44
26
27+ - name: Static analysis (detekt)
28+ run: ./gradlew detekt --no-daemon
29+
45 - name: Lint
30 - name: Lint
46 run: ./gradlew lintDebug --no-daemon
31 run: ./gradlew lintDebug --no-daemon
47
32
48 - name: Build debug APK
33 - name: Build debug APK
49 run: ./gradlew assembleDebug --no-daemon
34 run: ./gradlew assembleDebug --no-daemon
50
35
36+ - name: Build release APK
37+ run: ./gradlew assembleRelease --no-daemon
38+
51 - name: Upload debug APK
39 - name: Upload debug APK
52 uses: actions/upload-artifact@v4
40 uses: actions/upload-artifact@v4
53 with:
41 with:
54 name: opencode-debug.apk
42 name: opencode-debug.apk
55 path: app/build/outputs/apk/debug/*.apk
43 path: app/build/outputs/apk/debug/*.apk
56 if-no-files-found: error
44 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.ymldeletedβˆ’113
@@ -1,113 +0,0 @@
1βˆ’name: Release
2βˆ’
3βˆ’# Build a minified, signed release APK and publish it as a GitHub Release.
4βˆ’#
5βˆ’# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6βˆ’# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7βˆ’# Actions tab (workflow_dispatch). For a branch push or manual run the version
8βˆ’# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9βˆ’# tag name (with the leading `v` stripped) as the version.
10βˆ’#
11βˆ’# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12βˆ’# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13βˆ’#
14βˆ’# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15βˆ’# with that keystore; otherwise the Gradle build falls back to the debug key so
16βˆ’# the released APK is still installable (see app/build.gradle.kts). The version
17βˆ’# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
18βˆ’on:
19βˆ’ push:
20βˆ’ branches: [master]
21βˆ’ tags: ["v*"]
22βˆ’ workflow_dispatch:
23βˆ’
24βˆ’# Never abort a release mid-flight β€” a half-published release with a missing
25βˆ’# artifact is worse than a slightly delayed one.
26βˆ’concurrency:
27βˆ’ group: ${{ github.workflow }}-${{ github.ref }}
28βˆ’ cancel-in-progress: false
29βˆ’
30βˆ’jobs:
31βˆ’ release:
32βˆ’ runs-on: ubuntu-latest
33βˆ’ permissions:
34βˆ’ contents: write # push the tag and create the release
35βˆ’ id-token: write # OIDC so GitHub can sign the provenance attestation
36βˆ’ attestations: write # publish the attestation record
37βˆ’ env:
38βˆ’ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39βˆ’ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40βˆ’ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41βˆ’ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
42βˆ’ steps:
43βˆ’ - uses: actions/checkout@v4
44βˆ’ with:
45βˆ’ # Full history so the versionCode (git rev-list --count HEAD) is
46βˆ’ # accurate instead of collapsing to 1 on a shallow clone.
47βˆ’ fetch-depth: 0
48βˆ’
49βˆ’ - name: Compute version
50βˆ’ id: version
51βˆ’ run: |
52βˆ’ if [ "${{ github.ref_type }}" = "tag" ]; then
53βˆ’ VERSION="${GITHUB_REF_NAME#v}"
54βˆ’ TAG="${GITHUB_REF_NAME}"
55βˆ’ else
56βˆ’ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57βˆ’ TAG="v${VERSION}"
58βˆ’ fi
59βˆ’ CODE="$(git rev-list --count HEAD)"
60βˆ’ {
61βˆ’ echo "version=$VERSION"
62βˆ’ echo "tag=$TAG"
63βˆ’ echo "code=$CODE"
64βˆ’ } >> "$GITHUB_OUTPUT"
65βˆ’ echo "Releasing $TAG (version $VERSION, code $CODE)"
66βˆ’
67βˆ’ - uses: actions/setup-java@v4
68βˆ’ with:
69βˆ’ distribution: temurin
70βˆ’ java-version: 17
71βˆ’
72βˆ’ - uses: android-actions/setup-android@v3
73βˆ’
74βˆ’ - uses: gradle/actions/setup-gradle@v4
75βˆ’
76βˆ’ # The keystore ships as base64 in a secret so it never touches the repo.
77βˆ’ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
78βˆ’ - name: Decode signing keystore
79βˆ’ if: env.OPENCODE_STORE_BASE64 != ''
80βˆ’ run: |
81βˆ’ STORE_FILE="$RUNNER_TEMP/release.keystore"
82βˆ’ printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83βˆ’ echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84βˆ’
85βˆ’ - name: Build release APK
86βˆ’ run: |
87βˆ’ ./gradlew assembleRelease \
88βˆ’ -PversionName="${{ steps.version.outputs.version }}" \
89βˆ’ -PversionCode="${{ steps.version.outputs.code }}" \
90βˆ’ --no-daemon
91βˆ’
92βˆ’ # Rename to a stable, version-tagged filename for the release attachment.
93βˆ’ - name: Stage APK
94βˆ’ run: |
95βˆ’ mkdir -p staging
96βˆ’ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97βˆ’ cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98βˆ’
99βˆ’ # Signed build-provenance attestation for the exact artifact being
100βˆ’ # published; later verifiable with `gh attestation verify <apk>`.
101βˆ’ - name: Attest build provenance
102βˆ’ uses: actions/attest-build-provenance@v2
103βˆ’ with:
104βˆ’ subject-path: staging/*.apk
105βˆ’
106βˆ’ - name: Create release
107βˆ’ uses: softprops/action-gh-release@v2
108βˆ’ with:
109βˆ’ tag_name: ${{ steps.version.outputs.tag }}
110βˆ’ name: ${{ steps.version.outputs.version }}
111βˆ’ make_latest: true
112βˆ’ generate_release_notes: true
113βˆ’ files: staging/*.apk
.gitignoreβˆ’20
@@ -1,8 +1,6 @@
1# Gradle
1# Gradle
2.gradle/
2.gradle/
3build/
3build/
4βˆ’*.apk
5βˆ’*.aab
6
4
7# Kotlin
5# Kotlin
8.kotlin/
6.kotlin/
@@ -31,21 +29,3 @@ Thumbs.db
31
29
32# Nix
30# Nix
33result
31result
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
AGENTS.mdnew file+197
@@ -0,0 +1,197 @@
1+# AGENTS.md
2+
3+Guidance for AI coding agents (and humans) working in this repository.
4+
5+## Project
6+
7+opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8+[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9+remote opencode server over REST plus a long-lived Server-Sent Events stream,
10+and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11+an adaptive single-pane / two-pane Material 3 UI.
12+
13+- Application id / namespace: `soy.iko.opencode`
14+- Package root: `app/src/main/java/soy/iko/opencode`
15+- minSdk 26, compileSdk/targetSdk 35, JDK 17, Kotlin 2.0.x, AGP 8.7.x
16+- Single Gradle module (`:app`); no dynamic feature modules.
17+
18+## Build & environment
19+
20+A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 35, and a
21+Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22+
23+```bash
24+nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26+./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27+```
28+
29+Outside Nix, you need JDK 17 and the Android SDK (platform 35, build-tools
30+35.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31+
32+Install on a connected device/emulator:
33+
34+```bash
35+adb install -r app/build/outputs/apk/debug/app-debug.apk
36+adb shell am start -n soy.iko.opencode/.MainActivity
37+```
38+
39+## Test, lint, and static analysis
40+
41+```bash
42+./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43+./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44+./gradlew lintDebug # Android Lint (abortOnError = true)
45+./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46+```
47+
48+- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49+ `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50+ that override the `open` members of the real stores/clients). JUnit 4 +
51+ `kotlinx-coroutines-test`; no Robolectric β€” `testOptions.unitTests.isReturnDefaultValues = true`
52+ stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53+- Instrumented tests live in `app/src/androidTest/...` and need a device.
54+- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55+ findings block CI; existing ones are grandfathered.
56+- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` β†’ `detekt` β†’
57+ `lintDebug` β†’ `assembleDebug` β†’ `assembleRelease`, then uploads both APKs.
58+
59+## Running a server to develop against
60+
61+```bash
62+opencode serve --hostname 0.0.0.0 --port 4096
63+```
64+
65+Connect the app over LAN (`http://<host-ip>:4096`) or USB
66+(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67+(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68+"Set up a server and connect to it" section for full details.
69+
70+## Architecture
71+
72+Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73+
74+```
75+OpencodeApp (Application)
76+ └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77+ unread tracker, auto-reconnect, pending share / open-session signals)
78+ └─ OpencodeConnection (one per active server profile)
79+ β”œβ”€ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80+ β”œβ”€ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81+ β”œβ”€ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82+ └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83+ └─ MessageStore (in-memory reducer; internal, unit-tested)
84+
85+ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86+ └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87+ └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88+```
89+
90+Key invariants:
91+
92+- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93+ the SSE stream *before* the initial REST load and before any prompt POST, so
94+ early streamed parts aren't missed. Preserve this ordering if you touch it.
95+- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96+ for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97+ discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98+ throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99+ `UnknownEvent` β€” the whole point is that a new server release can't break the
100+ client. Add new modeled variants alongside the `Unknown` fallback.
101+- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102+ per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103+- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104+ `Connected` after a prior connection, the repository re-fetches messages from
105+ REST and merges (without pruning) under a generation counter so a stale fetch
106+ can't clobber a newer one. See `SessionRepository.kt` for the contract.
107+- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108+ the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109+ eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110+ for HTTP profiles the `Authorization` header is set proactively in
111+ `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112+ installed so credentials can't be re-sent silently on a cleartext 401.
113+- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114+ `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115+ called on real devices) that cancels the app scope and closes the active
116+ connection. `AppContainer.shutdown()` is guarded against double-invocation.
117+
118+## Source map
119+
120+```
121+data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122+data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123+ OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124+data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125+ EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126+ DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127+di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128+notification NotificationChannels, RunForegroundService (holds process priority
129+ during a run), SessionNotifications (completion notifications)
130+ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131+ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132+ agent/model/command picker sheets, permission dialog, export
133+ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (β‰₯840dp)
134+ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135+ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136+ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137+util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138+```
139+
140+## Conventions
141+
142+- **No comments unless asked** β€” existing code has extensive `//` comments
143+ explaining *why* (invariants, race conditions, security rationale). When you
144+ add code, match that style only for genuinely non-obvious invariants; don't
145+ narrate *what* the code does.
146+- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147+ inside coroutines β€” plain `runCatching` swallows `CancellationException` and
148+ breaks structured concurrency. Use it for any try/catch in a suspend context.
149+- **`safeExceptionSummary`** for logging Ktor exceptions β€” `ClientRequestException`
150+ messages embed the full request URL (may contain auth/paths); never log the
151+ raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152+- **`open` classes with protected no-arg test constructors.** Stores, the API
153+ client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154+ `open` and have a `protected constructor()` so tests can subclass and override
155+ individual members without instantiating Android frameworks. Keep this pattern
156+ when adding new injectable components.
157+- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158+ sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159+ than scattering literals.
160+- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161+ `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162+ against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163+- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164+ `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165+ Preserve this when adding mutating endpoints.
166+- **`withRetry`** wraps all REST reads and the permission response. Transient
167+ failures back off with jitter; see `NetworkConfig` for the parameters.
168+- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169+ 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170+ use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171+ composables and long parameter lists are accepted (detekt `LongMethod` /
172+ `LongParameterList` disabled).
173+- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174+ (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175+ Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176+- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177+ correctly.
178+- **Kotlin code style: `official`** (see `gradle.properties`).
179+
180+## Things to avoid
181+
182+- Don't add Hilt/Dagger/KSP β€” DI is intentionally hand-written (`AppContainer`).
183+- Don't add Robolectric β€” JVM tests use `isReturnDefaultValues = true` stubs and
184+ fakes; only device-dependent code goes in `androidTest`.
185+- Don't run `nix flake check` manually (CI only β€” per repo convention).
186+- Don't read or query `/nix/store` (extremely large β€” per repo convention).
187+- Don't introduce new detekt findings; the baseline only grandfathers existing
188+ ones. Run `./gradlew detekt` before considering work done.
189+- Don't log raw Ktor/OkHttp exceptions β€” use `safeExceptionSummary`.
190+- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191+ variants exist for forward compatibility and must remain reachable.
192+
193+## Renovate
194+
195+`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196+Ktor is grouped; AndroidX majors are held for manual review (pinned to
197+compileSdk 35). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mddeletedβˆ’40
@@ -1,40 +0,0 @@
1βˆ’# opencode-android
2βˆ’
3βˆ’Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4βˆ’
5βˆ’## Architecture
6βˆ’
7βˆ’- **shared/** β€” Rust Crux core (canonical spec, unit-tested with `cargo test`)
8βˆ’- **app/** β€” Android shell with Kotlin port of the reducer (`OpencodeCore`)
9βˆ’- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10βˆ’- Three screens: Connect β†’ Sessions β†’ Chat
11βˆ’
12βˆ’## Key Files
13βˆ’
14βˆ’- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` β€” reducer, all state management
15βˆ’- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` β€” UiState, Event, data classes
16βˆ’- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` β€” wire types, SSE event parser
17βˆ’- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` β€” OkHttp wrapper
18βˆ’- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` β€” SSE stream client
19βˆ’- `app/src/main/java/soy/iko/opencode/ui/screens/` β€” ConnectScreen, SessionsScreen, ChatScreen
20βˆ’- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` β€” message rendering
21βˆ’
22βˆ’## Build
23βˆ’
24βˆ’```sh
25βˆ’source .buildenv.sh
26βˆ’nix develop -c ./gradlew assembleDebug
27βˆ’```
28βˆ’
29βˆ’## Code Conventions
30βˆ’
31βˆ’- Kotlin + Jetpack Compose (Material 3)
32βˆ’- State flows through `MutableStateFlow<UiState>` β†’ `collectAsStateWithLifecycle`
33βˆ’- All state mutations on main dispatcher (viewModelScope)
34βˆ’- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35βˆ’- Keep the Rust core and Kotlin port behaviourally identical
36βˆ’
37βˆ’## Version Control
38βˆ’
39βˆ’- Uses `jj` (jujutsu) colocated with git
40βˆ’- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+64βˆ’126
@@ -1,153 +1,91 @@
1# opencode-android
1# opencode-android
2
2
3βˆ’A native Android client for the [**opencode**](https://opencode.ai) server (the
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
4βˆ’`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5βˆ’and **Jetpack Compose**.
6
4
7βˆ’Connect to a running `opencode serve`, browse and create sessions, and chat with
5+Application id: `soy.iko.opencode`
8βˆ’the agent β€” assistant replies (text, reasoning, and tool calls) stream in live
9βˆ’over the server's SSE event stream.
10
6
11βˆ’<p align="center"><em>Connect β†’ Sessions β†’ Chat, with token-by-token streaming.</em></p>
7+## Set up a server and connect to it
12
8
13βˆ’## Architecture
9+This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
14βˆ’
10+need an opencode server running somewhere reachable from your device.
15βˆ’The app follows the Crux pattern: **all application logic lives in a pure,
16βˆ’platform-agnostic core; the platform is a thin shell** that renders the core's
17βˆ’view model, sends it events, and performs the side effects it requests.
18βˆ’
19βˆ’```
20βˆ’β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ shared/ (Rust) ───────────────────────────┐
21βˆ’β”‚ Crux core β€” the single source of truth β”‚
22βˆ’β”‚ β€’ Model / Event / Effect / ViewModel β”‚
23βˆ’β”‚ β€’ update(): connect+auth, sessions, chat, SSE stream application β”‚
24βˆ’β”‚ β€’ crux_http for HTTP effects; unit-tested with `cargo test` β”‚
25βˆ’β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
26βˆ’ β–² mirrored, behaviour-for-behaviour
27βˆ’β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ app/ (Kotlin) ────────────────────────────┐
28βˆ’β”‚ Jetpack Compose shell β”‚
29βˆ’β”‚ β€’ OpencodeCore β€” Kotlin port of the reducer (same ViewModel) β”‚
30βˆ’β”‚ β€’ HttpClient (OkHttp) + SseClient (okhttp-sse) β€” the effects β”‚
31βˆ’β”‚ β€’ Compose UI: Connect / Sessions / Chat β”‚
32βˆ’β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
33βˆ’```
34βˆ’
35βˆ’### Why a Kotlin port instead of a JNI `.so`?
36βˆ’
37βˆ’Crux normally compiles the Rust core to a native library and the shell talks to
38βˆ’it over an FFI bridge. That requires an Android cross-compilation toolchain
39βˆ’(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40βˆ’environment intentionally ships **without** an NDK, so instead:
41βˆ’
42βˆ’- The **Rust core is the canonical specification** of behaviour and is fully
43βˆ’ unit-tested (`cd shared && cargo test`).
44βˆ’- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45βˆ’ reducer, driving an identical `UiState`/`ViewModel`.
46βˆ’
47βˆ’The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48βˆ’can be swapped for the compiled `.so` behind the same seam. The crate already
49βˆ’builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50βˆ’type-generation.
51βˆ’
52βˆ’## Project layout
53βˆ’
54βˆ’```
55βˆ’flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56βˆ’shared/ Crux core (Rust)
57βˆ’ src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58βˆ’ src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59βˆ’ src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60βˆ’generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61βˆ’app/ Android app (Kotlin + Compose)
62βˆ’ src/main/java/soy/iko/opencode/
63βˆ’ core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64βˆ’ ui/ Compose screens (Connect/Sessions/Chat), theme, components
65βˆ’ MainActivity.kt, CoreViewModel.kt
66βˆ’```
67βˆ’
68βˆ’## Getting started
69
11
70βˆ’### Dev shell
12+### 1. Start the server
71
13
72βˆ’Everything you need is provided by the Nix flake:
14+On the host that has your project and opencode installed:
73
15
74βˆ’```sh
16+```bash
75βˆ’nix develop # or: direnv allow (an .envrc with `use flake` is included)
17+opencode serve --hostname 0.0.0.0 --port 4096
76```
18```
77
19
78βˆ’This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
20+Useful flags:
79βˆ’build-tools 35.0.0) on your `PATH`.
80
21
81βˆ’### Build & test
22+| Flag | Default | Description |
23+| --- | --- | --- |
24+| `--port` | `4096` | Port to listen on |
25+| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
26+| `--mdns` | `false` | Enable mDNS discovery |
27+| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
82
28
83βˆ’```sh
29+The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
84βˆ’# Rust core β€” logic + streaming unit tests
85βˆ’cd shared && cargo test
86βˆ’
87βˆ’# Android app β€” debug APK
88βˆ’./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89βˆ’
90βˆ’# Android unit tests + lint
91βˆ’./gradlew testDebugUnitTest lintDebug
92βˆ’```
93
30
94βˆ’### Run against a server
31+### 2. (Optional) Add authentication
95
32
96βˆ’Start opencode's server on your machine or LAN:
33+opencode has no auth by default. To protect the server with HTTP Basic auth:
97
34
98βˆ’```sh
35+```bash
99βˆ’opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
36+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
100```
37```
101
38
102βˆ’Install the APK, launch the app, and enter the server URL (e.g.
39+The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
103βˆ’`http://192.168.1.10:4096`). If the server was started with a password the app
40+app's add-server dialog.
104βˆ’detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105
41
106βˆ’> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
42+### 3. Connect from the app
107
43
108βˆ’## The opencode API this client speaks
44+**Over Wi-Fi / LAN** β€” add a server with base URL `http://<host-LAN-ip>:4096`.
109
45
110βˆ’- **Connect / auth** β€” `GET /global/health` probes reachability; a `401` triggers
46+**Over USB only** β€” forward the port to the device and point the app at localhost:
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
47
123βˆ’## Continuous integration & releases
48+```bash
124βˆ’
49+adb reverse tcp:4096 tcp:4096
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βˆ’ ```
50+```
135
51
136βˆ’ The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
52+Then use base URL `http://127.0.0.1:4096` in the app.
137βˆ’ timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138
53
139βˆ’ **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
54+> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
140βˆ’ keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
55+> a reverse proxy, use an `https://` base URL instead.
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
56
144βˆ’## Notes & limitations
57+## Architecture
145
58
146βˆ’- Rendering covers text, reasoning, and tool-call parts. Interactive tool
59+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
147βˆ’ **permission** prompts (`permission.updated`) are not yet surfaced.
60+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
148βˆ’- Model/agent selection uses the server's configured default (no picker yet).
61+ (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
149βˆ’- No `.so` is built in this environment (see *Architecture* above).
62+ discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
63+ can't break decoding.
64+- **Manual DI** (`AppContainer` on the `Application`) β€” no Hilt/KSP.
65+- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
66+ `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
67+ Compose collects with `collectAsStateWithLifecycle`.
68+- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
69+
70+Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
71+(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
72+
73+## Build
74+
75+```bash
76+nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
77+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
78+./gradlew assembleRelease # R8-minified + resource-shrunk release APK
79+./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
80+./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
81+./gradlew lintDebug # Android Lint
82+./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
83+```
150
84
151βˆ’## License
85+## Install & run
152
86
153βˆ’Apache-2.0.
87+```bash
88+adb install -r app/build/outputs/apk/debug/app-debug.apk
89+adb shell am start -n soy.iko.opencode/.MainActivity
90+adb logcat --pid=$(adb shell pidof soy.iko.opencode)
91+```
app/build.gradle.kts+95βˆ’44
@@ -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.compose)
5 alias(libs.plugins.kotlin.serialization)
4 alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.compose)
6+ alias(libs.plugins.detekt)
6}
7}
7
8
8βˆ’// Version is overridable from the release workflow via
9+/**
9βˆ’// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10βˆ’val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11βˆ’val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
12+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12βˆ’
13+ */
13βˆ’// A release keystore is supplied by CI (decoded from a secret) via env vars.
14+val gitCommitCount: Int = runCatching {
14βˆ’// When it's absent β€” local builds, or CI before signing is configured β€” the
15+ providers.exec {
15βˆ’// release build falls back to the debug signing config so the APK is still
16+ commandLine("git", "rev-list", "--count", "HEAD")
16βˆ’// installable, rather than producing an uninstallable unsigned APK.
17+ }.standardOutput.asText.get().trim().toInt()
17βˆ’val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+}.getOrDefault(1)
18βˆ’val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19
19
20android {
20android {
21 namespace = "soy.iko.opencode"
21 namespace = "soy.iko.opencode"
@@ -25,35 +25,37 @@ android {
25 applicationId = "soy.iko.opencode"
25 applicationId = "soy.iko.opencode"
26 minSdk = 26
26 minSdk = 26
27 targetSdk = 35
27 targetSdk = 35
28βˆ’ versionCode = appVersionCode
28+ // Monotonically increasing across builds: derived from the git commit count so
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
29βˆ’ versionName = appVersionName
33+ versionName = "0.1.0"
30βˆ’
31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 }
35 }
33
36
34βˆ’ signingConfigs {
37+ // Release builds are signed when keystore credentials are supplied via env vars
35βˆ’ if (hasReleaseKeystore) {
38+ // (so CI can publish an installable APK). When the env vars are absent, the
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 ->
36βˆ’ create("release") {
42+ signingConfigs.create("release") {
37βˆ’ storeFile = file(releaseStorePath!!)
43+ storeFile = file(path)
38βˆ’ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
44+ storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
39βˆ’ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
45+ keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
40βˆ’ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
46+ keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
41βˆ’ }
42 }
47 }
43 }
48 }
44
49
45 buildTypes {
50 buildTypes {
46 release {
51 release {
47 isMinifyEnabled = true
52 isMinifyEnabled = true
53+ isShrinkResources = true
48 proguardFiles(
54 proguardFiles(
49 getDefaultProguardFile("proguard-android-optimize.txt"),
55 getDefaultProguardFile("proguard-android-optimize.txt"),
50 "proguard-rules.pro",
56 "proguard-rules.pro",
51 )
57 )
52βˆ’ signingConfig = if (hasReleaseKeystore) {
58+ releaseSigning?.let { signingConfig = it }
53βˆ’ signingConfigs.getByName("release")
54βˆ’ } else {
55βˆ’ signingConfigs.getByName("debug")
56βˆ’ }
57 }
59 }
58 }
60 }
59
61
@@ -62,42 +64,91 @@ android {
62 targetCompatibility = JavaVersion.VERSION_17
64 targetCompatibility = JavaVersion.VERSION_17
63 }
65 }
64
66
65βˆ’ kotlinOptions {
67+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
66βˆ’ jvmTarget = "17"
68+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
69+ kotlin {
70+ compilerOptions {
71+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72+ }
67 }
73 }
68
74
69 buildFeatures {
75 buildFeatures {
70 compose = true
76 compose = true
71 }
77 }
72
78
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+ // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
85+ // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
86+ // match-index scan), so it's disabled rather than worked around at each call site.
87+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
88+ }
89+
73 packaging {
90 packaging {
74 resources {
91 resources {
75 excludes += "/META-INF/{AL2.0,LGPL2.1}"
92 excludes += "/META-INF/{AL2.0,LGPL2.1}"
76 }
93 }
77 }
94 }
95+
96+ testOptions {
97+ unitTests {
98+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
99+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
100+ // against Android APIs don't need Robolectric.
101+ isReturnDefaultValues = true
102+ isIncludeAndroidResources = true
103+ }
104+ }
78}
105}
79
106
80dependencies {
107dependencies {
81 implementation(libs.androidx.core.ktx)
108 implementation(libs.androidx.core.ktx)
82βˆ’ implementation(libs.androidx.lifecycle.runtime.ktx)
109+ implementation(libs.androidx.core.splashscreen)
83βˆ’ implementation(libs.androidx.lifecycle.runtime.compose)
84βˆ’ implementation(libs.androidx.lifecycle.viewmodel.ktx)
85βˆ’ implementation(libs.androidx.lifecycle.viewmodel.compose)
86 implementation(libs.androidx.activity.compose)
110 implementation(libs.androidx.activity.compose)
111+ implementation(libs.androidx.lifecycle.viewmodel.compose)
112+ implementation(libs.androidx.lifecycle.runtime.compose)
113+ implementation(libs.androidx.navigation.compose)
87
114
88βˆ’ implementation(platform(libs.androidx.compose.bom))
115+ implementation(platform(libs.compose.bom))
89βˆ’ implementation(libs.androidx.compose.ui)
116+ implementation(libs.compose.ui)
90βˆ’ implementation(libs.androidx.compose.ui.graphics)
117+ implementation(libs.compose.ui.graphics)
91βˆ’ implementation(libs.androidx.compose.ui.tooling.preview)
118+ implementation(libs.compose.ui.tooling.preview)
92βˆ’ implementation(libs.androidx.compose.material3)
119+ implementation(libs.compose.material3)
93βˆ’ implementation(libs.androidx.compose.material.icons.extended)
120+ implementation(libs.compose.material.icons.extended)
94βˆ’ debugImplementation(libs.androidx.compose.ui.tooling)
121+ debugImplementation(libs.compose.ui.tooling)
122+
123+ implementation(libs.ktor.client.core)
124+ implementation(libs.ktor.client.okhttp)
125+ implementation(libs.ktor.client.content.negotiation)
126+ implementation(libs.ktor.serialization.kotlinx.json)
127+ implementation(libs.ktor.client.auth)
95
128
96βˆ’ implementation(libs.kotlinx.coroutines.android)
97 implementation(libs.kotlinx.serialization.json)
129 implementation(libs.kotlinx.serialization.json)
98βˆ’ implementation(libs.okhttp)
130+ implementation(libs.kotlinx.coroutines.android)
131+
99βˆ’ implementation(libs.okhttp.sse)
132+ implementation(libs.androidx.datastore.preferences)
133+ implementation(libs.androidx.security.crypto)
134+ implementation(libs.coil.compose)
135+ implementation(libs.markdown.renderer)
136+ implementation(libs.markdown.renderer.m3)
100
137
101 testImplementation(libs.junit)
138 testImplementation(libs.junit)
102 testImplementation(libs.kotlinx.coroutines.test)
139 testImplementation(libs.kotlinx.coroutines.test)
140+ testImplementation(libs.ktor.client.mock)
141+
142+ androidTestImplementation(libs.androidx.test.ext.junit)
143+ androidTestImplementation(libs.androidx.test.runner)
144+ androidTestImplementation(libs.androidx.test.core)
145+ androidTestImplementation(libs.kotlinx.coroutines.test)
146+}
147+
148+detekt {
149+ buildUponDefaultConfig = true
150+ // Carry forward the current baseline so CI only fails on *new* findings;
151+ // existing issues are grandfathered rather than blocking the migration.
152+ baseline = file("detekt-baseline.xml")
153+ config.setFrom("$rootDir/config/detekt/detekt.yml")
103}
154}
app/detekt-baseline.xmlnew file+16
@@ -0,0 +1,16 @@
1+<?xml version="1.0" ?>
2+<SmellBaseline>
3+ <ManuallySuppressedIssues/>
4+ <CurrentIssues>
5+ <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6+ <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
7+ <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
8+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</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:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
13+ <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
14+ <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
15+ </CurrentIssues>
16+</SmellBaseline>
app/proguard-rules.pro+44βˆ’13
@@ -1,16 +1,47 @@
1βˆ’# kotlinx.serialization β€” keep generated serializers and @Serializable metadata.
1+# --- kotlinx.serialization ---
2+# The plugin generates serializers; keep them and the $$serializer companion members.
2βˆ’-keepattributes *Annotation*, InnerClasses
3+-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
3-dontnote kotlinx.serialization.**
4-dontnote kotlinx.serialization.**
4βˆ’
5βˆ’-keepclassmembers class **$$serializer { *; }
5+-keepclassmembers class soy.iko.opencode.** {
6+ *** Companion;
7+}
6βˆ’-keepclasseswithmembers class * {
8+-keepclasseswithmembers class soy.iko.opencode.** {
7βˆ’ @kotlinx.serialization.Serializable <methods>;
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;
8}
15}
16+# Keep the serializable model classes (reflection-free serializer lookup needs the class).
9βˆ’-if @kotlinx.serialization.Serializable class **
17+-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
10βˆ’-keep, allowobfuscation, allowshrinking class <1>
18+
19+# --- Ktor / OkHttp (HTTP client + SSE) ---
20+# Don't warn about missing optional dependencies; keep only the classes that are
21+# accessed via reflection (Ktor engine registration, OkHttp internals that R8
22+# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
23+# so R8 can still eliminate dead code and shrink the APK.
24+-dontwarn io.ktor.**
25+-dontwarn okhttp3.**
26+-dontwarn okio.**
27+-dontwarn org.slf4j.**
28+-dontwarn org.jetbrains.annotations.**
29+# Ktor loads engines and plugins via ServiceLoader β€” keep the service files.
30+-keep class io.ktor.client.engine.** { *; }
31+-keep class io.ktor.client.plugins.** { *; }
32+-keep class io.ktor.serialization.** { *; }
33+-keep class io.ktor.http.** { *; }
34+# OkHttp uses reflection for platform detection and internal classes.
35+-keep class okhttp3.internal.platform.** { *; }
36+-keep class okhttp3.** { *; }
37+-keep class okio.** { *; }
38+
39+# --- Coil (image loading) ---
40+# Coil uses reflection for loadable decoders and the Compose integration.
41+-keep class coil.** { *; }
42+-keep class coil3.** { *; }
43+-dontwarn coil.**
11
44
12βˆ’# OkHttp / okio β€” platform-specific bits referenced reflectively.
45+# --- androidx.security.crypto pulls in Tink, which references compile-only
13βˆ’-dontwarn okhttp3.internal.platform.**
46+# com.google.errorprone annotations. They aren't on the runtime classpath.
14βˆ’-dontwarn org.conscrypt.**
47+-dontwarn com.google.errorprone.annotations.**
15βˆ’-dontwarn org.bouncycastle.**
16βˆ’-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktnew file+36
@@ -0,0 +1,36 @@
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.ktnew file+67
@@ -0,0 +1,67 @@
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.ktnew file+49
@@ -0,0 +1,49 @@
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+35βˆ’7
@@ -1,26 +1,54 @@
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">
3
4
4 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 <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" />
6
10
7 <application
11 <application
12+ android:name=".OpencodeApp"
8βˆ’ android:allowBackup="true"
13+ android:allowBackup="false"
9βˆ’ android:icon="@android:drawable/sym_def_app_icon"
14+ android:dataExtractionRules="@xml/data_extraction_rules"
15+ android:fullBackupContent="false"
16+ android:icon="@mipmap/ic_launcher"
10 android:label="@string/app_name"
17 android:label="@string/app_name"
18+ android:networkSecurityConfig="@xml/network_security_config"
11 android:supportsRtl="true"
19 android:supportsRtl="true"
12βˆ’ android:theme="@style/Theme.OpencodeAndroid"
20+ android:theme="@style/Theme.OpencodeAndroid">
13βˆ’ android:usesCleartextTraffic="true">
14
21
15 <activity
22 <activity
16 android:name=".MainActivity"
23 android:name=".MainActivity"
17 android:exported="true"
24 android:exported="true"
18βˆ’ android:windowSoftInputMode="adjustResize"
25+ android:launchMode="singleTop"
26+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
27+ android:enableOnBackInvokedCallback="true"
28+ tools:targetApi="33"
19βˆ’ android:theme="@style/Theme.OpencodeAndroid">
29+ android:theme="@style/Theme.OpencodeAndroid.Splash">
20 <intent-filter>
30 <intent-filter>
21 <action android:name="android.intent.action.MAIN" />
31 <action android:name="android.intent.action.MAIN" />
22 <category android:name="android.intent.category.LAUNCHER" />
32 <category android:name="android.intent.category.LAUNCHER" />
23 </intent-filter>
33 </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>
24 </activity>
46 </activity>
47+
48+ <service
49+ android:name=".notification.RunForegroundService"
50+ android:exported="false"
51+ android:foregroundServiceType="dataSync" />
25 </application>
52 </application>
53+
26</manifest>
54</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktdeletedβˆ’66
@@ -1,66 +0,0 @@
1βˆ’package soy.iko.opencode
2βˆ’
3βˆ’import android.app.Application
4βˆ’import android.content.Context
5βˆ’import androidx.core.content.edit
6βˆ’import androidx.lifecycle.AndroidViewModel
7βˆ’import androidx.lifecycle.viewModelScope
8βˆ’import kotlinx.coroutines.flow.distinctUntilChangedBy
9βˆ’import kotlinx.coroutines.launch
10βˆ’import soy.iko.opencode.core.Event
11βˆ’import soy.iko.opencode.core.OpencodeCore
12βˆ’
13βˆ’/**
14βˆ’ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15βˆ’ * connection) survives configuration changes. It also persists the last server
16βˆ’ * URL / username so reconnecting is one tap.
17βˆ’ */
18βˆ’class CoreViewModel(app: Application) : AndroidViewModel(app) {
19βˆ’ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20βˆ’
21βˆ’ val core = OpencodeCore(viewModelScope)
22βˆ’
23βˆ’ init {
24βˆ’ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25βˆ’ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26βˆ’ // The password is stored in plain text in SharedPreferences β€” a deliberate
27βˆ’ // convenience trade-off for a local dev tool so reconnecting is one tap.
28βˆ’ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29βˆ’
30βˆ’ // On a fresh process after a configuration change, resume the SSE stream if
31βˆ’ // the core is already connected (no-op otherwise β€” it guards on `connected`).
32βˆ’ core.resumeStreamingIfConnected()
33βˆ’
34βˆ’ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35βˆ’ // aren't already connected, kick off the connection attempt on launch. The core
36βˆ’ // handles an empty/invalid URL, so this only fires when there's something to try.
37βˆ’ core.view.value.let { restored ->
38βˆ’ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39βˆ’ core.dispatch(Event.Connect)
40βˆ’ }
41βˆ’ }
42βˆ’
43βˆ’ // Persist connection details once a connection succeeds.
44βˆ’ viewModelScope.launch {
45βˆ’ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46βˆ’ if (state.connected) {
47βˆ’ prefs.edit {
48βˆ’ putString(KEY_URL, state.serverUrl)
49βˆ’ putString(KEY_USER, state.username)
50βˆ’ putString(KEY_PASSWORD, state.password)
51βˆ’ }
52βˆ’ }
53βˆ’ }
54βˆ’ }
55βˆ’ }
56βˆ’
57βˆ’ override fun onCleared() {
58βˆ’ core.shutdown()
59βˆ’ }
60βˆ’
61βˆ’ private companion object {
62βˆ’ const val KEY_URL = "serverUrl"
63βˆ’ const val KEY_USER = "username"
64βˆ’ const val KEY_PASSWORD = "password"
65βˆ’ }
66βˆ’}
app/src/main/java/soy/iko/opencode/MainActivity.kt+115βˆ’13
@@ -1,31 +1,133 @@
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
3import android.os.Bundle
7import android.os.Bundle
4import androidx.activity.ComponentActivity
8import androidx.activity.ComponentActivity
5βˆ’import androidx.activity.SystemBarStyle
6import androidx.activity.compose.setContent
9import androidx.activity.compose.setContent
7import androidx.activity.enableEdgeToEdge
10import androidx.activity.enableEdgeToEdge
11+import androidx.activity.result.contract.ActivityResultContracts
12+import androidx.compose.foundation.isSystemInDarkTheme
13+import androidx.compose.runtime.LaunchedEffect
8import androidx.compose.runtime.getValue
14import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.remember
16+import kotlinx.coroutines.flow.combine
17+import androidx.core.content.ContextCompat
18+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
9import androidx.lifecycle.compose.collectAsStateWithLifecycle
19import androidx.lifecycle.compose.collectAsStateWithLifecycle
10βˆ’import androidx.lifecycle.viewmodel.compose.viewModel
20+import soy.iko.opencode.data.repo.ThemeMode
11βˆ’import soy.iko.opencode.ui.OpencodeApp
21+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
12import soy.iko.opencode.ui.theme.OpencodeTheme
22import soy.iko.opencode.ui.theme.OpencodeTheme
13
23
14class MainActivity : ComponentActivity() {
24class MainActivity : ComponentActivity() {
25+
26+ private val requestNotificationPermission = registerForActivityResult(
27+ ActivityResultContracts.RequestPermission(),
28+ ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
29+
30+ private var shareIntentHandled = false
31+ private var openSessionHandled = false
32+
15 override fun onCreate(savedInstanceState: Bundle?) {
33 override fun onCreate(savedInstanceState: Bundle?) {
16βˆ’ enableEdgeToEdge(
34+ val splash = installSplashScreen()
17βˆ’ statusBarStyle = SystemBarStyle.auto(
18βˆ’ lightScrim = 0xFF0F1115.toInt(),
19βˆ’ darkScrim = 0xFF0F1115.toInt(),
20βˆ’ ),
21βˆ’ )
22 super.onCreate(savedInstanceState)
35 super.onCreate(savedInstanceState)
36+ enableEdgeToEdge()
37+ savedInstanceState?.let {
38+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
40+ }
41+ val container = (application as OpencodeApp).container
42+ handleIntent(intent)
43+ if (savedInstanceState == null) maybeRequestNotificationPermission()
44+ // Hold the splash until the persisted theme settings load, so we never paint a
45+ // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
46+ // user's real choice β€” the flash the SettingsScreen's null-gate already avoids.
47+ var themeLoaded = false
48+ splash.setKeepOnScreenCondition { !themeLoaded }
23 setContent {
49 setContent {
24βˆ’ OpencodeTheme {
50+ val theme by remember(container) {
25βˆ’ val vm: CoreViewModel = viewModel()
51+ container.settingsStore.themeMode
52+ .combine(container.settingsStore.dynamicColor, ::Pair)
26βˆ’ val state by vm.core.view.collectAsStateWithLifecycle()
53+ }.collectAsStateWithLifecycle(initialValue = null)
27βˆ’ OpencodeApp(state = state, dispatch = vm.core::dispatch)
54+ LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
55+ theme?.let { (themeMode, dynamicColor) ->
56+ val dark = when (themeMode) {
57+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
58+ ThemeMode.LIGHT -> false
59+ ThemeMode.DARK -> true
60+ }
61+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
62+ OpencodeAppUi(container = container)
63+ }
64+ }
65+ }
66+ }
67+
68+ override fun onNewIntent(intent: Intent) {
69+ super.onNewIntent(intent)
70+ setIntent(intent)
71+ shareIntentHandled = false
72+ openSessionHandled = false
73+ handleIntent(intent)
74+ }
75+
76+ /** Capture text shared from another app so it can be prefilled into a session draft,
77+ * and session ids from notification taps / deep links so we can open them. */
78+ private fun handleIntent(intent: Intent?) {
79+ val container = (application as OpencodeApp).container
80+ if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
81+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82+ ?.takeIf { it.isNotBlank() }
83+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
84+ if (text != null) {
85+ container.setPendingShare(text)
86+ shareIntentHandled = true
28 }
87 }
29 }
88 }
89+ // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
90+ val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
91+ ?.data
92+ ?.takeIf { it.host == "session" }
93+ ?.lastPathSegment
94+ val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
95+ // Validate: session ids are opaque server-generated identifiers. Reject anything
96+ // with path separators or other traversal/control characters so a malicious deep
97+ // link can't inject path components into the REST URL path.
98+ // Guard against re-firing the retained ACTION_VIEW intent after process-death
99+ // restore: onCreate calls handleIntent again with the original intent, so without
100+ // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
101+ if (!openSessionHandled) {
102+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103+ container.requestOpenSession(it)
104+ openSessionHandled = true
105+ }
106+ }
107+ }
108+
109+ companion object {
110+ /** Intent extra carrying a session id to open (notifications / deep links). */
111+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
112+
113+ /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
114+ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
115+
116+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
118+ }
119+
120+ override fun onSaveInstanceState(outState: Bundle) {
121+ super.onSaveInstanceState(outState)
122+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
124+ }
125+
126+ /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
127+ private fun maybeRequestNotificationPermission() {
128+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
129+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
130+ PackageManager.PERMISSION_GRANTED
131+ if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
30 }
132 }
31}
133}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import coil.ImageLoader
5+import coil.ImageLoaderFactory
6+import coil.disk.DiskCache
7+import coil.memory.MemoryCache
8+import soy.iko.opencode.data.repo.CrashLogger
9+import soy.iko.opencode.di.AppContainer
10+import java.util.concurrent.atomic.AtomicBoolean
11+
12+class OpencodeApp : Application(), ImageLoaderFactory {
13+ lateinit var container: AppContainer
14+ private set
15+
16+ private val shutdownCalled = AtomicBoolean(false)
17+
18+ override fun onCreate() {
19+ super.onCreate()
20+ CrashLogger.get(this).install()
21+ container = AppContainer(this)
22+ // onTerminate() is only called in emulated process environments, not on real
23+ // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
24+ // scope cancellation, connection close) runs when the process is actually killed.
25+ Runtime.getRuntime().addShutdownHook(Thread {
26+ shutdown()
27+ })
28+ }
29+
30+ // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
31+ // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
32+ // the (auth-protected) network every time. Data-URI (base64) images skip the disk
33+ // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
34+ override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
35+ .memoryCache {
36+ MemoryCache.Builder(this)
37+ .maxSizePercent(0.25)
38+ .build()
39+ }
40+ .diskCache {
41+ DiskCache.Builder()
42+ .directory(cacheDir.resolve("image_cache"))
43+ .maxSizeBytes(50L * 1024 * 1024)
44+ .build()
45+ }
46+ .build()
47+
48+ override fun onTerminate() {
49+ super.onTerminate()
50+ shutdown()
51+ }
52+
53+ private fun shutdown() {
54+ // Guard against double shutdown β€” onTerminate and the shutdown hook can both
55+ // fire in emulated environments.
56+ if (!shutdownCalled.compareAndSet(false, true)) return
57+ container.shutdown()
58+ }
59+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktdeletedβˆ’75
@@ -1,75 +0,0 @@
1βˆ’package soy.iko.opencode.core
2βˆ’
3βˆ’import kotlinx.coroutines.Dispatchers
4βˆ’import kotlinx.coroutines.suspendCancellableCoroutine
5βˆ’import kotlinx.coroutines.withContext
6βˆ’import okhttp3.Call
7βˆ’import okhttp3.Callback
8βˆ’import okhttp3.MediaType.Companion.toMediaType
9βˆ’import okhttp3.OkHttpClient
10βˆ’import okhttp3.Request
11βˆ’import okhttp3.RequestBody.Companion.toRequestBody
12βˆ’import okhttp3.Response
13βˆ’import java.io.IOException
14βˆ’import java.util.concurrent.TimeUnit
15βˆ’import kotlin.coroutines.resume
16βˆ’import kotlin.coroutines.resumeWithException
17βˆ’
18βˆ’/** A completed HTTP response (any status code β€” non-2xx is not an error here). */
19βˆ’data class HttpResp(val code: Int, val body: String)
20βˆ’
21βˆ’/**
22βˆ’ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23βˆ’ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24βˆ’ * carrying its status code, matching the Rust core's `HttpResult` handling.
25βˆ’ */
26βˆ’class HttpClient(
27βˆ’ val okhttp: OkHttpClient = OkHttpClient.Builder()
28βˆ’ .connectTimeout(15, TimeUnit.SECONDS)
29βˆ’ .readTimeout(30, TimeUnit.SECONDS)
30βˆ’ .build(),
31βˆ’) {
32βˆ’ private val jsonMedia = "application/json".toMediaType()
33βˆ’
34βˆ’ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35βˆ’ exec(url, auth) { get() }
36βˆ’
37βˆ’ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38βˆ’ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39βˆ’
40βˆ’ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41βˆ’ exec(url, auth) { delete() }
42βˆ’
43βˆ’ // Build and run the request off the main thread. Building it β€” parsing [url]
44βˆ’ // in particular β€” can throw (e.g. a URL with no scheme), so that happens
45βˆ’ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46βˆ’ // instead of crashing the caller's coroutine.
47βˆ’ private suspend fun exec(
48βˆ’ url: String,
49βˆ’ auth: String?,
50βˆ’ method: Request.Builder.() -> Request.Builder,
51βˆ’ ): Result<HttpResp> = withContext(Dispatchers.IO) {
52βˆ’ runCatching {
53βˆ’ val builder = Request.Builder().url(url).method()
54βˆ’ builder.header("User-Agent", "opencode-android/1.0")
55βˆ’ if (auth != null) builder.header("Authorization", auth)
56βˆ’ await(okhttp.newCall(builder.build()))
57βˆ’ }
58βˆ’ }
59βˆ’
60βˆ’ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61βˆ’ cont.invokeOnCancellation { runCatching { call.cancel() } }
62βˆ’ call.enqueue(object : Callback {
63βˆ’ override fun onFailure(call: Call, e: IOException) {
64βˆ’ if (cont.isActive) cont.resumeWithException(e)
65βˆ’ }
66βˆ’
67βˆ’ override fun onResponse(call: Call, response: Response) {
68βˆ’ response.use {
69βˆ’ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70βˆ’ if (cont.isActive) cont.resume(HttpResp(it.code, body))
71βˆ’ }
72βˆ’ }
73βˆ’ })
74βˆ’ }
75βˆ’}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktdeletedβˆ’668
@@ -1,668 +0,0 @@
1βˆ’package soy.iko.opencode.core
2βˆ’
3βˆ’import kotlinx.coroutines.CoroutineScope
4βˆ’import kotlinx.coroutines.Job
5βˆ’import kotlinx.coroutines.delay
6βˆ’import kotlinx.coroutines.flow.MutableStateFlow
7βˆ’import kotlinx.coroutines.flow.StateFlow
8βˆ’import kotlinx.coroutines.flow.asStateFlow
9βˆ’import kotlinx.coroutines.launch
10βˆ’import java.util.Base64
11βˆ’
12βˆ’/**
13βˆ’ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14βˆ’ *
15βˆ’ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16βˆ’ * observable [view]. Effects are executed here in the shell: REST calls through
17βˆ’ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18βˆ’ * server-sent event is fed back in and applied incrementally, so assistant
19βˆ’ * replies stream in token-by-token.
20βˆ’ *
21βˆ’ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22βˆ’ * network I/O hops to a background thread inside the clients.
23βˆ’ */
24βˆ’class OpencodeCore(
25βˆ’ private val scope: CoroutineScope,
26βˆ’ private val http: HttpClient = HttpClient(),
27βˆ’) {
28βˆ’ // ── Model ────────────────────────────────────────────────────────────────
29βˆ’ private var serverUrl = ""
30βˆ’ private var username = ""
31βˆ’ private var password = ""
32βˆ’ private var authRequired = false
33βˆ’ private var connected = false
34βˆ’ private var loading = false
35βˆ’ private var error: String? = null
36βˆ’ private var screen = Screen.Connect
37βˆ’ private val sessions = mutableListOf<SessionView>()
38βˆ’ private var currentSessionId: String? = null
39βˆ’ private var currentSessionTitle = ""
40βˆ’ private val messages = mutableListOf<MsgState>()
41βˆ’ private var draft = ""
42βˆ’ private var generating = false
43βˆ’ private var sseConnected = true
44βˆ’ private var localSeq = 0L
45βˆ’
46βˆ’ private val _view = MutableStateFlow(snapshot())
47βˆ’ val view: StateFlow<UiState> = _view.asStateFlow()
48βˆ’
49βˆ’ private var sse: SseClient? = null
50βˆ’ private var sseClosedIntentionally = false
51βˆ’ private var reconnectAttempts = 0
52βˆ’ private var reconnectJob: Job? = null
53βˆ’
54βˆ’ // Guards against overlapping session refreshes: rapid taps launch multiple
55βˆ’ // loadSessions coroutines and the slowest-to-return would otherwise win.
56βˆ’ private var loadSessionsJob: Job? = null
57βˆ’ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58βˆ’ // before it dispatches SelectSession into a screen the user already left.
59βˆ’ private var createSessionJob: Job? = null
60βˆ’ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61βˆ’ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62βˆ’ private var loadMessagesJob: Job? = null
63βˆ’ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64βˆ’ // otherwise launch two concurrent health probes; cancel the prior one first.
65βˆ’ private var connectJob: Job? = null
66βˆ’ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67βˆ’ // before its response handler mutates the sessions list / screen the user already left.
68βˆ’ private var deleteSessionJob: Job? = null
69βˆ’ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70βˆ’ // can cancel it before its response handler touches stale message state.
71βˆ’ private var sendMessageJob: Job? = null
72βˆ’ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73βˆ’ // fire multiple abort requests, and navigating away can cancel it.
74βˆ’ private var abortJob: Job? = null
75βˆ’ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76βˆ’ private val removedMessageIds = mutableSetOf<String>()
77βˆ’
78βˆ’ // ── Event entry point ──────────────────────────────────────────────────────
79βˆ’ fun dispatch(event: Event) {
80βˆ’ when (event) {
81βˆ’ // Store the field verbatim while typing; normalizing here would fight
82βˆ’ // the user's edits (e.g. eat the "://" as they type it). The URL is
83βˆ’ // normalized once, on Connect.
84βˆ’ // Clear any prior error as soon as the user edits a field, so a stale
85βˆ’ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86βˆ’ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87βˆ’ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88βˆ’ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89βˆ’
90βˆ’ Event.Connect -> {
91βˆ’ serverUrl = normalizeUrl(serverUrl)
92βˆ’ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93βˆ’ // The auth fields are showing but empty β€” sending the request now
94βˆ’ // would just 401 again in a loop. Prompt for credentials instead.
95βˆ’ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96βˆ’ // Leave authRequired as-is; probeHealth sets it from the response (true on
97βˆ’ // 401, false on success). Resetting it here just flickers the auth fields.
98βˆ’ loading = true; error = null; emit()
99βˆ’ connectJob?.cancel()
100βˆ’ connectJob = scope.launch { probeHealth() }
101βˆ’ }
102βˆ’
103βˆ’ Event.LoadSessions -> {
104βˆ’ if (!connected) { error = "Not connected"; emit(); return }
105βˆ’ loading = true; emit()
106βˆ’ loadSessionsJob?.cancel()
107βˆ’ loadSessionsJob = scope.launch { loadSessions() }
108βˆ’ }
109βˆ’
110βˆ’ is Event.SelectSession -> {
111βˆ’ // The session may have been deleted (e.g. via SSE) while the list was on
112βˆ’ // screen; opening it would only 404 on loadMessages. Bail with an error.
113βˆ’ val session = sessions.firstOrNull { it.id == event.id }
114βˆ’ if (session == null) { error = "Session no longer exists"; emit(); return }
115βˆ’ currentSessionTitle = session.title
116βˆ’ currentSessionId = event.id
117βˆ’ messages.clear()
118βˆ’ removedMessageIds.clear()
119βˆ’ screen = Screen.Chat
120βˆ’ loading = true
121βˆ’ generating = false
122βˆ’ error = null
123βˆ’ emit()
124βˆ’ connectJob?.cancel()
125βˆ’ abortJob?.cancel()
126βˆ’ sendMessageJob?.cancel()
127βˆ’ deleteSessionJob?.cancel()
128βˆ’ loadMessagesJob?.cancel()
129βˆ’ loadMessagesJob = scope.launch { loadMessages(event.id) }
130βˆ’ }
131βˆ’
132βˆ’ Event.CreateSession -> {
133βˆ’ if (!connected) { error = "Not connected"; emit(); return }
134βˆ’ loading = true; error = null; emit()
135βˆ’ createSessionJob?.cancel()
136βˆ’ createSessionJob = scope.launch { createSession() }
137βˆ’ }
138βˆ’
139βˆ’ is Event.DeleteSession -> {
140βˆ’ if (!connected) { error = "Not connected"; emit(); return }
141βˆ’ val id = event.id
142βˆ’ // Delete on the server first; only drop it from the UI once that
143βˆ’ // succeeds, so a failed request leaves the session recoverable.
144βˆ’ loading = true; error = null; emit()
145βˆ’ deleteSessionJob?.cancel()
146βˆ’ deleteSessionJob = scope.launch {
147βˆ’ http.delete("$serverUrl/session/$id", authHeader()).fold(
148βˆ’ onSuccess = { resp ->
149βˆ’ if (resp.code in 200..299) {
150βˆ’ sessions.removeAll { it.id == id }
151βˆ’ if (isCurrent(id)) {
152βˆ’ currentSessionId = null
153βˆ’ messages.clear()
154βˆ’ screen = Screen.Sessions
155βˆ’ }
156βˆ’ } else {
157βˆ’ error = "Failed to delete session"
158βˆ’ }
159βˆ’ loading = false
160βˆ’ },
161βˆ’ onFailure = {
162βˆ’ error = "Failed to delete session"
163βˆ’ loading = false
164βˆ’ },
165βˆ’ )
166βˆ’ emit()
167βˆ’ }
168βˆ’ }
169βˆ’
170βˆ’ is Event.DraftChanged -> { draft = event.text; emit() }
171βˆ’
172βˆ’ is Event.SendMessage -> sendMessage(event.text)
173βˆ’
174βˆ’ Event.CancelGeneration -> {
175βˆ’ generating = false
176βˆ’ messages.forEach { it.streaming = false }
177βˆ’ emit()
178βˆ’ val id = currentSessionId ?: return
179βˆ’ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180βˆ’ // another so we don't stack duplicate abort requests.
181βˆ’ abortJob?.cancel()
182βˆ’ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183βˆ’ }
184βˆ’
185βˆ’ Event.NavigateToSessions -> {
186βˆ’ screen = Screen.Sessions
187βˆ’ currentSessionId = null
188βˆ’ messages.clear()
189βˆ’ removedMessageIds.clear()
190βˆ’ draft = ""
191βˆ’ generating = false
192βˆ’ // Clear any error from the chat (e.g. a failed send) so it doesn't
193βˆ’ // linger on the sessions list after the user navigates back.
194βˆ’ error = null
195βˆ’ loading = true
196βˆ’ emit()
197βˆ’ connectJob?.cancel()
198βˆ’ abortJob?.cancel()
199βˆ’ createSessionJob?.cancel()
200βˆ’ deleteSessionJob?.cancel()
201βˆ’ sendMessageJob?.cancel()
202βˆ’ loadMessagesJob?.cancel()
203βˆ’ loadSessionsJob?.cancel()
204βˆ’ loadSessionsJob = scope.launch { loadSessions() }
205βˆ’ }
206βˆ’
207βˆ’ Event.NavigateToConnect -> {
208βˆ’ screen = Screen.Connect
209βˆ’ connected = false
210βˆ’ loading = false
211βˆ’ generating = false
212βˆ’ sessions.clear()
213βˆ’ messages.clear()
214βˆ’ removedMessageIds.clear()
215βˆ’ currentSessionId = null
216βˆ’ draft = ""
217βˆ’ // Reset to the default so a late-firing close callback from the old
218βˆ’ // SSE connection can't flash the reconnecting banner on the next connect.
219βˆ’ sseConnected = true
220βˆ’ connectJob?.cancel()
221βˆ’ abortJob?.cancel()
222βˆ’ createSessionJob?.cancel()
223βˆ’ deleteSessionJob?.cancel()
224βˆ’ sendMessageJob?.cancel()
225βˆ’ loadMessagesJob?.cancel()
226βˆ’ closeSse()
227βˆ’ emit()
228βˆ’ }
229βˆ’
230βˆ’ Event.DismissError -> { error = null; emit() }
231βˆ’ }
232βˆ’ }
233βˆ’
234βˆ’ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235βˆ’ fun resumeStreamingIfConnected() {
236βˆ’ if (connected && sse == null) startSse()
237βˆ’ }
238βˆ’
239βˆ’ fun shutdown() {
240βˆ’ // Cancel every tracked coroutine before tearing down the stream so none can
241βˆ’ // emit into a cancelled scope after the ViewModel is cleared.
242βˆ’ connectJob?.cancel()
243βˆ’ loadSessionsJob?.cancel()
244βˆ’ createSessionJob?.cancel()
245βˆ’ loadMessagesJob?.cancel()
246βˆ’ deleteSessionJob?.cancel()
247βˆ’ sendMessageJob?.cancel()
248βˆ’ abortJob?.cancel()
249βˆ’ closeSse()
250βˆ’ }
251βˆ’
252βˆ’ // ── Effects ────────────────────────────────────────────────────────────────
253βˆ’ private fun sendMessage(raw: String) {
254βˆ’ val text = raw.trim()
255βˆ’ val session = currentSessionId ?: return
256βˆ’ if (text.isEmpty()) return
257βˆ’ // Debounce rapid double-taps on send: a send is already in flight, so a second
258βˆ’ // tap would create a duplicate user message and fire a duplicate request.
259βˆ’ if (generating) return
260βˆ’ localSeq += 1
261βˆ’ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262βˆ’ status = MessageStatus.Pending
263βˆ’ textParts["local"] = text
264βˆ’ }
265βˆ’ messages.add(user)
266βˆ’ // Keep the draft text so we can restore it if the send fails; otherwise the
267βˆ’ // user's message is lost and they'd have to retype it.
268βˆ’ val savedDraft = draft
269βˆ’ draft = ""
270βˆ’ generating = true
271βˆ’ error = null
272βˆ’ emit()
273βˆ’
274βˆ’ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275βˆ’ sendMessageJob?.cancel()
276βˆ’ sendMessageJob = scope.launch {
277βˆ’ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278βˆ’ result.fold(
279βˆ’ onSuccess = { resp ->
280βˆ’ if (resp.code in 200..299) {
281βˆ’ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282βˆ’ } else {
283βˆ’ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284βˆ’ generating = false
285βˆ’ messages.forEach { it.streaming = false }
286βˆ’ draft = savedDraft
287βˆ’ error = "Server returned status ${resp.code}"
288βˆ’ }
289βˆ’ },
290βˆ’ onFailure = { e ->
291βˆ’ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292βˆ’ generating = false
293βˆ’ messages.forEach { it.streaming = false }
294βˆ’ draft = savedDraft
295βˆ’ error = "Request failed: ${e.message}"
296βˆ’ },
297βˆ’ )
298βˆ’ emit()
299βˆ’ }
300βˆ’ }
301βˆ’
302βˆ’ private suspend fun probeHealth() {
303βˆ’ val hasCreds = hasCredentials()
304βˆ’ http.get("$serverUrl/global/health", authHeader()).fold(
305βˆ’ onSuccess = { resp ->
306βˆ’ when {
307βˆ’ resp.code == 401 -> {
308βˆ’ loading = false
309βˆ’ // A prior connection may have succeeded (connected = true); a
310βˆ’ // re-probe returning 401 (e.g. expired credentials) means we're
311βˆ’ // no longer authenticated, so drop the connected flag.
312βˆ’ connected = false
313βˆ’ // Always reveal the auth fields so the user can see/edit
314βˆ’ // their credentials β€” even when the ones supplied were wrong.
315βˆ’ authRequired = true
316βˆ’ if (hasCreds) error = "Invalid credentials"
317βˆ’ emit()
318βˆ’ }
319βˆ’ resp.code in 200..299 -> {
320βˆ’ connected = true
321βˆ’ authRequired = false
322βˆ’ error = null
323βˆ’ screen = Screen.Sessions
324βˆ’ loading = true
325βˆ’ emit()
326βˆ’ startSse()
327βˆ’ // Route through loadSessionsJob so a prior in-flight loadSessions
328βˆ’ // (e.g. from a rapid double-connect) is cancelled first.
329βˆ’ loadSessionsJob?.cancel()
330βˆ’ loadSessionsJob = scope.launch { loadSessions() }
331βˆ’ }
332βˆ’ else -> {
333βˆ’ loading = false
334βˆ’ connected = false
335βˆ’ error = "Server returned status ${resp.code}"
336βˆ’ emit()
337βˆ’ }
338βˆ’ }
339βˆ’ },
340βˆ’ onFailure = { e ->
341βˆ’ loading = false
342βˆ’ connected = false
343βˆ’ error = "Connection failed: ${e.message}"
344βˆ’ emit()
345βˆ’ },
346βˆ’ )
347βˆ’ }
348βˆ’
349βˆ’ private suspend fun loadSessions() {
350βˆ’ http.get("$serverUrl/session", authHeader()).fold(
351βˆ’ onSuccess = { resp ->
352βˆ’ if (resp.code in 200..299) {
353βˆ’ sessions.clear()
354βˆ’ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355βˆ’ // Impose a stable alphabetical order so the list doesn't shuffle with
356βˆ’ // whatever order the server happens to return sessions in.
357βˆ’ sessions.sortBy { it.title.lowercase() }
358βˆ’ } else {
359βˆ’ error = "Server returned status ${resp.code}"
360βˆ’ }
361βˆ’ },
362βˆ’ onFailure = { e -> error = "Request failed: ${e.message}" },
363βˆ’ )
364βˆ’ loading = false
365βˆ’ emit()
366βˆ’ }
367βˆ’
368βˆ’ private suspend fun createSession() {
369βˆ’ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370βˆ’ onSuccess = { resp ->
371βˆ’ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372βˆ’ if (session != null) {
373βˆ’ if (sessions.none { it.id == session.id }) {
374βˆ’ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375βˆ’ // Restore the alphabetical order immediately (matching loadSessions /
376βˆ’ // SessionUpserted) so the new session doesn't sit out-of-order at the
377βˆ’ // top until the SSE SessionUpserted event arrives to re-sort.
378βˆ’ sessions.sortBy { it.title.lowercase() }
379βˆ’ }
380βˆ’ emit()
381βˆ’ // SelectSession takes over the loading state (loads messages).
382βˆ’ dispatch(Event.SelectSession(session.id))
383βˆ’ return
384βˆ’ }
385βˆ’ error = "Server returned status ${resp.code}"
386βˆ’ },
387βˆ’ onFailure = { e -> error = "Request failed: ${e.message}" },
388βˆ’ )
389βˆ’ loading = false
390βˆ’ emit()
391βˆ’ }
392βˆ’
393βˆ’ private suspend fun loadMessages(sessionId: String) {
394βˆ’ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395βˆ’ onSuccess = { resp ->
396βˆ’ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397βˆ’ loading = false
398βˆ’ if (resp.code in 200..299) {
399βˆ’ messages.clear()
400βˆ’ removedMessageIds.clear()
401βˆ’ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402βˆ’ generating = messages.any { it.streaming }
403βˆ’ } else {
404βˆ’ error = "Server returned status ${resp.code}"
405βˆ’ }
406βˆ’ emit()
407βˆ’ },
408βˆ’ onFailure = { e ->
409βˆ’ if (!isCurrent(sessionId)) { loading = false; return }
410βˆ’ loading = false
411βˆ’ generating = false
412βˆ’ error = "Request failed: ${e.message}"
413βˆ’ emit()
414βˆ’ },
415βˆ’ )
416βˆ’ }
417βˆ’
418βˆ’ // ── SSE ─────────────────────────────────────────────────────────────────────
419βˆ’ private fun startSse() {
420βˆ’ closeSse()
421βˆ’ sseClosedIntentionally = false
422βˆ’ val client = SseClient(
423βˆ’ url = "$serverUrl/event",
424βˆ’ auth = authHeader(),
425βˆ’ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426βˆ’ onStateChange = { open ->
427βˆ’ scope.launch {
428βˆ’ sseConnected = open
429βˆ’ if (open) reconnectAttempts = 0 else scheduleReconnect()
430βˆ’ emit()
431βˆ’ }
432βˆ’ },
433βˆ’ )
434βˆ’ sse = client
435βˆ’ client.connect()
436βˆ’ }
437βˆ’
438βˆ’ private fun closeSse() {
439βˆ’ sseClosedIntentionally = true
440βˆ’ reconnectJob?.cancel()
441βˆ’ reconnectJob = null
442βˆ’ sse?.close()
443βˆ’ sse = null
444βˆ’ }
445βˆ’
446βˆ’ private fun scheduleReconnect() {
447βˆ’ if (sseClosedIntentionally || !connected) return
448βˆ’ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449βˆ’ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450βˆ’ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451βˆ’ if (delayMs < 30_000L) reconnectAttempts += 1
452βˆ’ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453βˆ’ // coroutines that each open a new SSE connection.
454βˆ’ reconnectJob?.cancel()
455βˆ’ reconnectJob = scope.launch {
456βˆ’ delay(delayMs)
457βˆ’ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458βˆ’ }
459βˆ’ }
460βˆ’
461βˆ’ private fun handleServerEvent(json: String) {
462βˆ’ val event = Protocol.parseEvent(json) ?: return
463βˆ’ // Only re-render if the event actually changed our state; applyEvent returns
464βˆ’ // false for events that don't apply (other session, unknown message, …).
465βˆ’ if (applyEvent(event)) emit()
466βˆ’ }
467βˆ’
468βˆ’ /** Apply a server event to the model. Returns true iff any state actually changed. */
469βˆ’ private fun applyEvent(event: Protocol.Event): Boolean {
470βˆ’ when (event) {
471βˆ’ is Protocol.Event.MessageUpdated -> {
472βˆ’ val info = event.info
473βˆ’ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474βˆ’ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475βˆ’ // don't re-create a message the server has already deleted.
476βˆ’ if (info.id in removedMessageIds) return false
477βˆ’ val streaming = info.time.completed == null && info.error == null
478βˆ’ val existing = messages.firstOrNull { it.id == info.id }
479βˆ’ if (existing != null) {
480βˆ’ existing.streaming = streaming
481βˆ’ if (existing.time == 0L) existing.time = info.time.created
482βˆ’ } else {
483βˆ’ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484βˆ’ }
485βˆ’ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486βˆ’ return true
487βˆ’ }
488βˆ’
489βˆ’ is Protocol.Event.PartUpdated -> {
490βˆ’ val part = event.part
491βˆ’ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492βˆ’ // Synthetic text parts are intentionally skipped; report no change so we
493βˆ’ // don't trigger a needless re-render/emit for them.
494βˆ’ return when (part) {
495βˆ’ is Protocol.Part.Text -> if (part.synthetic) {
496βˆ’ false
497βˆ’ } else {
498βˆ’ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499βˆ’ true
500βˆ’ }
501βˆ’ is Protocol.Part.Reasoning -> {
502βˆ’ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503βˆ’ true
504βˆ’ }
505βˆ’ is Protocol.Part.Tool -> {
506βˆ’ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507βˆ’ true
508βˆ’ }
509βˆ’ }
510βˆ’ }
511βˆ’
512βˆ’ is Protocol.Event.PartRemoved -> {
513βˆ’ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514βˆ’ msg.remove(event.partId)
515βˆ’ return true
516βˆ’ }
517βˆ’
518βˆ’ is Protocol.Event.MessageRemoved -> {
519βˆ’ // Remember the ID so a late MessageUpdated can't resurrect the message.
520βˆ’ removedMessageIds.add(event.messageId)
521βˆ’ return messages.removeAll { it.id == event.messageId }
522βˆ’ }
523βˆ’
524βˆ’ is Protocol.Event.SessionIdle -> {
525βˆ’ if (!isCurrent(event.sessionId)) return false
526βˆ’ generating = false
527βˆ’ messages.forEach { it.streaming = false }
528βˆ’ return true
529βˆ’ }
530βˆ’
531βˆ’ is Protocol.Event.SessionError -> {
532βˆ’ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533βˆ’ if (!scoped) return false
534βˆ’ event.error?.let { error = it.message() }
535βˆ’ generating = false
536βˆ’ messages.forEach { it.streaming = false }
537βˆ’ return true
538βˆ’ }
539βˆ’
540βˆ’ is Protocol.Event.SessionUpserted -> {
541βˆ’ val title = displayTitle(event.info.title)
542βˆ’ val existing = sessions.firstOrNull { it.id == event.info.id }
543βˆ’ if (existing != null) {
544βˆ’ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545βˆ’ } else {
546βˆ’ sessions.add(0, SessionView(event.info.id, title))
547βˆ’ }
548βˆ’ // Keep the list alphabetically sorted (matching loadSessions) so an
549βˆ’ // upserted session doesn't jump to the top and break the ordering.
550βˆ’ sessions.sortBy { it.title.lowercase() }
551βˆ’ if (isCurrent(event.info.id)) currentSessionTitle = title
552βˆ’ return true
553βˆ’ }
554βˆ’
555βˆ’ is Protocol.Event.SessionDeleted -> {
556βˆ’ val id = event.sessionId ?: return false
557βˆ’ val removed = sessions.removeAll { it.id == id }
558βˆ’ if (isCurrent(id)) {
559βˆ’ currentSessionId = null
560βˆ’ messages.clear()
561βˆ’ screen = Screen.Sessions
562βˆ’ return true
563βˆ’ }
564βˆ’ return removed
565βˆ’ }
566βˆ’
567βˆ’ Protocol.Event.Other -> return false
568βˆ’ }
569βˆ’ }
570βˆ’
571βˆ’ // ── View / helpers ──────────────────────────────────────────────────────────
572βˆ’ private fun snapshot() = UiState(
573βˆ’ screen = screen,
574βˆ’ serverUrl = serverUrl,
575βˆ’ username = username,
576βˆ’ password = password,
577βˆ’ authRequired = authRequired,
578βˆ’ connected = connected,
579βˆ’ loading = loading,
580βˆ’ error = error,
581βˆ’ sessions = sessions.toList(),
582βˆ’ currentSessionId = currentSessionId,
583βˆ’ currentSessionTitle = currentSessionTitle,
584βˆ’ messages = messages.map { it.toView() },
585βˆ’ draft = draft,
586βˆ’ generating = generating,
587βˆ’ sseConnected = sseConnected,
588βˆ’ )
589βˆ’
590βˆ’ private fun emit() { _view.value = snapshot() }
591βˆ’
592βˆ’ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593βˆ’ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594βˆ’
595βˆ’ private fun authHeader(): String? {
596βˆ’ if (!hasCredentials()) return null
597βˆ’ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598βˆ’ return "Basic $encoded"
599βˆ’ }
600βˆ’}
601βˆ’
602βˆ’// ── Internal message state (parts tracked by id for streaming) ─────────────────
603βˆ’private class MsgState(val id: String, val role: String, var time: Long) {
604βˆ’ var status = MessageStatus.Sent
605βˆ’ var streaming = false
606βˆ’ val textParts = LinkedHashMap<String, String>()
607βˆ’ val reasoningParts = LinkedHashMap<String, String>()
608βˆ’ val toolParts = LinkedHashMap<String, ToolView>()
609βˆ’
610βˆ’ fun remove(partId: String) {
611βˆ’ textParts.remove(partId)
612βˆ’ reasoningParts.remove(partId)
613βˆ’ toolParts.remove(partId)
614βˆ’ }
615βˆ’
616βˆ’ fun toView(): MessageView {
617βˆ’ val reasoning = reasoningParts.values.joinToString("")
618βˆ’ return MessageView(
619βˆ’ id = id,
620βˆ’ role = role,
621βˆ’ text = textParts.values.joinToString(""),
622βˆ’ reasoning = reasoning.ifEmpty { null },
623βˆ’ tools = toolParts.values.toList(),
624βˆ’ time = time,
625βˆ’ status = status,
626βˆ’ streaming = streaming,
627βˆ’ )
628βˆ’ }
629βˆ’}
630βˆ’
631βˆ’private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632βˆ’ val info = env.info
633βˆ’ val msg = MsgState(info.id, info.role, info.time.created)
634βˆ’ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635βˆ’ for (raw in env.parts) {
636βˆ’ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637βˆ’ when (val part = Protocol.parsePart(obj)) {
638βˆ’ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639βˆ’ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640βˆ’ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641βˆ’ null -> Unit
642βˆ’ }
643βˆ’ }
644βˆ’ return msg
645βˆ’}
646βˆ’
647βˆ’private fun pickText(text: String, delta: String?, prior: String?): String =
648βˆ’ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649βˆ’
650βˆ’private fun normalizeUrl(url: String): String {
651βˆ’ val trimmed = url.trim().trimEnd('/')
652βˆ’ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653βˆ’ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654βˆ’ return when {
655βˆ’ trimmed.isEmpty() -> ""
656βˆ’ "://" in trimmed -> trimmed
657βˆ’ else -> "http://$trimmed"
658βˆ’ }
659βˆ’}
660βˆ’
661βˆ’private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662βˆ’
663βˆ’// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664βˆ’@kotlinx.serialization.Serializable
665βˆ’private data class PromptBody(val parts: List<TextInput>)
666βˆ’
667βˆ’@kotlinx.serialization.Serializable
668βˆ’private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktdeletedβˆ’148
@@ -1,148 +0,0 @@
1βˆ’package soy.iko.opencode.core
2βˆ’
3βˆ’import kotlinx.serialization.SerialName
4βˆ’import kotlinx.serialization.Serializable
5βˆ’import kotlinx.serialization.json.Json
6βˆ’import kotlinx.serialization.json.JsonElement
7βˆ’import kotlinx.serialization.json.JsonObject
8βˆ’import kotlinx.serialization.json.JsonPrimitive
9βˆ’import kotlinx.serialization.json.booleanOrNull
10βˆ’import kotlinx.serialization.json.contentOrNull
11βˆ’import kotlinx.serialization.json.decodeFromJsonElement
12βˆ’import kotlinx.serialization.json.jsonObject
13βˆ’
14βˆ’/**
15βˆ’ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16βˆ’ *
17βˆ’ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18βˆ’ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19βˆ’ * catch-all so new server versions never break the stream parser.
20βˆ’ */
21βˆ’object Protocol {
22βˆ’ val json = Json {
23βˆ’ ignoreUnknownKeys = true
24βˆ’ isLenient = true
25βˆ’ coerceInputValues = true
26βˆ’ }
27βˆ’
28βˆ’ // ─── Sessions ────────────────────────────────────────────────────────────
29βˆ’
30βˆ’ @Serializable
31βˆ’ data class WireSession(val id: String, val title: String = "")
32βˆ’
33βˆ’ fun parseSessions(body: String): List<WireSession> =
34βˆ’ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35βˆ’
36βˆ’ fun parseSession(body: String): WireSession? =
37βˆ’ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38βˆ’
39βˆ’ // ─── Messages ────────────────────────────────────────────────────────────
40βˆ’
41βˆ’ @Serializable
42βˆ’ data class WireTime(val created: Long = 0, val completed: Long? = null)
43βˆ’
44βˆ’ @Serializable
45βˆ’ data class WireError(val name: String = "", val data: JsonElement? = null) {
46βˆ’ fun message(): String {
47βˆ’ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48βˆ’ return when {
49βˆ’ !msg.isNullOrEmpty() -> msg
50βˆ’ name.isNotEmpty() -> name
51βˆ’ else -> "Unknown error"
52βˆ’ }
53βˆ’ }
54βˆ’ }
55βˆ’
56βˆ’ @Serializable
57βˆ’ data class WireMessageInfo(
58βˆ’ val id: String,
59βˆ’ val role: String = "",
60βˆ’ @SerialName("sessionID") val sessionId: String = "",
61βˆ’ val time: WireTime = WireTime(),
62βˆ’ val error: WireError? = null,
63βˆ’ )
64βˆ’
65βˆ’ @Serializable
66βˆ’ data class WireMessageEnvelope(
67βˆ’ val info: WireMessageInfo,
68βˆ’ val parts: List<JsonElement> = emptyList(),
69βˆ’ )
70βˆ’
71βˆ’ fun parseMessages(body: String): List<WireMessageEnvelope> =
72βˆ’ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73βˆ’
74βˆ’ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75βˆ’
76βˆ’ sealed interface Part {
77βˆ’ val id: String
78βˆ’ val messageId: String
79βˆ’
80βˆ’ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81βˆ’ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82βˆ’ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83βˆ’ }
84βˆ’
85βˆ’ /** Parse a single part JSON object; returns null for kinds we don't render. */
86βˆ’ fun parsePart(obj: JsonObject): Part? {
87βˆ’ val id = obj.str("id")
88βˆ’ val messageId = obj.str("messageID")
89βˆ’ return when (obj.str("type")) {
90βˆ’ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91βˆ’ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92βˆ’ "tool" -> {
93βˆ’ val state = obj["state"] as? JsonObject
94βˆ’ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95βˆ’ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96βˆ’ Part.Tool(id, messageId, obj.str("tool"), status, title)
97βˆ’ }
98βˆ’ else -> null
99βˆ’ }
100βˆ’ }
101βˆ’
102βˆ’ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103βˆ’
104βˆ’ sealed interface Event {
105βˆ’ data class MessageUpdated(val info: WireMessageInfo) : Event
106βˆ’ data class PartUpdated(val part: Part, val delta: String?) : Event
107βˆ’ data class PartRemoved(val messageId: String, val partId: String) : Event
108βˆ’ data class MessageRemoved(val messageId: String) : Event
109βˆ’ data class SessionIdle(val sessionId: String) : Event
110βˆ’ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111βˆ’ data class SessionUpserted(val info: WireSession) : Event
112βˆ’ data class SessionDeleted(val sessionId: String?) : Event
113βˆ’ data object Other : Event
114βˆ’ }
115βˆ’
116βˆ’ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117βˆ’ fun parseEvent(raw: String): Event? {
118βˆ’ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119βˆ’ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120βˆ’ return when (root.str("type")) {
121βˆ’ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122βˆ’ "message.part.updated" -> {
123βˆ’ val partObj = props["part"] as? JsonObject ?: return Event.Other
124βˆ’ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125βˆ’ }
126βˆ’ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127βˆ’ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128βˆ’ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129βˆ’ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130βˆ’ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131βˆ’ "session.deleted" -> Event.SessionDeleted(
132βˆ’ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133βˆ’ )
134βˆ’ else -> Event.Other
135βˆ’ }
136βˆ’ }
137βˆ’
138βˆ’ private inline fun <reified T> decode(el: JsonElement?): T? =
139βˆ’ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140βˆ’
141βˆ’ // ─── Small JSON helpers ───────────────────────────────────────────────────
142βˆ’
143βˆ’ private fun JsonObject.str(key: String): String =
144βˆ’ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145βˆ’
146βˆ’ private fun JsonObject.bool(key: String): Boolean =
147βˆ’ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148βˆ’}
app/src/main/java/soy/iko/opencode/core/SseClient.ktdeletedβˆ’63
@@ -1,63 +0,0 @@
1βˆ’package soy.iko.opencode.core
2βˆ’
3βˆ’import okhttp3.OkHttpClient
4βˆ’import okhttp3.Request
5βˆ’import okhttp3.Response
6βˆ’import okhttp3.sse.EventSource
7βˆ’import okhttp3.sse.EventSourceListener
8βˆ’import okhttp3.sse.EventSources
9βˆ’import java.util.concurrent.TimeUnit
10βˆ’
11βˆ’/**
12βˆ’ * Long-lived subscription to the opencode `/event` SSE stream.
13βˆ’ *
14βˆ’ * Crux only models request/response HTTP, so the streaming connection lives here
15βˆ’ * in the shell: each decoded event's `data` payload (a JSON object whose own
16βˆ’ * `type` field is the discriminant) is handed to [onEvent], which the core then
17βˆ’ * parses via [Protocol.parseEvent].
18βˆ’ */
19βˆ’class SseClient(
20βˆ’ private val url: String,
21βˆ’ private val auth: String?,
22βˆ’ private val onEvent: (String) -> Unit,
23βˆ’ private val onStateChange: (Boolean) -> Unit,
24βˆ’) {
25βˆ’ // A dedicated client with no read timeout β€” the stream is meant to stay open.
26βˆ’ private val client = OkHttpClient.Builder()
27βˆ’ .connectTimeout(15, TimeUnit.SECONDS)
28βˆ’ .readTimeout(0, TimeUnit.SECONDS)
29βˆ’ .retryOnConnectionFailure(true)
30βˆ’ .build()
31βˆ’
32βˆ’ private var source: EventSource? = null
33βˆ’
34βˆ’ fun connect() {
35βˆ’ val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36βˆ’ builder.header("User-Agent", "opencode-android/1.0")
37βˆ’ if (auth != null) builder.header("Authorization", auth)
38βˆ’ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39βˆ’ }
40βˆ’
41βˆ’ fun close() {
42βˆ’ source?.cancel()
43βˆ’ source = null
44βˆ’ }
45βˆ’
46βˆ’ private val listener = object : EventSourceListener() {
47βˆ’ override fun onOpen(eventSource: EventSource, response: Response) {
48βˆ’ onStateChange(true)
49βˆ’ }
50βˆ’
51βˆ’ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52βˆ’ if (data.isNotBlank()) onEvent(data)
53βˆ’ }
54βˆ’
55βˆ’ override fun onClosed(eventSource: EventSource) {
56βˆ’ onStateChange(false)
57βˆ’ }
58βˆ’
59βˆ’ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60βˆ’ onStateChange(false)
61βˆ’ }
62βˆ’ }
63βˆ’}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktdeletedβˆ’77
@@ -1,77 +0,0 @@
1βˆ’package soy.iko.opencode.core
2βˆ’
3βˆ’/**
4βˆ’ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5βˆ’ * the two implementations stay behaviourally identical.
6βˆ’ */
7βˆ’
8βˆ’enum class Screen { Connect, Sessions, Chat }
9βˆ’
10βˆ’enum class MessageStatus { Sent, Pending, Failed }
11βˆ’
12βˆ’data class SessionView(
13βˆ’ val id: String,
14βˆ’ val title: String,
15βˆ’)
16βˆ’
17βˆ’data class ToolView(
18βˆ’ val name: String,
19βˆ’ val status: String,
20βˆ’ val title: String?,
21βˆ’)
22βˆ’
23βˆ’data class MessageView(
24βˆ’ val id: String,
25βˆ’ val role: String,
26βˆ’ val text: String,
27βˆ’ val reasoning: String?,
28βˆ’ val tools: List<ToolView>,
29βˆ’ val time: Long,
30βˆ’ val status: MessageStatus,
31βˆ’ val streaming: Boolean,
32βˆ’) {
33βˆ’ val isUser: Boolean get() = role == "user"
34βˆ’}
35βˆ’
36βˆ’data class UiState(
37βˆ’ val screen: Screen = Screen.Connect,
38βˆ’ val serverUrl: String = "",
39βˆ’ val username: String = "",
40βˆ’ val password: String = "",
41βˆ’ val authRequired: Boolean = false,
42βˆ’ val connected: Boolean = false,
43βˆ’ val loading: Boolean = false,
44βˆ’ val error: String? = null,
45βˆ’ val sessions: List<SessionView> = emptyList(),
46βˆ’ val currentSessionId: String? = null,
47βˆ’ val currentSessionTitle: String = "",
48βˆ’ val messages: List<MessageView> = emptyList(),
49βˆ’ val draft: String = "",
50βˆ’ val generating: Boolean = false,
51βˆ’ val sseConnected: Boolean = true,
52βˆ’)
53βˆ’
54βˆ’/**
55βˆ’ * Everything the shell can ask the core to do. Internal HTTP results are not
56βˆ’ * events here (unlike the Rust core, which routes them through `then_send`);
57βˆ’ * the Kotlin driver awaits them inline in coroutines instead.
58βˆ’ */
59βˆ’sealed interface Event {
60βˆ’ data class ServerUrlChanged(val url: String) : Event
61βˆ’ data class UsernameChanged(val value: String) : Event
62βˆ’ data class PasswordChanged(val value: String) : Event
63βˆ’ data object Connect : Event
64βˆ’
65βˆ’ data object LoadSessions : Event
66βˆ’ data class SelectSession(val id: String) : Event
67βˆ’ data object CreateSession : Event
68βˆ’ data class DeleteSession(val id: String) : Event
69βˆ’
70βˆ’ data class DraftChanged(val text: String) : Event
71βˆ’ data class SendMessage(val text: String) : Event
72βˆ’ data object CancelGeneration : Event
73βˆ’
74βˆ’ data object NavigateToSessions : Event
75βˆ’ data object NavigateToConnect : Event
76βˆ’ data object DismissError : Event
77βˆ’}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /agent`. */
7+@Immutable
8+@Serializable
9+data class Agent(
10+ val name: String,
11+ val description: String? = null,
12+ val mode: String? = null,
13+ val builtIn: Boolean = false,
14+ val prompt: String? = null,
15+) {
16+ val isPrimary: Boolean get() = mode == "primary"
17+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
18+}
19+
20+/** Entry from `GET /command`. */
21+@Immutable
22+@Serializable
23+data class Command(
24+ val name: String,
25+ val description: String? = null,
26+ val agent: String? = null,
27+ val model: String? = null,
28+ val template: String = "",
29+ val subtask: Boolean = false,
30+) {
31+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
32+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8+ * Discriminated on `type` (the global discriminator). Unknown events decode to
9+ * [UnknownEvent] so the stream never dies on a new event type.
10+ *
11+ * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12+ * drift across opencode versions; the reducer only reads the fields it needs.
13+ */
14+@Serializable
15+sealed interface BusEvent
16+
17+@Serializable
18+@SerialName("server.connected")
19+data object ServerConnected : BusEvent
20+
21+@Serializable
22+@SerialName("message.updated")
23+data class MessageUpdated(val properties: Props) : BusEvent {
24+ @Serializable
25+ data class Props(val info: MessageInfo)
26+}
27+
28+@Serializable
29+@SerialName("message.part.updated")
30+data class MessagePartUpdated(val properties: Props) : BusEvent {
31+ @Serializable
32+ data class Props(
33+ val part: Part,
34+ val sessionID: String? = null,
35+ val messageID: String? = null,
36+ )
37+}
38+
39+@Serializable
40+@SerialName("message.part.removed")
41+data class MessagePartRemoved(val properties: Props) : BusEvent {
42+ @Serializable
43+ data class Props(
44+ val sessionID: String? = null,
45+ val messageID: String? = null,
46+ val partID: String? = null,
47+ )
48+}
49+
50+@Serializable
51+@SerialName("message.removed")
52+data class MessageRemoved(val properties: Props) : BusEvent {
53+ @Serializable
54+ data class Props(
55+ val sessionID: String? = null,
56+ val messageID: String? = null,
57+ )
58+}
59+
60+@Serializable
61+@SerialName("session.idle")
62+data class SessionIdle(val properties: Props) : BusEvent {
63+ @Serializable
64+ data class Props(val sessionID: String? = null)
65+}
66+
67+@Serializable
68+@SerialName("session.error")
69+data class SessionError(val properties: Props) : BusEvent {
70+ @Serializable
71+ data class Props(
72+ val sessionID: String? = null,
73+ val error: kotlinx.serialization.json.JsonElement? = null,
74+ )
75+}
76+
77+@Serializable
78+@SerialName("session.updated")
79+data class SessionUpdated(val properties: Props) : BusEvent {
80+ @Serializable
81+ data class Props(val info: Session)
82+}
83+
84+@Serializable
85+@SerialName("session.deleted")
86+data class SessionDeleted(val properties: Props) : BusEvent {
87+ @Serializable
88+ data class Props(val info: Session? = null, val sessionID: String? = null)
89+}
90+
91+@Serializable
92+@SerialName("permission.updated")
93+data class PermissionUpdated(val properties: Permission) : BusEvent
94+
95+@Serializable
96+@SerialName("permission.replied")
97+data class PermissionReplied(val properties: Props) : BusEvent {
98+ @Serializable
99+ data class Props(
100+ val sessionID: String? = null,
101+ val permissionID: String? = null,
102+ val response: String? = null,
103+ )
104+}
105+
106+/** Fallback for any event type we don't model. */
107+@Serializable
108+@SerialName("__unknown")
109+data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+@Immutable
7+@Serializable
8+data class TimeInfo(
9+ val created: Long? = null,
10+ val updated: Long? = null,
11+ val completed: Long? = null,
12+)
13+
14+@Immutable
15+@Serializable
16+data class Tokens(
17+ val input: Long = 0,
18+ val output: Long = 0,
19+ val reasoning: Long = 0,
20+ val cache: Cache = Cache(),
21+) {
22+ @Serializable
23+ @Immutable
24+ data class Cache(
25+ val read: Long = 0,
26+ val write: Long = 0,
27+ )
28+}
app/src/main/java/soy/iko/opencode/data/model/Files.ktnew file+38
@@ -0,0 +1,38 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /file?path=` (directory listing). */
7+@Immutable
8+@Serializable
9+data class FileNode(
10+ val name: String = "",
11+ val path: String = "",
12+ val type: String? = null,
13+) {
14+ val isDirectory: Boolean get() = type == "directory"
15+}
16+
17+/** Response of `GET /file/content?path=`. */
18+@Immutable
19+@Serializable
20+data class FileContent(
21+ val type: String? = null, // "text" | "binary"
22+ val content: String = "",
23+ val diff: String? = null,
24+ val mimeType: String? = null,
25+ val encoding: String? = null,
26+) {
27+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
28+}
29+
30+/** Entry from `GET /file/status` (VCS status). */
31+@Immutable
32+@Serializable
33+data class FileStatusEntry(
34+ val path: String = "",
35+ val added: Int = 0,
36+ val removed: Int = 0,
37+ val status: String? = null, // "added" | "deleted" | "modified"
38+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+65
@@ -0,0 +1,65 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The `info` object of a message. Discriminated on `role`, which differs from the
11+ * global `type` discriminator used by [Part], so this hierarchy overrides it.
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("role")
16+sealed interface MessageInfo {
17+ val id: String
18+ val sessionID: String
19+ val time: TimeInfo?
20+}
21+
22+@Immutable
23+@Serializable
24+@SerialName("user")
25+data class UserMessage(
26+ override val id: String,
27+ override val sessionID: String,
28+ override val time: TimeInfo? = null,
29+) : MessageInfo
30+
31+@Immutable
32+@Serializable
33+@SerialName("assistant")
34+data class AssistantMessage(
35+ override val id: String,
36+ override val sessionID: String,
37+ val parentID: String? = null,
38+ val providerID: String? = null,
39+ val modelID: String? = null,
40+ val cost: Double? = null,
41+ val tokens: Tokens? = null,
42+ val error: JsonObject? = null,
43+ override val time: TimeInfo? = null,
44+) : MessageInfo {
45+ /** A run is complete once the server stamps a completion time. */
46+ val isComplete: Boolean get() = time?.completed != null
47+}
48+
49+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50+@Immutable
51+@Serializable
52+@SerialName("__unknown")
53+data class UnknownMessage(
54+ override val id: String = "",
55+ override val sessionID: String = "",
56+ override val time: TimeInfo? = null,
57+) : MessageInfo
58+
59+/** A message together with its parts β€” the shape returned by message endpoints and the prompt response. */
60+@Immutable
61+@Serializable
62+data class MessageWithParts(
63+ val info: MessageInfo,
64+ val parts: List<Part> = emptyList(),
65+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+99
@@ -0,0 +1,99 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+
7+/**
8+ * A message part β€” a discriminated union on `type` (the global class discriminator).
9+ * Unknown types decode to [UnknownPart] via the registered polymorphic default,
10+ * so the stream survives server additions.
11+ *
12+ * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
13+ * uses to attach streamed parts to the right message.
14+ */
15+@Serializable
16+sealed interface Part {
17+ val id: String
18+ val messageID: String?
19+ val sessionID: String?
20+}
21+
22+@Immutable
23+@Serializable
24+@SerialName("text")
25+data class TextPart(
26+ override val id: String = "",
27+ override val messageID: String? = null,
28+ override val sessionID: String? = null,
29+ val text: String = "",
30+ val synthetic: Boolean = false,
31+ val ignored: Boolean = false,
32+ val time: TimeInfo? = null,
33+) : Part
34+
35+@Immutable
36+@Serializable
37+@SerialName("reasoning")
38+data class ReasoningPart(
39+ override val id: String = "",
40+ override val messageID: String? = null,
41+ override val sessionID: String? = null,
42+ val text: String = "",
43+ val time: TimeInfo? = null,
44+) : Part
45+
46+@Immutable
47+@Serializable
48+@SerialName("tool")
49+data class ToolPart(
50+ override val id: String = "",
51+ override val messageID: String? = null,
52+ override val sessionID: String? = null,
53+ val callID: String = "",
54+ val tool: String = "",
55+ val state: ToolState = ToolPending,
56+) : Part
57+
58+@Immutable
59+@Serializable
60+@SerialName("file")
61+data class FilePart(
62+ override val id: String = "",
63+ override val messageID: String? = null,
64+ override val sessionID: String? = null,
65+ val mime: String? = null,
66+ val filename: String? = null,
67+ val url: String? = null,
68+ val source: String? = null,
69+) : Part
70+
71+@Immutable
72+@Serializable
73+@SerialName("step-start")
74+data class StepStartPart(
75+ override val id: String = "",
76+ override val messageID: String? = null,
77+ override val sessionID: String? = null,
78+) : Part
79+
80+@Immutable
81+@Serializable
82+@SerialName("step-finish")
83+data class StepFinishPart(
84+ override val id: String = "",
85+ override val messageID: String? = null,
86+ override val sessionID: String? = null,
87+ val cost: Double? = null,
88+ val tokens: Tokens? = null,
89+) : Part
90+
91+/** Fallback for an unrecognized `type`. */
92+@Immutable
93+@Serializable
94+@SerialName("__unknown")
95+data class UnknownPart(
96+ override val id: String = "",
97+ override val messageID: String? = null,
98+ override val sessionID: String? = null,
99+) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonArray
8+import kotlinx.serialization.json.JsonPrimitive
9+
10+/**
11+ * A permission request from `permission.updated`. The tool detail lives in [type] +
12+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
13+ * it's kept as a [JsonElement] and flattened for display.
14+ */
15+@Immutable
16+@Serializable
17+data class Permission(
18+ val id: String,
19+ val sessionID: String = "",
20+ val type: String? = null,
21+ val pattern: JsonElement? = null,
22+ val messageID: String? = null,
23+ val callID: String? = null,
24+ val title: String? = null,
25+ val metadata: JsonObject? = null,
26+ val time: TimeInfo? = null,
27+) {
28+ val patternText: String?
29+ get() = when (val p = pattern) {
30+ null -> null
31+ is JsonPrimitive -> p.content
32+ // Only a JSON array is flattened; any other shape (e.g. an object) would make
33+ // the previous unguarded `p.jsonArray` throw during composition, so treat it
34+ // as "no displayable pattern" instead.
35+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36+ else -> null
37+ }
38+}
39+
40+/** Allowed values for the permission response body. */
41+enum class PermissionResponse(val wire: String) {
42+ ONCE("once"),
43+ ALWAYS("always"),
44+ REJECT("reject"),
45+}
46+
47+/** Body for `POST /session/:id/permissions/:permissionID`. */
48+@Serializable
49+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Project.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * A project known to the server, from `GET /project`. A project corresponds to a git
8+ * worktree root; [worktree] is the absolute path the agent can run in. Used to populate
9+ * the directory picker when creating a session in a specific directory.
10+ */
11+@Immutable
12+@Serializable
13+data class Project(
14+ val id: String = "",
15+ val worktree: String = "",
16+ val vcs: String? = null,
17+ val time: TimeInfo? = null,
18+)
19+
20+/**
21+ * The server's current path info, from `GET /path`. [directory] is the working directory
22+ * a request with no `directory` override resolves to (the server's launch cwd), shown in
23+ * the picker as the "server default" option.
24+ */
25+@Immutable
26+@Serializable
27+data class PathInfo(
28+ val directory: String? = null,
29+ val worktree: String? = null,
30+ val state: String? = null,
31+ val config: String? = null,
32+)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktnew file+26
@@ -0,0 +1,26 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Body for `POST /session/:id/message`. */
6+@Serializable
7+data class PromptRequest(
8+ val parts: List<PromptPart>,
9+ val model: ModelRef? = null,
10+ val agent: String? = null,
11+)
12+
13+/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14+@Serializable
15+data class PromptPart(
16+ val type: String = "text",
17+ val text: String,
18+)
19+
20+/** Body for `POST /session/:id/command`. */
21+@Serializable
22+data class CommandRequest(
23+ val command: String,
24+ val arguments: String = "",
25+ val agent: String? = null,
26+)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Response of `GET /config/providers`. */
7+@Immutable
8+@Serializable
9+data class ProvidersResponse(
10+ val providers: List<Provider> = emptyList(),
11+ val default: Map<String, String> = emptyMap(),
12+)
13+
14+@Immutable
15+@Serializable
16+data class Provider(
17+ val id: String,
18+ val name: String? = null,
19+ val models: Map<String, ModelInfo> = emptyMap(),
20+) {
21+ val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
22+}
23+
24+@Immutable
25+@Serializable
26+data class ModelInfo(
27+ val id: String? = null,
28+ val name: String? = null,
29+) {
30+ fun displayName(modelKey: String): String =
31+ name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
32+}
33+
34+/** A flattened provider/model selection used by the prompt body and the model picker. */
35+@Immutable
36+@Serializable
37+data class ModelRef(
38+ val providerID: String,
39+ val modelID: String,
40+)
41+
42+/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
43+@Immutable
44+data class ModelOption(
45+ val providerID: String,
46+ val modelID: String,
47+ val providerLabel: String,
48+ val modelLabel: String,
49+) {
50+ val ref: ModelRef get() = ModelRef(providerID, modelID)
51+}
52+
53+/** Flatten providers into a sorted list of pickable options. */
54+fun ProvidersResponse.toOptions(): List<ModelOption> =
55+ providers.flatMap { provider ->
56+ provider.models.map { (modelKey, info) ->
57+ ModelOption(
58+ providerID = provider.id,
59+ modelID = info.id ?: modelKey,
60+ providerLabel = provider.displayName,
61+ modelLabel = info.displayName(modelKey),
62+ )
63+ }
64+ }.sortedWith(
65+ // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
66+ // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
67+ compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
68+ .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
69+ )
70+
71+/** The default option to preselect: the server's default for some provider, else the first model. */
72+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
73+ default.entries.firstOrNull()?.let { (providerID, modelID) ->
74+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
75+ }
76+ return options.firstOrNull()
77+}
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktnew file+21
@@ -0,0 +1,21 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+
5+/**
6+ * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7+ * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8+ */
9+@Immutable
10+data class ServerProfile(
11+ val id: String,
12+ val label: String,
13+ val baseUrl: String,
14+ val username: String? = null,
15+ val password: String? = null,
16+ val lastUsed: Long = 0,
17+) {
18+ val hasAuth: Boolean get() = !username.isNullOrBlank()
19+
20+ val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
21+}
app/src/main/java/soy/iko/opencode/data/model/Session.ktnew file+40
@@ -0,0 +1,40 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** A session as returned by `GET /session` and `POST /session`. */
7+@Immutable
8+@Serializable
9+data class Session(
10+ val id: String,
11+ val parentID: String? = null,
12+ val title: String? = null,
13+ val version: String? = null,
14+ val time: TimeInfo? = null,
15+ // The absolute worktree/directory the agent runs in for this session. Set server-side
16+ // from the `directory` query param at creation; persisted per session so the client
17+ // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
18+ val directory: String? = null,
19+ val projectID: String? = null,
20+) {
21+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
22+
23+ /** A compact form of [directory] for list/subtitle display: the last path segment,
24+ * or the full path when it has no separators. Null/blank when no directory is set. */
25+ val displayDirectory: String?
26+ get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
27+}
28+
29+/** Body for `POST /session`. */
30+@Serializable
31+data class CreateSessionRequest(
32+ val parentID: String? = null,
33+ val title: String? = null,
34+)
35+
36+/** Body for `PATCH /session/:id`. */
37+@Serializable
38+data class UpdateSessionRequest(
39+ val title: String? = null,
40+)
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+53
@@ -0,0 +1,53 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The state machine of a tool call, discriminated on `status`.
11+ * pending -> running -> completed | error
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("status")
16+sealed interface ToolState
17+
18+@Serializable
19+@SerialName("pending")
20+data object ToolPending : ToolState
21+
22+@Immutable
23+@Serializable
24+@SerialName("running")
25+data class ToolRunning(
26+ val input: JsonElement? = null,
27+ val title: String? = null,
28+ val time: TimeInfo? = null,
29+) : ToolState
30+
31+@Immutable
32+@Serializable
33+@SerialName("completed")
34+data class ToolCompleted(
35+ val input: JsonElement? = null,
36+ val output: String? = null,
37+ val title: String? = null,
38+ val time: TimeInfo? = null,
39+) : ToolState
40+
41+@Immutable
42+@Serializable
43+@SerialName("error")
44+data class ToolError(
45+ val input: JsonElement? = null,
46+ val error: String? = null,
47+ val time: TimeInfo? = null,
48+) : ToolState
49+
50+/** Fallback for an unrecognized `status`. */
51+@Serializable
52+@SerialName("__unknown")
53+data object ToolUnknown : ToolState
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktnew file+259
@@ -0,0 +1,259 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.util.safeExceptionSummary
5+import android.util.Log
6+import io.ktor.client.HttpClient
7+import io.ktor.client.plugins.timeout
8+import io.ktor.client.plugins.sse.sse
9+import io.ktor.sse.ServerSentEvent
10+import kotlinx.coroutines.CancellationException
11+import kotlinx.coroutines.CoroutineScope
12+import kotlin.coroutines.EmptyCoroutineContext
13+import kotlinx.coroutines.channels.BufferOverflow
14+import kotlinx.coroutines.channels.Channel
15+import kotlinx.coroutines.channels.ChannelResult
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.SharingStarted
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.buffer
23+import kotlinx.coroutines.flow.channelFlow
24+import kotlinx.coroutines.flow.shareIn
25+import kotlinx.coroutines.isActive
26+import kotlinx.coroutines.launch
27+import kotlinx.coroutines.selects.onTimeout
28+import kotlinx.coroutines.selects.select
29+import java.io.IOException
30+import kotlin.random.Random
31+
32+/**
33+ * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
34+ * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
35+ * subscribes (WhileSubscribed) and reconnects with backoff on drop.
36+ *
37+ * Ordering contract: callers that send a prompt must already be collecting [events]
38+ * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
39+ */
40+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
41+open class EventStreamClient(
42+ private val client: HttpClient,
43+ private val scope: CoroutineScope,
44+ private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
45+ private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
46+ private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
47+ private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
48+) {
49+ protected constructor() : this(
50+ HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51+ CoroutineScope(EmptyCoroutineContext),
52+ )
53+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
54+
55+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
56+ open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57+
58+ open val events: SharedFlow<BusEvent> by lazy {
59+ stream()
60+ .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
61+ .shareIn(
62+ scope = scope,
63+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
64+ replay = 0,
65+ )
66+ }
67+
68+ /** Conflated channel used to cut the reconnect backoff short when network returns. */
69+ private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70+
71+ /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying.
72+ * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
73+ * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
74+ * underlying ClientRequestException only as the cause β€” so we inspect the wrapper's response status
75+ * and walk the whole cause chain rather than matching a single exception type. */
76+ private fun isSseAuthFailure(e: Throwable): Boolean =
77+ generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
78+ .take(16)
79+ .mapNotNull { t ->
80+ when (t) {
81+ is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
82+ is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
83+ else -> null
84+ }
85+ }
86+ .any { it == 401 || it == 403 }
87+
88+ /** Request an immediate reconnect, skipping any in-progress backoff. */
89+ open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
90+
91+ /**
92+ * Read SSE events from [events], racing each read against an idle-timeout watchdog.
93+ * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
94+ * clean stream close or scope cancellation.
95+ */
96+ private suspend fun readSseEvents(
97+ scope: CoroutineScope,
98+ events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
99+ send: suspend (BusEvent) -> Unit,
100+ ) {
101+ while (scope.isActive) {
102+ // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
103+ // read), so a socket the peer never closed would otherwise hang until OS TCP
104+ // keepalive kicks in β€” minutes to hours on stock Linux. If nothing arrives
105+ // within the idle timeout, treat the connection as half-open and drop it.
106+ val result: ChannelResult<ServerSentEvent>? = select {
107+ events.onReceiveCatching { it }
108+ onTimeout(idleTimeoutMs) { null }
109+ }
110+ if (result == null) throw IOException("SSE idle timeout, reconnecting")
111+ val sse = result.getOrNull()
112+ if (sse == null) {
113+ // The producer channel is closed. A non-null cause means `incoming`
114+ // failed (e.g. a socket reset bridged from the producer) β€” rethrow it so
115+ // the outer loop logs and retries with backoff. A clean close (null
116+ // cause) just ends this attempt and lets the loop reconnect.
117+ result.exceptionOrNull()?.let { throw it }
118+ break
119+ }
120+ val data = sse.data
121+ if (data == null) {
122+ // A comment or keep-alive event (no data field) resets the idle watchdog
123+ // β€” servers send `: ping` comments to keep the connection alive through
124+ // proxies. Without this, a server that only sends comments would still
125+ // hit the idle timeout and reconnect unnecessarily.
126+ continue
127+ }
128+ val event = try {
129+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
130+ } catch (e: kotlinx.serialization.SerializationException) {
131+ Log.d("EventStream", "Skipping unparseable SSE event", e)
132+ continue
133+ }
134+ if (_state.value != ConnectionState.Connected) {
135+ _state.value = ConnectionState.Connected
136+ }
137+ send(event)
138+ }
139+ }
140+
141+ private fun stream(): Flow<BusEvent> = channelFlow {
142+ val scope = this
143+ // Drain any stale reconnect signal from a previous collection cycle so it
144+ // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
145+ // stopped the upstream while triggerReconnect() queued a signal).
146+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
147+ var backoffMs = initialBackoffMs
148+ while (isActive) {
149+ _state.value = ConnectionState.Connecting
150+ try {
151+ // The SSE stream is long-lived: disable the request-level and socket
152+ // timeouts (the client default is 60s for REST) in the request config
153+ // so the connection isn't killed mid-stream. The idle-timeout watchdog
154+ // below handles half-open connections instead.
155+ client.sse(
156+ "event",
157+ request = {
158+ timeout {
159+ requestTimeoutMillis = Long.MAX_VALUE
160+ // Disable the socket (read) timeout entirely for the SSE stream.
161+ // A finite value would kill the connection whenever the server
162+ // is quiet for longer than that (idle sessions, slow agent runs),
163+ // causing a reconnect storm. Half-open detection is handled by
164+ // the idle-timeout watchdog below plus OkHttp's pingInterval on
165+ // HTTP/2 connections.
166+ socketTimeoutMillis = Long.MAX_VALUE
167+ }
168+ },
169+ ) {
170+ // The SSE block is open β€” the connection is established. Mark as
171+ // Connected immediately so downstream re-seed logic fires even when
172+ // the server only sends keep-alive comments (no data events).
173+ if (_state.value != ConnectionState.Connected) {
174+ _state.value = ConnectionState.Connected
175+ }
176+ // Clear any stale reconnect signal from a triggerReconnect() call
177+ // made while the previous stream was healthy, so it doesn't suppress
178+ // the next legitimate backoff.
179+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
180+ // Bridge ktor's `incoming` flow to a channel so each element can be
181+ // raced against the idle-timeout watchdog via select. We collect it
182+ // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
183+ // the collector as a child of this channelFlow, so when `incoming`
184+ // throws on an abnormal socket close β€” a TCP reset, i.e. the common
185+ // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST β€” the child's
186+ // failure cancels the whole channelFlow and bypasses the retry `catch`
187+ // below. That killed reconnect outright and surfaced the IOException as
188+ // an uncaught crash through the (handler-less) shareIn scope. Collecting
189+ // into our own channel and closing it with the cause keeps the failure
190+ // local: readSseEvents sees the closed channel, rethrows the cause, and
191+ // the normal retry/backoff path handles it. (A clean EOF closes the
192+ // channel with no cause β†’ readSseEvents breaks and reconnects as before.)
193+ val events = Channel<ServerSentEvent>(Channel.BUFFERED)
194+ val producer = scope.launch {
195+ try {
196+ incoming.collect { events.send(it) }
197+ events.close()
198+ } catch (c: CancellationException) {
199+ events.close(c)
200+ throw c
201+ } catch (t: Throwable) {
202+ // Deliver the failure via the channel rather than letting it
203+ // propagate up and cancel the parent channelFlow.
204+ events.close(t)
205+ }
206+ }
207+ // Reset the backoff only once real data actually arrives, not on
208+ // block entry: a server that accepts the connection then immediately
209+ // drops it would otherwise loop forever at the initial backoff and
210+ // never escalate the exponential ramp.
211+ var receivedAny = false
212+ try {
213+ readSseEvents(scope, events) {
214+ if (!receivedAny) {
215+ receivedAny = true
216+ backoffMs = initialBackoffMs
217+ }
218+ send(it)
219+ }
220+ } finally {
221+ producer.cancel()
222+ events.cancel()
223+ }
224+ }
225+ } catch (c: CancellationException) {
226+ _state.value = ConnectionState.Disconnected
227+ throw c
228+ } catch (e: Exception) {
229+ // If the scope was cancelled (e.g. connection close), the closed-client
230+ // exception is expected β€” don't log a spurious "stream error" warning.
231+ if (isSseAuthFailure(e)) {
232+ // Credentials are wrong β€” retrying won't help. Log a scrubbed
233+ // summary (class + status) instead of the full exception, whose
234+ // message carries the request URL and may include auth or paths.
235+ Log.w("EventStream", "SSE auth failed (401/403), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
236+ _state.value = ConnectionState.Failed
237+ if (!isActive) break
238+ // Don't complete the flow: a subscriber may fix the profile and call
239+ // triggerReconnect(). Suspend until an explicit reconnect signal
240+ // arrives (no backoff delay), then re-attempt the connection.
241+ reconnectSignal.receive()
242+ continue
243+ } else if (isActive) {
244+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
245+ }
246+ }
247+ _state.value = ConnectionState.Disconnected
248+ if (!isActive) break
249+ // Wait for the backoff, but allow a reconnect signal to cut it short.
250+ var signaled = false
251+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
252+ select {
253+ reconnectSignal.onReceive { signaled = true }
254+ onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
255+ }
256+ backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
257+ }
258+ }
259+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+114
@@ -0,0 +1,114 @@
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.client.request.header
14+import io.ktor.http.URLBuilder
15+import io.ktor.http.takeFrom
16+import io.ktor.serialization.kotlinx.json.json
17+import java.util.concurrent.TimeUnit
18+
19+/**
20+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
21+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
22+ * SSE plugin used for `GET /event`.
23+ */
24+object HttpClientFactory {
25+
26+ fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
27+ expectSuccess = true
28+
29+ engine {
30+ config {
31+ // A normal read timeout for REST calls. The SSE stream overrides this
32+ // to infinite inside the sse {} request block (see EventStreamClient),
33+ // so the long-lived event connection is never killed mid-stream.
34+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
35+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
36+ retryOnConnectionFailure(true)
37+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
38+ }
39+ }
40+
41+ install(ContentNegotiation) {
42+ json(OpencodeJson)
43+ }
44+
45+ install(SSE)
46+
47+ // Request-level timeout for REST calls. The SSE stream overrides this to
48+ // infinite inside the sse {} request block (see EventStreamClient) so the
49+ // long-lived /event connection isn't killed after 60s. The engine's connect
50+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
51+ install(HttpTimeout) {
52+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
53+ }
54+
55+ val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
57+
58+ if (profile.hasAuth) {
59+ if (isHttps) {
60+ install(Auth) {
61+ basic {
62+ credentials {
63+ BasicAuthCredentials(
64+ username = profile.username.orEmpty(),
65+ password = profile.password.orEmpty(),
66+ )
67+ }
68+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
69+ // The Auth plugin is only installed for HTTPS profiles β€” over HTTP,
70+ // even reactive (401-challenge) credential sending would leak
71+ // passwords in cleartext, so we don't install it at all.
72+ sendWithoutRequest { true }
73+ }
74+ }
75+ } else {
76+ // For HTTP profiles with auth, send credentials proactively in the
77+ // Authorization header via defaultRequest (below) so the user's intent
78+ // to connect over cleartext is honored, but we never install the Auth
79+ // plugin's reactive challenge-response which would silently re-send
80+ // credentials on any 401 without checking the protocol.
81+ }
82+ }
83+
84+ defaultRequest {
85+ url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
86+ // For HTTP profiles with auth, attach the Basic auth header proactively on
87+ // every request (the Auth plugin is skipped for non-HTTPS). The user chose
88+ // cleartext explicitly, so we honor that β€” but without the reactive challenge
89+ // logic that could re-send credentials silently.
90+ if (profile.hasAuth && !isHttps) {
91+ val credentials = java.util.Base64.getEncoder().encodeToString(
92+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
93+ )
94+ header("Authorization", "Basic $credentials")
95+ }
96+ }
97+ }
98+
99+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
100+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
101+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
102+ fun normalizeBaseUrl(raw: String): String {
103+ val trimmed = raw.trim()
104+ // Compare the scheme prefix case-insensitively (a lowercased copy) while
105+ // preserving the original casing of the rest of the URL in the output.
106+ val lowerTrimmed = trimmed.lowercase()
107+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
108+ trimmed
109+ } else {
110+ "https://$trimmed"
111+ }
112+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
113+ }
114+}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+151
@@ -0,0 +1,151 @@
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+ /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28+ const val retryMaxDelayMs = 30_000L
29+ /** Jitter factor for retry backoff (0.2 = Β±20% symmetric random jitter on each delay). */
30+ const val retryJitterFactor = 0.2
31+
32+ // --- SSE stream (EventStreamClient) ---
33+
34+ /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35+ const val sseInitialBackoffMs = 500L
36+ /** Cap on the SSE reconnect backoff. */
37+ const val sseMaxBackoffMs = 10_000L
38+ /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
39+ const val sseIdleTimeoutMs = 90_000L
40+ /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41+ const val sseEventBufferCapacity = 64
42+
43+ // --- Catalog cache (OpencodeApiClient) ---
44+
45+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
46+ const val catalogCacheTtlMs = 60_000L
47+
48+ // --- Session list (SessionListViewModel) ---
49+
50+ /** Max sessions to fetch previews for in one batch. */
51+ const val maxPreviewSessions = 50
52+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
53+ const val maxConcurrentPreviews = 8
54+ /** Max characters of a session's last message to keep as a list preview. */
55+ const val previewTextMaxLength = 200
56+ /** Debounce delay before fetching a session preview after an SSE update, so a burst
57+ * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
58+ * instead of launching one per event (each downloading the full message history). */
59+ const val previewDebounceMs = 500L
60+ /** Debounce for applying SessionUpdated SSE events to the session list state. During
61+ * active streaming the server emits these frequently; without debouncing each one
62+ * triggers a full list filter + sort + recomposition, causing scroll jank. */
63+ const val sessionUpdateDebounceMs = 300L
64+
65+ /** Grace period before a deferred session delete actually fires, during which an
66+ * Undo snackbar lets the user cancel it. */
67+ const val undoDeleteDelayMs = 5_000L
68+
69+ /** Grace period before a deferred server-profile delete actually fires, during which
70+ * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
71+ const val undoServerDeleteDelayMs = 5_000L
72+
73+ /** Grace period before a deferred crash-report delete actually fires, during which
74+ * an Undo snackbar lets the user cancel it. */
75+ const val undoReportDeleteDelayMs = 5_000L
76+
77+ // --- In-memory message store (SessionRepository.MessageStore) ---
78+
79+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
80+ const val maxInMemoryMessages = 500
81+
82+ // --- File browser (FileBrowserViewModel) ---
83+
84+ /** Debounce delay before firing a file-search request after the user stops typing. */
85+ const val fileSearchDebounceMs = 250L
86+
87+ // --- StateFlow sharing (WhileSubscribed) ---
88+
89+ /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
90+ const val stateFlowSubscriptionTimeoutMs = 5_000L
91+
92+ // --- Draft persistence (ChatViewModel) ---
93+
94+ /** Debounce delay before persisting a draft to disk after the user stops typing. */
95+ const val draftDebounceMs = 500L
96+
97+ /** Maximum characters allowed in the chat input field. A generous cap that prevents
98+ * a huge paste from stalling the UI (the field would otherwise buffer and lay out
99+ * an unbounded string) while leaving plenty of room for long prompts. */
100+ const val maxDraftLengthChars = 32_000
101+
102+ /** Show the "remaining characters" supportingText under the chat input once the
103+ * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
104+ * visible before it silently kicks in but doesn't clutter a normal short prompt. */
105+ const val draftCountdownThresholdFraction = 0.8f
106+
107+ /** Maximum characters allowed in a session title (rename dialog). Prevents the
108+ * server from rejecting an overly long title and keeps list rows readable. */
109+ const val maxSessionTitleChars = 200
110+
111+ // --- Profile store (ServerEditViewModel) ---
112+
113+ /** Timeout for loading a profile from DataStore before giving up. */
114+ const val profileLoadTimeoutMs = 5_000L
115+
116+ // --- SSE stream (EventStreamClient, AppContainer) ---
117+
118+ /** Delay before retrying the message-activity observer after a failure. */
119+ const val observerRetryDelayMs = 5_000L
120+ /** Max concurrent assistant runs to track for completion notifications. */
121+ const val activeRunsLimit = 200
122+
123+ // --- Markdown rendering (MarkdownText) ---
124+
125+ /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
126+ * library re-parses the full AST on every content change, so during a long streaming
127+ * response this is O(nΒ²) work. 50ms (~20fps) is smooth for progressively appearing
128+ * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
129+ const val streamingThrottleMs = 50L
130+
131+ // --- Snackbar one-shot events (ViewModels) ---
132+
133+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
134+ const val snackbarEventBufferCapacity = 16
135+
136+ /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
137+ * after a tap, giving immediate feedback that the refresh was triggered even when
138+ * the connection state is already Connected (so the ConnectionBanner doesn't change). */
139+ const val refreshFeedbackMs = 1_200L
140+
141+ // --- UI layout constants ---
142+
143+ /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
144+ const val twoPaneWidthThresholdDp = 840
145+ /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
146+ const val userBubbleWidthFraction = 0.85f
147+ /** Left pane weight in the two-pane layout (session list). */
148+ const val twoPaneLeftWeight = 0.38f
149+ /** Right pane weight in the two-pane layout (chat detail). */
150+ const val twoPaneRightWeight = 0.62f
151+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+347
@@ -0,0 +1,347 @@
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.PathInfo
13+import soy.iko.opencode.data.model.PermissionReplyBody
14+import soy.iko.opencode.data.model.PermissionResponse
15+import soy.iko.opencode.data.model.Project
16+import soy.iko.opencode.data.model.PromptPart
17+import soy.iko.opencode.data.model.PromptRequest
18+import soy.iko.opencode.data.model.ProvidersResponse
19+import soy.iko.opencode.data.model.Session
20+import soy.iko.opencode.data.model.UpdateSessionRequest
21+import io.ktor.client.HttpClient
22+import io.ktor.client.call.body
23+import io.ktor.client.plugins.ClientRequestException
24+import io.ktor.client.plugins.ResponseException
25+import io.ktor.client.plugins.ServerResponseException
26+import io.ktor.client.request.delete
27+import io.ktor.client.request.get
28+import io.ktor.client.request.header
29+import io.ktor.client.request.parameter
30+import io.ktor.client.request.patch
31+import io.ktor.client.request.post
32+import io.ktor.client.request.setBody
33+import io.ktor.http.ContentType
34+import io.ktor.http.contentType
35+import kotlinx.coroutines.delay
36+import kotlinx.coroutines.sync.Mutex
37+import kotlinx.coroutines.sync.withLock
38+import kotlin.random.Random
39+import java.net.URLEncoder
40+
41+/**
42+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
43+ * SSE stream lives in [EventStreamClient]; everything else is here.
44+ */
45+open class OpencodeApiClient private constructor(
46+ private val client: HttpClient?,
47+ @Suppress("unused") private val testMode: Boolean,
48+) {
49+ constructor(client: HttpClient) : this(client, false)
50+ protected constructor() : this(null, true)
51+
52+ // --- Catalog cache (providers/agents/commands) ---
53+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
54+ // A short-lived in-memory cache avoids redundant network calls when navigating
55+ // between sessions on the same server.
56+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
57+ // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
58+ // The lock is held during the fetch to prevent a thundering-herd stampede when
59+ // multiple coroutines miss the cache simultaneously.
60+ private val providersMutex = Mutex()
61+ private val agentsMutex = Mutex()
62+ private val commandsMutex = Mutex()
63+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
64+ private var cachedAgents: CachedEntry<List<Agent>>? = null
65+ private var cachedCommands: CachedEntry<List<Command>>? = null
66+
67+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
68+ open suspend fun ping() {
69+ withRetry { client!!.get("global/health").body<String>() }
70+ }
71+
72+ open suspend fun listSessions(): List<Session> = withRetry {
73+ client!!.get("session").body()
74+ }
75+
76+ /**
77+ * Create a session, optionally in a specific worktree [directory]. The directory is a
78+ * query parameter (not a body field): the server records it on the session and resolves
79+ * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
80+ * β€” and shouldn't β€” re-send it. A null/blank [directory] lets the server fall back to
81+ * its own launch cwd, preserving the pre-directory behavior.
82+ */
83+ open suspend fun createSession(title: String? = null, directory: String? = null): Session {
84+ // Generate the idempotency key before entering withRetry so every retry attempt
85+ // shares it. Without this, a POST that reached the server but whose response was
86+ // lost (reset/timeout) would be retried and create a second orphan session.
87+ val idempotencyKey = java.util.UUID.randomUUID().toString()
88+ val dir = directory?.takeIf { it.isNotBlank() }
89+ return withRetry {
90+ client!!.post("session") {
91+ contentType(ContentType.Application.Json)
92+ header("Idempotency-Key", idempotencyKey)
93+ if (dir != null) parameter("directory", dir)
94+ setBody(CreateSessionRequest(title = title))
95+ }.body()
96+ }
97+ }
98+
99+ /** List the projects (worktree roots) the server knows about, for the directory picker. */
100+ open suspend fun listProjects(): List<Project> = withRetry {
101+ client!!.get("project").body()
102+ }
103+
104+ /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
105+ * session with no directory override runs in. */
106+ open suspend fun currentPath(): PathInfo = withRetry {
107+ client!!.get("path").body()
108+ }
109+
110+ open suspend fun updateSession(id: String, title: String): Session = withRetry {
111+ client!!.patch("session/${encode(id)}") {
112+ contentType(ContentType.Application.Json)
113+ setBody(UpdateSessionRequest(title = title))
114+ }.body()
115+ }
116+
117+ open suspend fun deleteSession(id: String) {
118+ try {
119+ withRetry { client!!.delete("session/${encode(id)}").body<String>() }
120+ } catch (t: ClientRequestException) {
121+ // A DELETE that succeeded but whose response was lost gets retried; the retry
122+ // hits an already-gone session and returns 404. Deleting a non-existent session
123+ // is effectively success, so swallow 404 rather than surfacing a spurious failure.
124+ if (t.response.status.value != 404) throw t
125+ }
126+ }
127+
128+ open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
129+ client!!.get("session/${encode(sessionId)}/message").body()
130+ }
131+
132+ open suspend fun sendPrompt(
133+ sessionId: String,
134+ text: String,
135+ model: ModelRef? = null,
136+ agent: String? = null,
137+ ): MessageWithParts {
138+ // Generate the idempotency key *before* entering withRetry so all retry
139+ // attempts share the same key. If the key were generated inside withRetry,
140+ // each attempt would get a fresh UUID and the server couldn't deduplicate a
141+ // request that reached it but whose response was lost (e.g. timeout).
142+ val idempotencyKey = java.util.UUID.randomUUID().toString()
143+ return withRetry {
144+ client!!.post("session/${encode(sessionId)}/message") {
145+ contentType(ContentType.Application.Json)
146+ header("Idempotency-Key", idempotencyKey)
147+ setBody(
148+ PromptRequest(
149+ parts = listOf(PromptPart(text = text)),
150+ model = model,
151+ agent = agent,
152+ ),
153+ )
154+ }.body()
155+ }
156+ }
157+
158+ open suspend fun abort(sessionId: String) {
159+ withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
160+ }
161+
162+ /** Invoke a slash-command by name via `POST /session/:id/command`. */
163+ open suspend fun runCommand(
164+ sessionId: String,
165+ command: String,
166+ arguments: String = "",
167+ agent: String? = null,
168+ ): MessageWithParts {
169+ // Generate the idempotency key before entering withRetry so all retry
170+ // attempts share the same key, preventing duplicate command execution if
171+ // the server processed the request but the response was lost.
172+ val idempotencyKey = java.util.UUID.randomUUID().toString()
173+ return withRetry {
174+ client!!.post("session/${encode(sessionId)}/command") {
175+ contentType(ContentType.Application.Json)
176+ header("Idempotency-Key", idempotencyKey)
177+ setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
178+ }.body()
179+ }
180+ }
181+
182+ open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
183+ val now = System.currentTimeMillis()
184+ val cached = cachedProviders
185+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
186+ return@withLock cached.value
187+ }
188+ val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
189+ cachedProviders = CachedEntry(value, System.currentTimeMillis())
190+ value
191+ }
192+
193+ open suspend fun agents(): List<Agent> = agentsMutex.withLock {
194+ val now = System.currentTimeMillis()
195+ val cached = cachedAgents
196+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
197+ return@withLock cached.value
198+ }
199+ val value = withRetry { client!!.get("agent").body<List<Agent>>() }
200+ cachedAgents = CachedEntry(value, System.currentTimeMillis())
201+ value
202+ }
203+
204+ open suspend fun commands(): List<Command> = commandsMutex.withLock {
205+ val now = System.currentTimeMillis()
206+ val cached = cachedCommands
207+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
208+ return@withLock cached.value
209+ }
210+ val value = withRetry { client!!.get("command").body<List<Command>>() }
211+ cachedCommands = CachedEntry(value, System.currentTimeMillis())
212+ value
213+ }
214+
215+ /** Invalidate the catalog cache (e.g. on server switch). */
216+ open suspend fun invalidateCache() {
217+ providersMutex.withLock { cachedProviders = null }
218+ agentsMutex.withLock { cachedAgents = null }
219+ commandsMutex.withLock { cachedCommands = null }
220+ }
221+
222+ open suspend fun invalidateProvidersCache() {
223+ providersMutex.withLock { cachedProviders = null }
224+ }
225+
226+ open suspend fun invalidateAgentsCache() {
227+ agentsMutex.withLock { cachedAgents = null }
228+ }
229+
230+ open suspend fun invalidateCommandsCache() {
231+ commandsMutex.withLock { cachedCommands = null }
232+ }
233+
234+ /** Respond to a permission request so a paused tool run can proceed. */
235+ open suspend fun respondPermission(
236+ sessionId: String,
237+ permissionId: String,
238+ response: PermissionResponse,
239+ ): String = try {
240+ withRetry {
241+ client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
242+ contentType(ContentType.Application.Json)
243+ setBody(PermissionReplyBody(response.wire))
244+ }.body<String>()
245+ }
246+ } catch (t: ClientRequestException) {
247+ // A response that reached the server but whose reply was lost gets retried; the
248+ // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
249+ // the answer landed, so treat them as success instead of a spurious failure.
250+ val code = t.response.status.value
251+ if (code != 404 && code != 409) throw t
252+ ""
253+ }
254+
255+ // --- Files ---
256+
257+ open suspend fun findFiles(query: String): List<String> = withRetry {
258+ client!!.get("find/file") { parameter("query", query) }.body()
259+ }
260+
261+ open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
262+ client!!.get("file") { parameter("path", path) }.body()
263+ }
264+
265+ open suspend fun readFile(path: String): FileContent = withRetry {
266+ client!!.get("file/content") { parameter("path", path) }.body()
267+ }
268+
269+ open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
270+ client!!.get("file/status").body()
271+ }
272+}
273+
274+private suspend fun <T> withRetry(
275+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
276+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
277+ block: suspend () -> T,
278+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
279+
280+/**
281+ * Retry a suspending block up to [maxAttempts] times with exponential backoff.
282+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
283+ * non-retryable β€” a 401 or 404 won't succeed on retry, so they surface immediately
284+ * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
285+ * server errors are retried. Exposed as `internal` so the retry/skip rules are
286+ * unit-testable without an HTTP server.
287+ */
288+internal suspend fun <T> withRetryInternal(
289+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
290+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
291+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
292+ block: suspend () -> T,
293+): T {
294+ var lastError: Throwable? = null
295+ for (attempt in 1..maxAttempts) {
296+ try {
297+ return block()
298+ } catch (c: kotlinx.coroutines.CancellationException) {
299+ throw c
300+ } catch (t: ClientRequestException) {
301+ // 429 (Too Many Requests) is transient β€” retry with backoff.
302+ // All other 4xx are non-retryable.
303+ if (t.response.status.value != 429) throw t
304+ lastError = t
305+ if (attempt < maxAttempts) {
306+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
307+ }
308+ } catch (t: ServerResponseException) {
309+ // 5xx server errors are retryable with backoff.
310+ lastError = t
311+ if (attempt < maxAttempts) {
312+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
313+ }
314+ } catch (t: ResponseException) {
315+ // 3xx (redirect limit exceeded) is non-retryable.
316+ throw t
317+ } catch (t: Exception) {
318+ lastError = t
319+ if (attempt < maxAttempts) {
320+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
321+ }
322+ }
323+ }
324+ throw lastError ?: IllegalStateException("withRetry failed without error")
325+}
326+
327+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
328+ * so the backoff formula is unit-testable without an HTTP server. */
329+internal fun jitteredBackoff(
330+ attempt: Int,
331+ initialDelayMs: Long,
332+ jitterFactor: Double,
333+): Long {
334+ // Guard against Int overflow from the shift and Long overflow from
335+ // the multiplication at high attempt counts.
336+ val shift = (attempt - 1).coerceAtMost(30)
337+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
338+ // Symmetric jitter: vary by Β±jitterFactor so the delay is sometimes
339+ // shorter, sometimes longer β€” spreads concurrent client retries and
340+ // prevents thundering-herd reconnection storms.
341+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
342+ return (baseDelay + jitter).coerceAtLeast(0)
343+}
344+
345+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
346+private fun encode(segment: String): String =
347+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+37
@@ -0,0 +1,37 @@
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+ encodeDefaults = true
29+ explicitNulls = false
30+ classDiscriminator = "type"
31+ serializersModule = SerializersModule {
32+ polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33+ polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34+ polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35+ polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36+ }
37+}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktnew file+216
@@ -0,0 +1,216 @@
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.Job
11+import kotlinx.coroutines.SupervisorJob
12+import kotlinx.coroutines.cancel
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.launch
18+import java.io.File
19+import java.io.PrintWriter
20+import java.io.StringWriter
21+import java.text.SimpleDateFormat
22+import java.util.Date
23+import java.util.Locale
24+import soy.iko.opencode.util.runCatchingCancellable
25+
26+/**
27+ * A self-contained crash reporter with no network or third-party backend. An uncaught
28+ * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29+ * text report (timestamp, device/app metadata, and the full stack trace), then the
30+ * previous handler is re-invoked so the process still dies normally.
31+ *
32+ * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33+ * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34+ * without pulling in a hosted analytics SDK.
35+ */
36+class CrashLogger private constructor(private val appContext: Context) {
37+
38+ data class CrashReport(
39+ val fileName: String,
40+ val timestamp: Long,
41+ val preview: String,
42+ )
43+
44+ private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46+
47+ private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48+ val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
49+
50+ fun install() {
51+ val previous = Thread.getDefaultUncaughtExceptionHandler()
52+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53+ runCatching { writeReport(thread, throwable) }
54+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
55+ previous?.uncaughtException(thread, throwable)
56+ }
57+ // Load crash reports off the main thread so startup isn't blocked by file I/O
58+ // (listFiles + reading the first line of each report). The StateFlow updates
59+ // whenever the scan completes, so the Diagnostics screen reflects the result.
60+ scope.launch { runCatchingCancellable { refresh() } }
61+ }
62+
63+ /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
64+ * refresh() runs on the multi-threaded IO dispatcher from several call sites
65+ * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
66+ private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
67+
68+ fun refresh() {
69+ val generation = refreshGeneration.incrementAndGet()
70+ val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
71+ ?.sortedByDescending { it.lastModified() }
72+ ?.map { f ->
73+ CrashReport(
74+ fileName = f.name,
75+ timestamp = f.lastModified(),
76+ preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
77+ )
78+ }
79+ .orEmpty()
80+ // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
81+ // that started before a delete) can't momentarily re-show a just-removed report.
82+ if (generation == refreshGeneration.get()) _reports.value = scanned
83+ }
84+
85+ fun readReport(fileName: String): String? {
86+ val file = File(crashDir, fileName).canonicalFile
87+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
88+ return runCatching { file.readText() }.getOrNull()
89+ }
90+
91+ fun deleteReport(fileName: String) {
92+ scope.launch {
93+ val file = File(crashDir, fileName).canonicalFile
94+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
95+ file.delete()
96+ }
97+ refresh()
98+ }
99+ }
100+
101+ /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
103+
104+ /**
105+ * Schedule [fileName] for deletion after [delayMs], cancellable via
106+ * [cancelScheduledDelete]. Runs on the logger's own long-lived scope β€” not the
107+ * Diagnostics screen's composition scope β€” so navigating away during the undo window
108+ * still commits the delete instead of silently dropping it. A repeated schedule for
109+ * the same name replaces the prior timer.
110+ */
111+ fun scheduleDelete(fileName: String, delayMs: Long) {
112+ val job = scope.launch {
113+ delay(delayMs)
114+ // Atomically claim the delete by removing our own map entry BEFORE deleting.
115+ // The remove is the synchronization point with cancelScheduledDelete: whichever
116+ // removes the entry first wins. If an undo raced the timer and removed it first,
117+ // this returns false and we skip the delete β€” so the file survives, matching the
118+ // `true` that cancel returned to the UI. Deleting first (then removing) would let
119+ // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
120+ // remove(name, thisJob) also no-ops for a stale job after a reschedule.
121+ val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
122+ if (!claimed) return@launch
123+ val file = File(crashDir, fileName).canonicalFile
124+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
125+ file.delete()
126+ }
127+ refresh()
128+ }
129+ pendingDeletes.put(fileName, job)?.cancel()
130+ }
131+
132+ /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
133+ * pending (undo succeeded), false if the delete had already fired. */
134+ fun cancelScheduledDelete(fileName: String): Boolean {
135+ val job = pendingDeletes.remove(fileName) ?: return false
136+ job.cancel()
137+ return true
138+ }
139+
140+ fun clearAll() {
141+ scope.launch {
142+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
143+ refresh()
144+ }
145+ }
146+
147+ fun reportCount(): Int = _reports.value.size
148+
149+ /** Cancel the internal coroutine scope so background work doesn't outlive the
150+ * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
151+ * intentionally left installed β€” a crash during shutdown still needs to be logged. */
152+ fun shutdown() {
153+ scope.cancel()
154+ }
155+
156+ private fun writeReport(thread: Thread, throwable: Throwable) {
157+ val now = Date()
158+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
159+ // Append a short random suffix so two crashes in the same millisecond don't
160+ // overwrite each other.
161+ val suffix = java.lang.Long.toHexString(System.nanoTime())
162+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
163+ val sw = StringWriter()
164+ PrintWriter(sw).use { pw ->
165+ pw.println("opencode-android crash report")
166+ pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
167+ pw.println("Thread: ${thread.name}")
168+ pw.println("App version: ${appVersion()}")
169+ pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
170+ pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
171+ pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
172+ pw.println()
173+ pw.println(throwable.javaClass.name + ": " + throwable.message)
174+ throwable.printStackTrace(pw)
175+ }
176+ // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
177+ // message (and every "Caused by:" message) verbatim, so scrubbing only the
178+ // top-level message above is futile β€” Ktor/OkHttp embed the full request URL
179+ // in those messages, which may contain auth or internal paths. Scrubbing the
180+ // assembled text guarantees no URL survives anywhere in the stored report.
181+ file.writeText(scrubUrls(sw.toString()))
182+ }
183+
184+ private fun appVersion(): String = runCatching {
185+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
186+ appContext.packageManager.getPackageInfo(
187+ appContext.packageName,
188+ PackageManager.PackageInfoFlags.of(0),
189+ )
190+ } else {
191+ @Suppress("DEPRECATION")
192+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
193+ }
194+ "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
195+ }.getOrDefault("unknown")
196+
197+ companion object {
198+ /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199+ internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
200+
201+ /**
202+ * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
203+ * redaction (used on the whole crash report, including exception messages and
204+ * stack traces produced by printStackTrace) can be unit-tested in isolation.
205+ */
206+ internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
207+
208+ @Volatile
209+ private var instance: CrashLogger? = null
210+
211+ fun get(context: Context): CrashLogger =
212+ instance ?: synchronized(this) {
213+ instance ?: CrashLogger(context.applicationContext).also { instance = it }
214+ }
215+ }
216+}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+266
@@ -0,0 +1,266 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.flow.update
10+import android.util.Log
11+import kotlinx.coroutines.launch
12+import kotlinx.coroutines.withContext
13+import java.util.concurrent.Executors
14+
15+/**
16+ * Persists the in-progress message draft for each session so it survives
17+ * back-navigation and process death. Backed by SharedPreferences because the
18+ * UI needs the initial value synchronously and drafts are low-stakes data.
19+ *
20+ * The prefs file is loaded on a background thread at construction time. Until
21+ * the load completes, [drafts] reports an empty map and [get] returns "" rather
22+ * than blocking the caller (previously a [CountDownLatch] could stall the main
23+ * thread for up to 2 seconds on a slow disk).
24+ */
25+open class DraftStore private constructor(
26+ private val appContext: Context?,
27+ private val scope: CoroutineScope?,
28+ @Suppress("unused") private val testMode: Boolean,
29+) {
30+ constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31+ protected constructor() : this(null, null, true)
32+
33+ private val prefs by lazy {
34+ val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35+ ?: error("No context β€” override methods in test subclass")
36+ prefsInitialized.set(true)
37+ sp
38+ }
39+
40+ /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
41+ * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
42+ * write on the main thread is safe or whether the write must be deferred to a
43+ * background thread to avoid blocking on first-time disk I/O. */
44+ private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
45+
46+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
47+ * so the main thread is never blocked on disk I/O. */
48+ private val flushExecutor by lazy {
49+ Executors.newSingleThreadExecutor { r ->
50+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
51+ }
52+ }
53+
54+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
55+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
56+ open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
57+
58+ /** Queued follow-up messages per session, persisted under a namespaced key so a
59+ * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
60+ * ChatViewModel would otherwise lose it, along with the cleared draft). */
61+ private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
62+
63+ private val _ready = MutableStateFlow(false)
64+ /** True once the background prefs load has completed. */
65+ open val ready: StateFlow<Boolean> = _ready.asStateFlow()
66+
67+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
68+ * finished. For these the in-memory state is authoritative β€” the load must not
69+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
70+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
71+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
72+ * but live under different prefs keys. */
73+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
74+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
75+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
76+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
77+
78+ private fun markDraftTouched(sessionId: String) {
79+ if (!_ready.value) touchedDraftKeys.add(sessionId)
80+ }
81+ private fun markFollowUpTouched(sessionId: String) {
82+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
83+ }
84+
85+ init {
86+ // Eagerly load the prefs file on a background thread so the first main-thread
87+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
88+ // so callers can observe readiness without blocking.
89+ if (scope != null && appContext != null) {
90+ scope.launch(Dispatchers.IO) {
91+ val all = runCatching {
92+ prefs.all.mapValues { it.value as? String ?: "" }
93+ }.getOrDefault(emptyMap())
94+ // Partition persisted keys: follow-up keys carry a prefix, everything else
95+ // is a plain per-session draft.
96+ val drafts = mutableMapOf<String, String>()
97+ val followUps = mutableMapOf<String, String>()
98+ all.forEach { (k, v) ->
99+ if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
100+ else drafts[k] = v
101+ }
102+ // Merge instead of overwriting: any in-memory writes that arrived before
103+ // the background load completed (e.g. a share-intent draft injected via
104+ // setImmediate) take priority over the persisted snapshot so they aren't
105+ // clobbered. For keys the user explicitly touched before the load finished,
106+ // drop the persisted entry first so a value they *cleared* isn't resurrected
107+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
108+ _drafts.update { current ->
109+ drafts.apply {
110+ touchedDraftKeys.forEach { remove(it) }
111+ putAll(current)
112+ }
113+ }
114+ _followUps.update { current ->
115+ followUps.apply {
116+ touchedFollowUpKeys.forEach { remove(it) }
117+ putAll(current)
118+ }
119+ }
120+ _ready.value = true
121+ }
122+ } else {
123+ _ready.value = true
124+ }
125+ }
126+
127+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
128+ open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
129+
130+ /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
131+ open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
132+
133+ /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
134+ * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
135+ * so a follow-up queued just before process death is flushed before the process exits.
136+ * A blank [text] clears the stored follow-up. */
137+ open fun flushFollowUp(sessionId: String, text: String) {
138+ markFollowUpTouched(sessionId)
139+ _followUps.update { current ->
140+ val updated = current.toMutableMap()
141+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
142+ updated
143+ }
144+ writeAsync(FOLLOWUP_PREFIX + sessionId, text)
145+ }
146+
147+ /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
148+ * first-time disk I/O β€” see [flushDraft] for the prefs-not-yet-open rationale. */
149+ private fun writeAsync(key: String, text: String) {
150+ val write = {
151+ runCatching {
152+ prefs.edit().apply {
153+ if (text.isBlank()) remove(key) else putString(key, text)
154+ }.apply()
155+ }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
156+ }
157+ if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
158+ }
159+
160+ /** Update the in-memory draft immediately without persisting to disk.
161+ * Used when navigation must see the new value synchronously (e.g. share-intent
162+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
163+ open fun setImmediate(sessionId: String, text: String) {
164+ markDraftTouched(sessionId)
165+ _drafts.update { current ->
166+ val updated = current.toMutableMap()
167+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
168+ updated
169+ }
170+ }
171+
172+ open suspend fun set(sessionId: String, text: String) {
173+ markDraftTouched(sessionId)
174+ _drafts.update { current ->
175+ val updated = current.toMutableMap()
176+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
177+ updated
178+ }
179+ withContext(Dispatchers.IO) {
180+ runCatching {
181+ prefs.edit().apply {
182+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
183+ }.apply()
184+ }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
185+ }
186+ }
187+
188+ /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
189+ * Also clears any queued follow-up so a deleted session leaves nothing behind. */
190+ open suspend fun remove(sessionId: String) {
191+ markDraftTouched(sessionId)
192+ markFollowUpTouched(sessionId)
193+ _drafts.update { current ->
194+ val updated = current.toMutableMap()
195+ updated.remove(sessionId)
196+ updated
197+ }
198+ _followUps.update { current ->
199+ val updated = current.toMutableMap()
200+ updated.remove(sessionId)
201+ updated
202+ }
203+ withContext(Dispatchers.IO) {
204+ runCatching {
205+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
206+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
207+ }
208+ }
209+
210+ /**
211+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
212+ * viewModelScope is already cancelled and a suspending write can't be used.
213+ *
214+ * If the SharedPreferences file has already been opened (the normal case β€” the
215+ * background init load touches it shortly after construction), the write uses
216+ * [apply] (asynchronous) and returns immediately without blocking the main thread.
217+ *
218+ * If the prefs file has NOT been opened yet (a rare race where onCleared fires
219+ * before the background init), the write is dispatched to a background thread
220+ * instead of blocking the main thread on a synchronous disk read. The draft
221+ * may be lost if the process exits before the write completes, but this is
222+ * preferable to an ANR.
223+ */
224+ open fun flushDraft(sessionId: String, text: String) {
225+ markDraftTouched(sessionId)
226+ _drafts.update { current ->
227+ val updated = current.toMutableMap()
228+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
229+ updated
230+ }
231+ if (prefsInitialized.get()) {
232+ // Prefs already opened β€” the lazy resolves instantly and apply() is async.
233+ runCatching {
234+ prefs.edit().apply {
235+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
236+ }.apply()
237+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
238+ } else {
239+ // Prefs not yet opened β€” dispatch to a background thread to avoid a
240+ // synchronous disk read on the main thread (which would ANR). The draft
241+ // may be lost if the process exits before the write completes.
242+ flushExecutor.execute {
243+ runCatching {
244+ prefs.edit().apply {
245+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
246+ }.apply()
247+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
248+ }
249+ }
250+ }
251+
252+ private companion object {
253+ /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
254+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
255+ * id (which are non-blank, space-free tokens like "ses_..."). */
256+ const val FOLLOWUP_PREFIX = "followup"
257+ }
258+
259+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
260+ open fun shutdown() {
261+ flushExecutor.shutdown()
262+ // Await pending flushDraft writes so drafts aren't lost if the process exits
263+ // immediately after shutdown (e.g. ANR-triggered process kill).
264+ runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
265+ }
266+}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktnew file+78
@@ -0,0 +1,78 @@
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.RedirectResponseException
6+import io.ktor.client.plugins.ResponseException
7+import io.ktor.client.plugins.ServerResponseException
8+import java.io.IOException
9+import java.net.ConnectException
10+import java.net.SocketTimeoutException
11+import java.net.UnknownHostException
12+
13+/**
14+ * User-facing categories for a thrown error. Extracted so the classification logic is
15+ * unit-testable without an Android [android.content.Context] (the mapping from a kind
16+ * to a localized string lives in [friendlyError]).
17+ */
18+enum class ErrorKind {
19+ /** Server host could not be resolved / connection refused. */
20+ NOT_REACHABLE,
21+
22+ /** Connect or read timed out. */
23+ TIMEOUT,
24+
25+ /** Server returned a 5xx response. */
26+ SERVER,
27+
28+ /** Server returned a 4xx response (auth, not-found, etc.). */
29+ CLIENT,
30+
31+ /** Any other network-level I/O failure. */
32+ NETWORK,
33+
34+ /** Anything that isn't network-related. */
35+ UNKNOWN,
36+}
37+
38+/**
39+ * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40+ * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41+ * by a library still classifies correctly.
42+ */
43+fun classifyError(t: Throwable): ErrorKind {
44+ var current: Throwable? = t
45+ // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46+ // IOException) still classify by their root.
47+ var hops = 0
48+ while (current != null && hops < 8) {
49+ when (current) {
50+ is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51+ is SocketTimeoutException, is HttpRequestTimeoutException ->
52+ return ErrorKind.TIMEOUT
53+ is ServerResponseException -> return ErrorKind.SERVER
54+ is ClientRequestException -> return ErrorKind.CLIENT
55+ is RedirectResponseException -> return ErrorKind.CLIENT
56+ is IOException -> return ErrorKind.NETWORK
57+ else -> {
58+ // Any other ResponseException (unrecognized status family).
59+ if (current is ResponseException) return ErrorKind.SERVER
60+ }
61+ }
62+ current = current.cause
63+ hops++
64+ }
65+ return ErrorKind.UNKNOWN
66+}
67+
68+/** The HTTP status code carried by a client/server error, if any. */
69+fun responseStatusCode(t: Throwable): Int? {
70+ var current: Throwable? = t
71+ var hops = 0
72+ while (current != null && hops < 8) {
73+ if (current is ResponseException) return current.response.status.value
74+ current = current.cause
75+ hops++
76+ }
77+ return null
78+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+237
@@ -0,0 +1,237 @@
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.sync.Mutex
15+import kotlinx.coroutines.sync.withLock
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.flowOn
18+import kotlinx.coroutines.flow.map
19+import kotlinx.coroutines.withContext
20+import kotlinx.serialization.Serializable
21+import kotlinx.serialization.builtins.ListSerializer
22+
23+private val Context.dataStore by preferencesDataStore(name = "server_profiles")
24+
25+/**
26+ * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
27+ * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
28+ * by profile id, and merged back in on read.
29+ */
30+open class ProfileStore private constructor(
31+ private val appContext: Context?,
32+ @Suppress("unused") private val testMode: Boolean,
33+) {
34+ constructor(context: Context) : this(context.applicationContext, false)
35+ protected constructor() : this(null, true)
36+
37+ private val profilesKey = stringPreferencesKey("profiles_json")
38+
39+ /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
40+ * DataStore emission. The mutex serializes concurrent callers so a second collector waits
41+ * for the migration's writes to land (rather than skipping ahead and reading empty secure
42+ * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
43+ private val migrationMutex = Mutex()
44+ @Volatile private var migrationDone = false
45+
46+ private val securePrefs: SharedPreferences? by lazy {
47+ val ctx = appContext ?: return@lazy null
48+ runCatching {
49+ val masterKey = MasterKey.Builder(ctx)
50+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
51+ .build()
52+ EncryptedSharedPreferences.create(
53+ ctx,
54+ "server_secrets",
55+ masterKey,
56+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
57+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
58+ )
59+ }.getOrElse {
60+ Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
61+ null
62+ }
63+ }
64+
65+ /** True when EncryptedSharedPreferences could not be initialized. */
66+ open val securePrefsUnavailable: Boolean get() = securePrefs == null
67+
68+ /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
69+ * can still connect β€” but passwords won't survive an app reinstall. */
70+ private val fallbackPrefs: SharedPreferences by lazy {
71+ appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
72+ }
73+
74+ private fun prefsForPasswords(): SharedPreferences =
75+ securePrefs ?: fallbackPrefs
76+
77+ /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
78+ * then clear the fallback. Runs at most once per process β€” subsequent calls
79+ * are no-ops β€” so DataStore re-emissions don't repeatedly scan SharedPreferences. */
80+ private suspend fun migrateFallbackPasswords() {
81+ if (migrationDone) return
82+ migrationMutex.withLock {
83+ // Re-check inside the lock: a concurrent caller may have completed the migration
84+ // while we were waiting. Serializing here (rather than a bare compareAndSet that
85+ // marks done before doing the work) ensures a second collector doesn't read the
86+ // secure store before the first caller's write has populated it.
87+ if (migrationDone) return
88+ val secure = securePrefs
89+ if (secure == null) {
90+ // No secure store to migrate into; nothing to do, and no point retrying.
91+ migrationDone = true
92+ return
93+ }
94+ withContext(Dispatchers.IO) {
95+ val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
96+ if (fallbackKeys.isNotEmpty()) {
97+ // Write passwords to the secure store FIRST, then remove them from the
98+ // plaintext fallback. SharedPreferences.apply() persists to disk
99+ // asynchronously, so removing from fallback before the secure batch lands
100+ // could lose the password if the process dies between the two writes.
101+ // Writing secure first makes the worst case a harmless duplicate (both
102+ // stores hold it; the migration is idempotent) rather than data loss.
103+ secure.edit().apply {
104+ for (key in fallbackKeys) {
105+ val pw = fallbackPrefs.getString(key, null) ?: continue
106+ putString(key, pw)
107+ }
108+ }.apply()
109+ fallbackPrefs.edit().apply {
110+ for (key in fallbackKeys) remove(key)
111+ }.apply()
112+ }
113+ }
114+ // Only mark done once the writes are in the secure store's in-memory cache (apply()
115+ // updates it synchronously), so the next collector past the mutex reads real values.
116+ migrationDone = true
117+ }
118+ }
119+
120+ /** Stored shape on DataStore (everything except the secret password). */
121+ @Serializable
122+ private data class StoredProfile(
123+ val id: String,
124+ val label: String,
125+ val baseUrl: String,
126+ val username: String? = null,
127+ val lastUsed: Long = 0,
128+ )
129+
130+ open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
131+ // Migrate any orphaned plaintext passwords to secure prefs on first load.
132+ migrateFallbackPasswords()
133+ val json = prefs[profilesKey] ?: return@map emptyList()
134+ val stored = runCatching {
135+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
136+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
137+ .getOrDefault(emptyList())
138+ .sortedByDescending { it.lastUsed }
139+ // Batch all password lookups into a single IO dispatch instead of one
140+ // withContext round-trip per profile (N dispatches β†’ 1). DataStore already
141+ // runs this map block on IO, but each passwordFor() call still pays the
142+ // suspend + redispatch overhead.
143+ val pwPrefs = prefsForPasswords()
144+ stored.map { it.toProfile(pwPrefs) }
145+ }
146+ // The .map above runs in the *collector's* context, and every consumer collects on
147+ // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
148+ // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
149+ ?.flowOn(Dispatchers.IO)
150+ ?: kotlinx.coroutines.flow.flowOf(emptyList())
151+
152+ private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
153+ val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
154+ return ServerProfile(
155+ id = id,
156+ label = label,
157+ baseUrl = baseUrl,
158+ username = username,
159+ password = pw,
160+ lastUsed = lastUsed,
161+ )
162+ }
163+
164+ open suspend fun resolve(profile: ServerProfile): ServerProfile {
165+ val pw = if (profile.hasAuth) {
166+ withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
167+ } else {
168+ null
169+ }
170+ return profile.copy(password = pw)
171+ }
172+
173+ open suspend fun save(profile: ServerProfile) {
174+ // Write the password first so that if the DataStore write fails, we're left
175+ // with an orphaned password (harmless, cleaned up on next save/delete) rather
176+ // than a profile with no password (which would break authentication).
177+ withContext(Dispatchers.IO) {
178+ prefsForPasswords().edit().apply {
179+ val pw = profile.password
180+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
181+ putString(passwordKey(profile.id), pw)
182+ } else {
183+ remove(passwordKey(profile.id))
184+ }
185+ }.apply()
186+ }
187+ appContext!!.dataStore.edit { prefs ->
188+ val current = prefs[profilesKey]?.let {
189+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
190+ .getOrDefault(emptyList())
191+ } ?: emptyList()
192+ // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
193+ // couldn't read the existing value due to a DataStore timeout), preserve
194+ // the existing nonzero lastUsed so saving an edit doesn't reset the
195+ // profile's sort position in the server list.
196+ val preservedLastUsed = if (profile.lastUsed == 0L) {
197+ current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
198+ } else {
199+ profile.lastUsed
200+ }
201+ val stored = StoredProfile(
202+ id = profile.id,
203+ label = profile.label,
204+ baseUrl = profile.baseUrl,
205+ username = profile.username?.takeIf { it.isNotBlank() },
206+ lastUsed = preservedLastUsed,
207+ )
208+ val updated = current.filterNot { it.id == profile.id } + stored
209+ prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
210+ }
211+ }
212+
213+ open suspend fun delete(id: String) {
214+ // Delete the profile from DataStore first, then the password. If the process
215+ // dies between the two, an orphaned password remains (harmless, cleaned up on
216+ // next save/delete) rather than a profile with no password.
217+ appContext!!.dataStore.edit { prefs ->
218+ val current = prefs[profilesKey]?.let {
219+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
220+ .getOrDefault(emptyList())
221+ } ?: emptyList()
222+ prefs[profilesKey] = OpencodeJson.encodeToString(
223+ ListSerializer(StoredProfile.serializer()),
224+ current.filterNot { it.id == id },
225+ )
226+ }
227+ // Clean up the password in both secure and fallback prefs so no orphaned
228+ // plaintext password lingers if secure prefs were temporarily unavailable.
229+ withContext(Dispatchers.IO) {
230+ val key = passwordKey(id)
231+ securePrefs?.edit()?.remove(key)?.apply()
232+ fallbackPrefs.edit().remove(key).apply()
233+ }
234+ }
235+
236+ private fun passwordKey(id: String) = "pw_$id"
237+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktnew file+461
@@ -0,0 +1,461 @@
1+package soy.iko.opencode.data.repo
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.MessagePartRemoved
6+import soy.iko.opencode.data.model.MessagePartUpdated
7+import soy.iko.opencode.data.model.MessageRemoved
8+import soy.iko.opencode.data.model.MessageUpdated
9+import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ModelRef
11+import soy.iko.opencode.data.model.Part
12+import soy.iko.opencode.data.model.SessionError
13+import soy.iko.opencode.data.model.SessionIdle
14+import soy.iko.opencode.data.model.UnknownMessage
15+import soy.iko.opencode.data.network.EventStreamClient
16+import soy.iko.opencode.data.network.NetworkConfig
17+import soy.iko.opencode.data.network.OpencodeApiClient
18+import soy.iko.opencode.util.runCatchingCancellable
19+import soy.iko.opencode.util.safeExceptionSummary
20+import android.util.Log
21+import kotlinx.coroutines.Dispatchers
22+import kotlinx.coroutines.channels.Channel
23+import kotlinx.coroutines.flow.Flow
24+import kotlinx.coroutines.flow.channelFlow
25+import kotlinx.coroutines.flow.consumeAsFlow
26+import kotlinx.coroutines.flow.conflate
27+import kotlinx.coroutines.flow.flowOn
28+import kotlinx.coroutines.launch
29+import kotlinx.coroutines.sync.Mutex
30+import kotlinx.coroutines.sync.withLock
31+
32+/**
33+ * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
34+ * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
35+ * each keyed by message id with parts keyed by part id (idempotent upserts).
36+ */
37+open class SessionRepository(
38+ private val api: OpencodeApiClient,
39+ private val eventStream: EventStreamClient,
40+) {
41+ open suspend fun listSessions() = api.listSessions()
42+ open suspend fun createSession(title: String? = null, directory: String? = null) =
43+ api.createSession(title, directory)
44+ open suspend fun deleteSession(id: String) = api.deleteSession(id)
45+ open suspend fun abort(sessionId: String) = api.abort(sessionId)
46+
47+ open suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
48+ api.sendPrompt(sessionId, text, model, agent)
49+
50+ open suspend fun runCommand(
51+ sessionId: String,
52+ command: String,
53+ arguments: String = "",
54+ agent: String? = null,
55+ ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
56+
57+ /**
58+ * A live, ordered view of [sessionId]'s messages. Begins collecting the event
59+ * stream before the initial REST load so no streamed part is missed, then reduces
60+ * events into the in-memory state. The flow is conflated so a fast burst of
61+ * streaming tokens (each publishing a fresh snapshot) never back-pressures the
62+ * event reducer β€” a slow collector simply sees the most recent snapshot.
63+ */
64+ open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
65+ val store = MessageStore()
66+ val lock = Mutex()
67+ // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
68+ // single snapshot+send per demand cycle. Previously every changed event called
69+ // snapshot() β€” allocating an O(N) copy of the whole message list β€” and ~97% of
70+ // those copies were immediately discarded by the outer .conflate() during fast
71+ // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
72+ // drain loop means N events in a burst allocate ~1 snapshot, not N.
73+ val dirty = Channel<Unit>(Channel.CONFLATED)
74+
75+ // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
76+ // collapses repeated marks into a single pending signal, so a tight burst of
77+ // events doesn't even allocate N marks.
78+ fun publish() { dirty.trySend(Unit) }
79+
80+ // Subscribe to events first so we don't miss early deltas during the initial load.
81+ launch {
82+ eventStream.events.collect { event ->
83+ if (lock.withLock { store.reduce(sessionId, event) }) publish()
84+ }
85+ }
86+
87+ // Seed generation counter, shared between the initial load and the reconnect
88+ // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
89+ // initial load) clobbering a newer seed. Only mutated/read under [lock].
90+ var seedGeneration = 0
91+
92+ // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
93+ // is performed *outside* the lock so events arriving via SSE during the fetch are
94+ // not blocked. The seed itself is under the lock; a seed generation counter ensures
95+ // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
96+ launch {
97+ // Re-seed from REST when the SSE stream reconnects after a drop. The
98+ // hasConnectedBefore flag is NOT reset on disconnect β€” it stays true after
99+ // the first successful connection so a subsequent reconnect triggers the
100+ // re-seed. Resetting it on disconnect would make the condition always false
101+ // after the first cycle, turning the re-seed into dead code.
102+ //
103+ // A seed generation counter guards against a stale re-seed clobbering a
104+ // newer one: if two reconnects fire in quick succession, the older fetch
105+ // (which returns later) is discarded because its generation no longer matches.
106+ var hasConnectedBefore = false
107+ eventStream.state.collect { state ->
108+ if (state == EventStreamClient.ConnectionState.Connected) {
109+ if (hasConnectedBefore) {
110+ // Pivot before the fetch: parts already in memory predate this
111+ // snapshot and must yield to it, while parts that stream in during
112+ // the fetch (added to streamedSincePivot via reduce) outrank it.
113+ // Bump the generation under the same lock so the compare in the
114+ // initial seed sees a consistent value.
115+ val generation = lock.withLock {
116+ store.beginReseed()
117+ ++seedGeneration
118+ }
119+ val fresh = runCatchingCancellable { api.listMessages(sessionId) }
120+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
121+ .getOrDefault(emptyList())
122+ var seedChanged = false
123+ lock.withLock {
124+ if (generation == seedGeneration) {
125+ // Merge without pruning: SSE events arriving during the
126+ // REST fetch may have added messages not yet indexed by
127+ // the REST endpoint. Pruning would silently delete them.
128+ // Stale messages from deletions during the disconnect
129+ // gap are eventually removed via MessageRemoved events
130+ // or on the next app restart.
131+ seedChanged = store.seed(fresh, prune = false)
132+ }
133+ }
134+ if (seedChanged) publish()
135+ }
136+ hasConnectedBefore = true
137+ }
138+ }
139+ }
140+
141+ val initial = runCatchingCancellable { api.listMessages(sessionId) }
142+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
143+ .getOrDefault(emptyList())
144+ // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
145+ // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
146+ // returns after one has applied, seeding this older REST data would clobber the newer
147+ // state β€” reverting a completed run to "running", regressing cost/token/part state β€”
148+ // until the next SSE event or reconnect healed it.
149+ val seeded = lock.withLock {
150+ if (seedGeneration == 0) {
151+ store.seed(initial)
152+ true
153+ } else false
154+ }
155+ if (seeded) publish()
156+
157+ // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
158+ // taken under the lock so a reader never sees a half-reduced state; send() runs
159+ // outside the lock so a slow downstream collector can't stall the drain. This
160+ // loop also keeps the flow alive until the collector cancels; the launched jobs
161+ // above are children of this scope and are torn down with it.
162+ dirty.consumeAsFlow().collect {
163+ val snapshot = lock.withLock { store.snapshot() }
164+ send(snapshot)
165+ }
166+ // Run the whole reducer pipeline β€” event reduction, list rebuilds, and the O(N)
167+ // snapshot copy β€” on Dispatchers.Default rather than the collector's context. The
168+ // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
169+ // this every streamed token's reduce()/snapshot() would execute on the UI thread
170+ // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
171+ // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
172+ }.flowOn(Dispatchers.Default).conflate()
173+
174+ companion object {
175+ /** Convenience: is this event a run-completion signal for [sessionId]?
176+ * A null sessionID is NOT treated as a wildcard β€” an idle event with no
177+ * session id must not reset the running state of every open chat. */
178+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
179+ event is SessionIdle && event.properties.sessionID == sessionId
180+
181+ fun isError(event: BusEvent, sessionId: String): Boolean =
182+ event is SessionError && event.properties.sessionID == sessionId
183+ }
184+}
185+
186+/**
187+ * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
188+ * Exposed as `internal` so the reducer logic can be unit-tested directly.
189+ */
190+internal class MessageStore {
191+ // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
192+ // insertion-ordered.
193+ private val messages = LinkedHashMap<String, Holder>()
194+
195+ /**
196+ * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
197+ * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
198+ * [MessageWithParts] view is materialized lazily and cached until the next mutation β€” so a
199+ * burst of streamed token updates rebuilds a message's part list at most once per published
200+ * [snapshot], not once per token. The cached view is also reference-stable across snapshots
201+ * while unchanged, so Compose skips recomposing bubbles that didn't change.
202+ */
203+ private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
204+ private var cached: MessageWithParts? = null
205+ fun view(): MessageWithParts =
206+ cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
207+ /** Adopt an existing immutable instance as the cache β€” avoids an immediate rebuild and
208+ * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
209+ fun adopt(m: MessageWithParts) { cached = m }
210+ fun invalidate() { cached = null }
211+ }
212+
213+ /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
214+ private fun holderOf(m: MessageWithParts): Holder {
215+ val parts = LinkedHashMap<String, Part>(m.parts.size)
216+ for (p in m.parts) parts[p.id] = p
217+ return Holder(m.info, parts).also { it.adopt(m) }
218+ }
219+
220+ /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
221+ * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
222+ * so they're newer than the snapshot and must win over it; every other part is taken
223+ * from the authoritative REST snapshot. Without this distinction the merge kept every
224+ * overlapping in-memory part, so a part that changed server-side during the disconnect
225+ * (e.g. a tool going running -> completed) stayed stale until app restart. */
226+ private val streamedSincePivot = mutableSetOf<String>()
227+
228+ /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
229+ * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
230+ * on a reconnect re-seed these are the messages whose info changed *during* the REST
231+ * fetch, so the in-memory info is newer than the snapshot and must win; every other
232+ * message's info is taken from the authoritative REST snapshot. Without this the merge
233+ * kept the in-memory info, so a run that finished server-side during the disconnect
234+ * (e.g. cost/token totals, completion time) stayed stale until app restart. */
235+ private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
236+
237+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
238+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
239+ fun beginReseed() {
240+ streamedSincePivot.clear()
241+ messageInfoUpdatedSincePivot.clear()
242+ }
243+
244+ /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
245+ private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
246+
247+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
248+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
249+
250+ fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
251+
252+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
253+ var changed = false
254+ // On re-seed (after SSE reconnect), remove messages that are no longer in the
255+ // server snapshot (e.g. deleted during the disconnection gap). This keeps the
256+ // in-memory state in sync with the authoritative REST snapshot rather than
257+ // accumulating stale messages forever. Skipped on the initial seed to avoid
258+ // racing with just-arrived SSE events whose messages may not yet be in REST.
259+ if (prune) {
260+ val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
261+ val before = messages.size
262+ messages.keys.retainAll(incomingIds)
263+ if (messages.size != before) changed = true
264+ }
265+
266+ for (m in initial) {
267+ val existing = messages[m.info.id]
268+ if (existing == null) {
269+ messages[m.info.id] = holderOf(m)
270+ changed = true
271+ } else {
272+ // A part streamed in between subscribe and this initial load may already
273+ // have populated this message (see observeMessages). Merge instead of
274+ // overwriting so that newer streamed part isn't discarded: take the
275+ // snapshot's part order, swap in the streamed version where ids overlap,
276+ // append any streamed-only parts, and adopt the authoritative REST info.
277+ // existing.parts is already keyed by part id (insertion-ordered).
278+ val streamedById = existing.parts
279+ val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
280+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
281+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
282+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
283+ // discarding an in-memory part that went stale during a disconnect.
284+ val ordered = m.parts.map { p ->
285+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
286+ }.toMutableList()
287+ for (p in existing.parts.values) {
288+ if (p.id !in snapshotIds) ordered.add(p)
289+ }
290+ // Adopt the authoritative REST info unless the in-memory info was itself
291+ // updated live since the pivot (i.e. during this fetch), in which case it's
292+ // newer than the snapshot. Otherwise the REST snapshot wins, discarding
293+ // in-memory info that went stale during a disconnect.
294+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
295+ val merged = MessageWithParts(info = info, parts = ordered)
296+ if (merged != existing.view()) {
297+ messages[m.info.id] = holderOf(merged)
298+ changed = true
299+ }
300+ }
301+ }
302+ // A part that streamed in *before* this initial load (observeMessages subscribes to
303+ // SSE before fetching) inserts its message at the front of the insertion-ordered map,
304+ // and the merge above keeps that position β€” pinning a running message above the older
305+ // history that seed() inserts afterward. Restore chronological order by creation time
306+ // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
307+ // removes from the map's front, which before reordering is the newest streamed message.
308+ if (reorderByTime()) changed = true
309+ if (evictOldMessages()) changed = true
310+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
311+ // that stream in *after* this seed can outrank the next reconnect's snapshot.
312+ streamedSincePivot.clear()
313+ messageInfoUpdatedSincePivot.clear()
314+ return changed
315+ }
316+
317+ /** Reorder the message map by message creation time (stable). Ties and entries with no
318+ * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
319+ * relative order, so such a message stays last. Preserves each entry's map key so the
320+ * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
321+ private fun reorderByTime(): Boolean {
322+ val entries = messages.entries.map { it.key to it.value }
323+ val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
324+ // Detect a reordering by comparing keys positionally, instead of allocating two more
325+ // N-sized key lists just to compare them.
326+ var changed = false
327+ for (i in sorted.indices) {
328+ if (sorted[i].first != entries[i].first) { changed = true; break }
329+ }
330+ if (!changed) return false
331+ messages.clear()
332+ for ((k, v) in sorted) messages[k] = v
333+ return true
334+ }
335+
336+ /** Returns true if the state changed (and a new snapshot should be published). */
337+ fun reduce(sessionId: String, event: BusEvent): Boolean {
338+ return when (event) {
339+ is MessageUpdated -> handleMessageUpdated(sessionId, event)
340+
341+ is MessagePartUpdated -> {
342+ val part = event.properties.part
343+ val messageId = part.messageID ?: event.properties.messageID
344+ val partSession = part.sessionID ?: event.properties.sessionID
345+ when {
346+ messageId == null -> false
347+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
348+ // No session id anywhere on the event: a null session is NOT a wildcard
349+ // (mirroring isIdle/isError). Every open session's store sees the shared
350+ // event stream, so accepting it unconditionally would leak the part into
351+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
352+ // only if this store already holds the parent message β€” MessageUpdated,
353+ // which always carries a session id, will have created it here first.
354+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
355+ }
356+ }
357+
358+ is MessagePartRemoved -> handlePartRemoved(sessionId, event)
359+
360+ is MessageRemoved -> handleMessageRemoved(sessionId, event)
361+
362+ else -> false
363+ }
364+ }
365+
366+ private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
367+ val info = event.properties.info
368+ if (info.sessionID != sessionId) return false
369+ // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
370+ // field) would collide with other such messages in the map. Generate a unique synthetic
371+ // key so each unknown message gets its own entry instead of overwriting others.
372+ val key = if (info.id.isEmpty() && info is UnknownMessage) {
373+ "unknown-${unknownCounter.incrementAndGet()}"
374+ } else {
375+ info.id
376+ }
377+ val existing = messages[key]
378+ // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
379+ // redundant snapshot (mirrors upsertPart's identical-part guard).
380+ if (existing != null && existing.info == info) return false
381+ if (existing == null) {
382+ messages[key] = Holder(info, LinkedHashMap())
383+ } else {
384+ existing.info = info
385+ existing.invalidate()
386+ }
387+ // Record that this message's info streamed in live, so a re-seed after a reconnect knows
388+ // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
389+ messageInfoUpdatedSincePivot.add(key)
390+ if (existing == null) evictOldMessages()
391+ return true
392+ }
393+
394+ /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
395+ private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
396+ eventSession == null || eventSession == sessionId
397+
398+ private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
399+ val messageId = event.properties.messageID ?: return false
400+ val partId = event.properties.partID ?: return false
401+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
402+ return removePart(messageId, partId)
403+ }
404+
405+ private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
406+ val id = event.properties.messageID ?: return false
407+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
408+ val removed = messages.remove(id) ?: return false
409+ messageInfoUpdatedSincePivot.remove(id)
410+ removed.parts.keys.forEach { streamedSincePivot.remove(it) }
411+ return true
412+ }
413+
414+ private fun upsertPart(messageId: String, part: Part): Boolean {
415+ val current = messages[messageId]
416+ if (current != null) {
417+ // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
418+ // on every streamed token. A LinkedHashMap re-put keeps the part's original
419+ // position (replace-in-place); a new id is appended β€” matching the old list order.
420+ if (current.parts[part.id] == part) return false
421+ current.parts[part.id] = part
422+ current.invalidate()
423+ } else {
424+ val parts = LinkedHashMap<String, Part>()
425+ parts[part.id] = part
426+ messages[messageId] = Holder(
427+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
428+ parts = parts,
429+ )
430+ }
431+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
432+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
433+ streamedSincePivot.add(part.id)
434+ if (current == null) evictOldMessages()
435+ return true
436+ }
437+
438+ private fun removePart(messageId: String, partId: String): Boolean {
439+ val current = messages[messageId] ?: return false
440+ if (current.parts.remove(partId) == null) return false
441+ current.invalidate()
442+ return true
443+ }
444+
445+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
446+ * Returns true if any messages were evicted. */
447+ private fun evictOldMessages(): Boolean {
448+ var evicted = false
449+ while (messages.size > maxMessages) {
450+ val oldestKey = messages.keys.iterator().next()
451+ val removed = messages.remove(oldestKey)
452+ // Prune the evicted message's pivot bookkeeping so these sets don't grow
453+ // unbounded over a long-running session that never reconnects (they're only
454+ // otherwise cleared on reseed/seed).
455+ messageInfoUpdatedSincePivot.remove(oldestKey)
456+ removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
457+ evicted = true
458+ }
459+ return evicted
460+ }
461+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+50
@@ -0,0 +1,50 @@
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), input behavior. */
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+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22+
23+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
24+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
25+ .getOrDefault(ThemeMode.SYSTEM)
26+ }
27+
28+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
29+ prefs[dynamicColorKey] ?: true
30+ }
31+
32+ /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
33+ * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
34+ * true to match the prior hardcoded behavior. */
35+ val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
36+ prefs[sendOnEnterKey] ?: true
37+ }
38+
39+ suspend fun setThemeMode(mode: ThemeMode) {
40+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
41+ }
42+
43+ suspend fun setDynamicColor(enabled: Boolean) {
44+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
45+ }
46+
47+ suspend fun setSendOnEnter(enabled: Boolean) {
48+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
49+ }
50+}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+647
@@ -0,0 +1,647 @@
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.NetworkCapabilities
7+import android.net.NetworkRequest
8+import android.util.Log
9+import soy.iko.opencode.data.model.AssistantMessage
10+import soy.iko.opencode.data.model.BusEvent
11+import soy.iko.opencode.data.model.MessagePartUpdated
12+import soy.iko.opencode.data.model.MessageUpdated
13+import soy.iko.opencode.data.model.StepFinishPart
14+import soy.iko.opencode.data.model.ServerProfile
15+import soy.iko.opencode.data.model.SessionIdle
16+import soy.iko.opencode.data.repo.DraftStore
17+import soy.iko.opencode.data.repo.ErrorKind
18+import soy.iko.opencode.data.repo.ProfileStore
19+import soy.iko.opencode.data.repo.SettingsStore
20+import soy.iko.opencode.data.repo.classifyError
21+import soy.iko.opencode.data.repo.responseStatusCode
22+import soy.iko.opencode.data.network.HttpClientFactory
23+import soy.iko.opencode.data.network.NetworkConfig
24+import soy.iko.opencode.data.network.OpencodeApiClient
25+import soy.iko.opencode.notification.NotificationChannels
26+import soy.iko.opencode.util.safeExceptionSummary
27+import soy.iko.opencode.notification.SessionNotifications
28+import soy.iko.opencode.R
29+import soy.iko.opencode.util.runCatchingCancellable
30+import kotlinx.coroutines.CoroutineScope
31+import kotlinx.coroutines.Dispatchers
32+import kotlinx.coroutines.ExperimentalCoroutinesApi
33+import kotlinx.coroutines.SupervisorJob
34+import kotlinx.coroutines.cancel
35+import kotlinx.coroutines.delay
36+import kotlinx.coroutines.flow.MutableStateFlow
37+import kotlinx.coroutines.flow.StateFlow
38+import kotlinx.coroutines.flow.asStateFlow
39+import kotlinx.coroutines.flow.emptyFlow
40+import kotlinx.coroutines.flow.first
41+import kotlinx.coroutines.Job
42+import kotlinx.coroutines.flow.flatMapLatest
43+import kotlinx.coroutines.flow.update
44+import kotlinx.coroutines.isActive
45+import kotlinx.coroutines.launch
46+import kotlinx.coroutines.sync.Mutex
47+import kotlinx.coroutines.sync.withLock
48+import kotlinx.coroutines.withTimeoutOrNull
49+import java.util.concurrent.atomic.AtomicBoolean
50+
51+/**
52+ * Result of probing a server URL to check connectivity and whether authentication
53+ * is required, without persisting a profile or opening a long-lived connection.
54+ */
55+sealed class ProbeResult {
56+ /** Server is reachable and accepts unauthenticated requests. */
57+ object Reachable : ProbeResult()
58+
59+ /** Server is reachable but rejected the probe with an authentication error. */
60+ object NeedsAuth : ProbeResult()
61+
62+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
63+ data class Unreachable(val error: String) : ProbeResult()
64+}
65+
66+/**
67+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
68+ * process-wide singletons and the currently active [OpencodeConnection].
69+ */
70+open class AppContainer private constructor(
71+ private val appContext: Context?,
72+ private val skipInit: Boolean,
73+ @Suppress("unused") private val testMode: Boolean,
74+) {
75+ constructor(context: Context) : this(context.applicationContext, false, false)
76+ protected constructor() : this(null, true, true)
77+
78+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
79+ open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
80+ open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
81+ open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
82+
83+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
84+ open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
85+
86+ /** True while a background auto-reconnect to the most-recent server is in flight. */
87+ private val _reconnecting = MutableStateFlow(false)
88+ open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
89+
90+ /**
91+ * Set once a startup auto-reconnect succeeds. The server list collects this and
92+ * (once) navigates straight to the session list so a returning user skips it.
93+ * [consumeAutoConnect] guards against re-firing on screen re-entry.
94+ */
95+ private val _autoConnectDone = MutableStateFlow(false)
96+ open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
97+ private val autoConnectConsumed = AtomicBoolean(false)
98+ open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
99+
100+ /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
101+ private val _pendingShare = MutableStateFlow<String?>(null)
102+ open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
103+ open fun setPendingShare(text: String) { _pendingShare.value = text }
104+ open fun consumePendingShare(): String? {
105+ val current = _pendingShare.value ?: return null
106+ return if (_pendingShare.compareAndSet(current, null)) current else null
107+ }
108+
109+ /**
110+ * A session id to open from an external trigger (a notification tap or a deep link).
111+ * The nav host consumes it once a connection is active.
112+ */
113+ private val _pendingOpenSession = MutableStateFlow<String?>(null)
114+ open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
115+ open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
116+ open fun consumePendingOpenSession(): String? {
117+ val current = _pendingOpenSession.value ?: return null
118+ return if (_pendingOpenSession.compareAndSet(current, null)) current else null
119+ }
120+
121+ /**
122+ * The session the user is currently viewing (or null when not in a chat). Drives the
123+ * unread tracker: messages arriving for any *other* session mark it unread, and
124+ * opening a session clears its unread state.
125+ */
126+ private val _currentSession = MutableStateFlow<String?>(null)
127+ open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
128+
129+ /** Session ids that received activity while not being viewed, mapped to the count
130+ * of unread message events. A count (not just a presence set) lets the session
131+ * list badge show "3 unread" instead of a bare dot, so the user can tell whether
132+ * one reply or a whole burst arrived. */
133+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
134+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
135+
136+ /** True when any assistant run is actively streaming across all sessions. Drives the
137+ * disconnect confirmation on the session list (disconnecting mid-run kills it). */
138+ private val _anyRunActive = MutableStateFlow(false)
139+ open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
140+
141+ /** Whether the device currently has network connectivity. Distinct from the SSE
142+ * connection state so the UI can tell "you're offline" (device) from "server
143+ * unreachable" (credentials/host). Updated by the [networkCallback]. */
144+ private val _isOnline = MutableStateFlow(true)
145+ open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
146+
147+ open fun setCurrentSession(id: String?) {
148+ _currentSession.value = id
149+ if (id != null) {
150+ _unread.update { it - id }
151+ unreadMessageIds.remove(id)
152+ appContext?.let { SessionNotifications.cancel(it, id) }
153+ }
154+ }
155+
156+ /** Restore a session's unread badge after a failed server switch reconnects.
157+ * Preserves the prior [count] so a session badged with "5 unread" before the
158+ * switch attempt still shows 5 (not 1) after the restore. No-op for the session
159+ * the user is currently viewing, since a viewed session is never badged. */
160+ open fun restoreUnread(id: String, count: Int) {
161+ if (id != _currentSession.value) {
162+ _unread.update { it + (id to count) }
163+ }
164+ }
165+
166+ /** Resolve a localized string β€” view models reach resources through the container. */
167+ open fun string(id: Int, vararg formatArgs: Any): String {
168+ if (appContext == null) return ""
169+ return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
170+ }
171+
172+ /**
173+ * Convert a throwable into a user-facing message. Classifies the error by concrete
174+ * type (network, timeout, HTTP status) rather than string-matching class names, so
175+ * the message reflects what actually went wrong without leaking internal URLs/state.
176+ */
177+ open fun friendlyError(t: Throwable): String =
178+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
179+
180+ /**
181+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
182+ * that aren't operating on the active connection (e.g. [probeServer]) can still
183+ * produce a user-facing message with the right server address.
184+ */
185+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
186+ when (classifyError(t)) {
187+ ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
188+ string(R.string.error_not_reachable, baseUrl)
189+ ErrorKind.TIMEOUT -> string(R.string.error_timeout)
190+ ErrorKind.SERVER -> string(R.string.error_server)
191+ // Show only the HTTP status (e.g. 401, 404) β€” never the request URL,
192+ // which a ClientRequestException carries in its message and which we
193+ // promised not to leak (it can include auth or internal paths).
194+ ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
195+ ?: string(R.string.error_generic)
196+ ErrorKind.UNKNOWN -> string(R.string.error_generic)
197+ }
198+
199+ init {
200+ if (!skipInit) {
201+ NotificationChannels.create(appContext!!)
202+ observeMessageActivity()
203+ appScope.launch { autoConnect() }
204+ }
205+ }
206+
207+ /** Release resources held for the process lifetime (network callback, app scope). */
208+ open fun shutdown() {
209+ // Cancel the app scope first so any coroutine holding the connection mutex
210+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
211+ // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
212+ // that can't be cancelled until after the runBlocking completes.
213+ appScope.cancel()
214+ // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
215+ // 2 seconds is generous: appScope.cancel() already triggered cancellation of
216+ // any coroutine holding the mutex; we're just waiting for it to unwind.
217+ kotlinx.coroutines.runBlocking {
218+ withTimeoutOrNull(2_000) {
219+ connectionMutex.withLock {
220+ runCatching { _activeConnection.value?.close() }
221+ _activeConnection.value = null
222+ }
223+ }
224+ // If the timeout fired, force-clear the connection so it isn't left dangling.
225+ _activeConnection.value = null
226+ }
227+ val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
228+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
229+ if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
230+ if (!skipInit) draftStore.shutdown()
231+ }
232+
233+ /**
234+ * Watch the SSE bus for new message activity and badge any session that isn't
235+ * currently open. This powers the unread dot on the session list so the user can
236+ * tell which conversations got a reply while they were elsewhere.
237+ *
238+ * Uses [flatMapLatest] so a server switch (which replaces the active connection)
239+ * re-subscribes to the new event stream and drops the old one.
240+ */
241+ @OptIn(ExperimentalCoroutinesApi::class)
242+ private fun observeMessageActivity() {
243+ appScope.launch {
244+ // Retry the observer if it fails for any reason (transient flow exception,
245+ // cancellation due to scope issues). Without this, a single failure would
246+ // permanently disable unread tracking and completion notifications.
247+ while (isActive) {
248+ runCatchingCancellable {
249+ activeConnection
250+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
251+ .collect { event ->
252+ // SessionIdle is not "message activity" (don't badge as unread)
253+ // but signals a run finished β€” fire a completion notification if the
254+ // session isn't currently being viewed and was actively streaming.
255+ if (event is SessionIdle) {
256+ val idleSid = event.properties.sessionID ?: return@collect
257+ // Clear the run state unconditionally β€” even for the session on
258+ // screen. Gating the removal on `idleSid != currentSession` (as
259+ // this once did) leaks the id in activeRuns and pins anyRunActive
260+ // true forever whenever a run finishes while being viewed. Only
261+ // the completion *notification* is suppressed for the viewed one.
262+ val wasRunning = synchronized(activeRuns) {
263+ activeRuns.remove(idleSid).also { removed ->
264+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
265+ }
266+ }
267+ if (wasRunning && idleSid != _currentSession.value) {
268+ // Fire this off the collector's coroutine: notifySessionCompleted
269+ // does a network listSessions() to resolve the title, and blocking
270+ // the shared-event collector here would stall the SharedFlow,
271+ // overflowing its DROP_OLDEST buffer and silently dropping live
272+ // parts/updates for every other subscriber (e.g. the message reducer).
273+ appScope.launch { notifySessionCompleted(idleSid) }
274+ }
275+ return@collect
276+ }
277+ val sid = sessionOf(event) ?: return@collect
278+ // Re-read currentSession here, right before mutating the badge state:
279+ // the user may have opened this session between when this event was
280+ // enqueued and now (setCurrentSession clears its badge and dedup set).
281+ // A stale earlier check would race a badge β€” and a dedup entry β€” back
282+ // in for the session that's actually on screen.
283+ if (sid != _currentSession.value) {
284+ // Increment the unread count once per distinct *message*, not per
285+ // event: a single reply emits many message.updated / message.part.
286+ // updated events (one per streamed token, plus cost/token refreshes),
287+ // which would otherwise inflate the badge into the dozens/hundreds.
288+ val messageId = messageIdOfEvent(event)
289+ val counted = unreadMessageIds.getOrPut(sid) {
290+ java.util.Collections.synchronizedSet(mutableSetOf())
291+ }
292+ if (messageId == null || counted.add(messageId)) {
293+ _unread.update { current ->
294+ // Guard again inside the atomic update lambda so a retry
295+ // (or an open that landed just now) can't reintroduce the
296+ // badge for the session on screen.
297+ if (sid == _currentSession.value) current
298+ else current + (sid to (current[sid] ?: 0) + 1)
299+ }
300+ }
301+ }
302+ // Track sessions actively streaming so we know which idle events
303+ // represent a finished run worth notifying about.
304+ if (isLiveRunActivity(event)) {
305+ // The add-under-lock is required both for thread-safety and for the
306+ // LRU access-order refresh (keeps a continuously-streaming session
307+ // from being evicted), so it runs per event. Only the flag write is
308+ // skippable once it's already set.
309+ synchronized(activeRuns) {
310+ if (activeRuns.size >= activeRunsLimit) {
311+ activeRuns.remove(activeRuns.iterator().next())
312+ }
313+ activeRuns.add(sid)
314+ }
315+ if (!_anyRunActive.value) _anyRunActive.value = true
316+ }
317+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
318+ if (!isActive) break
319+ delay(NetworkConfig.observerRetryDelayMs)
320+ }
321+ }
322+ }
323+
324+ /** True if [event] is live streaming activity worth tracking as an active run. A
325+ * trailing message.updated that lands *after* the run finished (final cost/token totals,
326+ * carrying a completion time) is NOT live streaming: with no further SessionIdle to
327+ * follow it, re-adding the session would pin anyRunActive true indefinitely. */
328+ private fun isLiveRunActivity(event: BusEvent): Boolean {
329+ if (event !is MessagePartUpdated && event !is MessageUpdated) return false
330+ val info = (event as? MessageUpdated)?.properties?.info
331+ if (info is AssistantMessage && info.isComplete) return false
332+ // A step-finish part is the trailing completion marker for a run (it carries the
333+ // final cost/token totals) and arrives after the last stream delta. Like a completed
334+ // message.updated, treating it as live would re-add the session to activeRuns with no
335+ // following SessionIdle to clear it β€” pinning anyRunActive true indefinitely when such
336+ // a part is replayed/reordered after the run already went idle.
337+ val part = (event as? MessagePartUpdated)?.properties?.part
338+ if (part is StepFinishPart) return false
339+ return true
340+ }
341+
342+ /** Session ids currently streaming an assistant run (best-effort, in-process). */
343+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
344+
345+ /** Per non-viewed session, the set of message ids already reflected in its unread
346+ * count. De-duplicates the many streaming events that share one message id so the
347+ * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
348+ private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
349+
350+ /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
351+ private val activeRunsLimit = NetworkConfig.activeRunsLimit
352+
353+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
354+ private val connectionMutex = Mutex()
355+
356+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
357+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
358+ private companion object {
359+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
360+ }
361+
362+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
363+ private suspend fun notifySessionCompleted(sessionId: String) {
364+ val conn = activeConnection.value ?: return
365+ val title = runCatchingCancellable {
366+ conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
367+ }.getOrNull() ?: sessionId
368+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
369+ }
370+
371+ /** Extract the session id an event pertains to, for message-activity events. */
372+ private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
373+
374+ /**
375+ * On cold start, transparently reconnect to the most recently used server so a
376+ * returning user lands in their session list instead of the empty server screen.
377+ */
378+ private suspend fun autoConnect() {
379+ val recent = runCatchingCancellable { profileStore.profiles.first() }
380+ .getOrDefault(emptyList())
381+ .firstOrNull() ?: return
382+ _reconnecting.value = true
383+ val ok = runCatchingCancellable {
384+ val conn = connect(recent)
385+ conn.api.ping()
386+ }.isSuccess
387+ _reconnecting.value = false
388+ if (ok) _autoConnectDone.value = true else disconnect()
389+ }
390+
391+ /**
392+ * When the device regains connectivity, nudge the active SSE stream to reconnect
393+ * immediately instead of waiting out the exponential backoff.
394+ */
395+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
396+
397+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
398+ if (appContext == null) return null
399+ val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
400+ ?: return null
401+ // Seed the online state from the current active network so the indicator is
402+ // correct on cold start rather than defaulting to "online" until a callback fires.
403+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
404+ val callback = object : ConnectivityManager.NetworkCallback() {
405+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
406+ override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
407+ }
408+ // Only match networks that actually provide internet β€” a capability-less request
409+ // fires onAvailable for transports that can't reach the server (and would trigger
410+ // spurious reconnects), so require NET_CAPABILITY_INTERNET.
411+ val request = NetworkRequest.Builder()
412+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
413+ .build()
414+ runCatching { cm.registerNetworkCallback(request, callback) }
415+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
416+ return callback
417+ }
418+
419+ /** Open (or re-open) a connection to [profile], replacing any current one.
420+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
421+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
422+ open suspend fun connect(profile: ServerProfile): OpencodeConnection =
423+ connectionMutex.withLock {
424+ _activeConnection.value?.close()
425+ _activeConnection.value = null
426+ activeRuns.clear()
427+ _anyRunActive.value = false
428+ _unread.value = emptyMap()
429+ unreadMessageIds.clear()
430+ val now = System.currentTimeMillis()
431+ val resolved = profileStore.resolve(profile)
432+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
433+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
434+ if (needsSave) profileStore.save(finalProfile)
435+ OpencodeConnection(finalProfile).also { _activeConnection.value = it }
436+ }
437+
438+ open suspend fun disconnect() {
439+ // Acquire the connection mutex so disconnect is serialized with connect().
440+ // Previously this was fire-and-forget (launch on appScope), which raced with
441+ // a subsequent connect(): connect() could acquire the mutex, create a new
442+ // connection, and then the pending disconnect() would close it.
443+ connectionMutex.withLock {
444+ _activeConnection.value?.close()
445+ _activeConnection.value = null
446+ // Reset run/unread state on disconnect too. With no SSE stream there's no
447+ // SessionIdle to drain activeRuns, so without this the working indicator
448+ // (anyRunActive) and unread badges would stay pinned until the next connect().
449+ activeRuns.clear()
450+ _anyRunActive.value = false
451+ _unread.value = emptyMap()
452+ unreadMessageIds.clear()
453+ }
454+ }
455+
456+ /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
457+ * ViewModel scope) so navigating away during the undo window still commits the delete
458+ * instead of silently cancelling it β€” mirroring [CrashLogger.scheduleDelete]. */
459+ private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
460+
461+ /**
462+ * Schedule the profile [id] for deletion after [delayMs], cancellable via
463+ * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
464+ * after the delete resolves so the caller can clear its optimistic-hide state. A
465+ * repeated schedule for the same id replaces the prior timer.
466+ */
467+ open fun scheduleProfileDelete(
468+ id: String,
469+ delayMs: Long,
470+ onDeleted: () -> Unit,
471+ onError: (Throwable) -> Unit,
472+ ) {
473+ val job = appScope.launch {
474+ delay(delayMs)
475+ // Claim the delete by removing our own entry BEFORE committing it β€” the atomic
476+ // remove is the sync point with cancelProfileDelete. Losing the race (an undo
477+ // removed us first) means we skip, honouring cancel's `true`; deleting first
478+ // would let a cancel land in the gap and falsely report undo of a gone profile.
479+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
480+ val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
481+ if (!claimed) return@launch
482+ runCatchingCancellable { profileStore.delete(id) }
483+ .onSuccess { onDeleted() }
484+ .onFailure { onError(it) }
485+ }
486+ pendingProfileDeletes.put(id, job)?.cancel()
487+ }
488+
489+ /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
490+ * still pending (undo succeeded), false if the delete had already fired. */
491+ open fun cancelProfileDelete(id: String): Boolean {
492+ val job = pendingProfileDeletes.remove(id) ?: return false
493+ job.cancel()
494+ return true
495+ }
496+
497+ /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
498+ * ViewModel scope) so navigating away during the undo window still commits the delete
499+ * instead of silently cancelling it (which would leave the session deleted-in-UI but
500+ * alive on the server, reappearing on the next refresh). */
501+ private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
502+
503+ /**
504+ * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
505+ * (the Undo action). Also clears the session's stored draft on a successful delete.
506+ * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
507+ * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
508+ *
509+ * The owning connection is captured now, at schedule time β€” NOT re-resolved when the timer
510+ * fires. Otherwise a server switch during the undo window would send the delete to whichever
511+ * server happens to be active at fire time: the original session would never be deleted (its
512+ * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
513+ * on the new server could be deleted instead. A disconnect closes the captured connection's
514+ * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
515+ * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
516+ * error) if there is no active connection at schedule time.
517+ */
518+ open fun scheduleSessionDelete(
519+ id: String,
520+ delayMs: Long,
521+ onDeleted: () -> Unit,
522+ onError: (Throwable) -> Unit,
523+ ) {
524+ val conn = _activeConnection.value
525+ val job = appScope.launch {
526+ delay(delayMs)
527+ // Claim the delete by removing our own entry BEFORE committing it β€” the atomic
528+ // remove is the sync point with cancelSessionDelete. Losing the race (an undo
529+ // removed us first) means we skip, honouring cancel's `true`; deleting first
530+ // would let a cancel land in the gap and falsely report undo of a gone session.
531+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
532+ val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
533+ if (!claimed) return@launch
534+ if (conn == null) {
535+ onError(IllegalStateException("No active connection"))
536+ } else {
537+ runCatchingCancellable { conn.repository.deleteSession(id) }
538+ .onSuccess {
539+ draftStore.remove(id)
540+ onDeleted()
541+ }
542+ .onFailure { onError(it) }
543+ }
544+ }
545+ pendingSessionDeletes.put(id, job)?.cancel()
546+ }
547+
548+ /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
549+ * still pending (undo succeeded), false if the delete had already fired. */
550+ open fun cancelSessionDelete(id: String): Boolean {
551+ val job = pendingSessionDeletes.remove(id) ?: return false
552+ job.cancel()
553+ return true
554+ }
555+
556+ /**
557+ * Probe a server URL (without credentials) to check reachability and detect whether
558+ * the server requires authentication. Builds a short-lived HTTP client with the same
559+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
560+ * classifies the outcome:
561+ *
562+ * - 2xx β†’ [ProbeResult.Reachable] (no auth needed)
563+ * - 401/403 β†’ [ProbeResult.NeedsAuth]
564+ * - anything else β†’ [ProbeResult.Unreachable] with a user-facing message
565+ *
566+ * The probe client is always closed afterwards so no resources linger. This does not
567+ * touch the active connection or the profile store.
568+ */
569+ open suspend fun probeServer(baseUrl: String): ProbeResult {
570+ return probeWithProfile(ServerProfile(
571+ id = "probe",
572+ label = "",
573+ baseUrl = baseUrl.trim(),
574+ username = null,
575+ password = null,
576+ ))
577+ }
578+
579+ /**
580+ * Probe a server URL *with* credentials to validate them. Returns true if the
581+ * server accepts the credentials (ping succeeds), false on 401/403, and throws
582+ * on other failures (unreachable, timeout) so the caller can surface a friendly
583+ * message. Mirrors [probeServer]'s short-lived-client pattern.
584+ */
585+ open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
586+ val probeProfile = ServerProfile(
587+ id = "probe-auth",
588+ label = "",
589+ baseUrl = baseUrl.trim(),
590+ username = username.trim().takeIf { it.isNotBlank() },
591+ password = password.trim().takeIf { it.isNotEmpty() },
592+ )
593+ val client = HttpClientFactory.create(probeProfile)
594+ return try {
595+ val api = OpencodeApiClient(client)
596+ api.ping()
597+ true
598+ } catch (e: Exception) {
599+ val status = responseStatusCode(e)
600+ if (status == 401 || status == 403) false else throw e
601+ } finally {
602+ runCatching { client.close() }
603+ }
604+ }
605+
606+ private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
607+ val client = HttpClientFactory.create(profile)
608+ return try {
609+ val api = OpencodeApiClient(client)
610+ api.ping()
611+ ProbeResult.Reachable
612+ } catch (e: Exception) {
613+ val status = responseStatusCode(e)
614+ if (status == 401 || status == 403) {
615+ ProbeResult.NeedsAuth
616+ } else {
617+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
618+ }
619+ } finally {
620+ runCatching { client.close() }
621+ }
622+ }
623+}
624+
625+/**
626+ * Extract the session id a message-activity event pertains to, or null for events that
627+ * don't describe message activity. This drives the unread badge: any session that
628+ * receives activity while it isn't the currently-viewed one gets badged. Extracted as
629+ * a top-level `internal` function so the rule is unit-testable without an Android
630+ * [Context] (the surrounding [AppContainer] needs one).
631+ */
632+internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
633+ is MessageUpdated -> event.properties.info.sessionID
634+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
635+ else -> null
636+}
637+
638+/**
639+ * The message id a message-activity event pertains to, used to de-duplicate the unread
640+ * badge so a reply's many streaming events count once. Null for events that carry no
641+ * message id (those fall back to being counted individually).
642+ */
643+internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
644+ is MessageUpdated -> event.properties.info.id
645+ is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
646+ else -> null
647+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+41
@@ -0,0 +1,41 @@
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+open class OpencodeConnection(val profile: ServerProfile) {
19+
20+ protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"))
21+
22+ private val scopeJob = SupervisorJob()
23+ private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
24+ private val client by lazy { HttpClientFactory.create(profile) }
25+
26+ open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
27+ open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
28+ open val repository: SessionRepository by lazy { SessionRepository(api, events) }
29+
30+ /**
31+ * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
32+ * to wind down before closing the HTTP client. Calling client.close() while the SSE
33+ * coroutine is still mid-request produces a spurious "stream error, will retry"
34+ * warning during normal teardown.
35+ */
36+ open suspend fun close() {
37+ scope.cancel()
38+ scopeJob.join()
39+ runCatching { client.close() }
40+ }
41+}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktnew file+34
@@ -0,0 +1,34 @@
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.ktnew file+101
@@ -0,0 +1,101 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.Notification
4+import android.app.PendingIntent
5+import android.app.Service
6+import android.content.Context
7+import android.content.Intent
8+import android.content.pm.ServiceInfo
9+import android.os.Build
10+import android.os.IBinder
11+import android.util.Log
12+import androidx.core.app.NotificationCompat
13+import androidx.core.app.ServiceCompat
14+import soy.iko.opencode.MainActivity
15+import soy.iko.opencode.R
16+
17+/**
18+ * A foreground service kept alive while an opencode agent is actively running. Holding a
19+ * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
20+ * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
21+ *
22+ * The service is started when a run begins ([start]) and stopped when it goes idle
23+ * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
24+ * Started/stopped via Compose from the chat screen based on the `running` state.
25+ */
26+class RunForegroundService : Service() {
27+
28+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29+ val notification = buildNotification()
30+ // startForeground can throw ForegroundServiceStartNotAllowedException on
31+ // Android 12+ if the app is in the background when the service starts. Wrap
32+ // it so a backgrounded start (e.g. the user navigates away at the wrong
33+ // moment) doesn't crash the app β€” the SSE stream will just continue without
34+ // foreground priority and may be killed by the system sooner.
35+ runCatching {
36+ ServiceCompat.startForeground(
37+ this,
38+ NOTIF_ID,
39+ notification,
40+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
41+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
42+ } else {
43+ 0
44+ },
45+ )
46+ }.onFailure {
47+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
48+ stopSelf()
49+ }
50+ return START_NOT_STICKY
51+ }
52+
53+ private fun buildNotification(): Notification {
54+ // Tapping the notification opens the app so the user can see the running session.
55+ val openIntent = Intent(this, MainActivity::class.java).apply {
56+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57+ }
58+ val pendingIntent = PendingIntent.getActivity(
59+ this, 0, openIntent,
60+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61+ )
62+ return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63+ .setSmallIcon(R.drawable.ic_launcher_foreground)
64+ .setContentTitle(getString(R.string.notif_running_title))
65+ .setContentText(getString(R.string.notif_running_text))
66+ .setContentIntent(pendingIntent)
67+ .setOngoing(true)
68+ .setPriority(NotificationCompat.PRIORITY_LOW)
69+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
70+ .build()
71+ }
72+
73+ override fun onDestroy() {
74+ // Safety net: if the process is being killed while a run is in progress,
75+ // ensure the notification is removed rather than lingering.
76+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
77+ super.onDestroy()
78+ }
79+
80+ override fun onBind(intent: Intent?): IBinder? = null
81+
82+ companion object {
83+ private const val TAG = "RunForegroundService"
84+ private const val NOTIF_ID = 1
85+
86+ fun start(context: Context) {
87+ // startForegroundService can throw ForegroundServiceStartNotAllowedException
88+ // on Android 12+ if the app is in the background. Wrap it so a backgrounded
89+ // start (e.g. the user navigates away at the wrong moment) doesn't crash.
90+ runCatching {
91+ context.startForegroundService(Intent(context, RunForegroundService::class.java))
92+ }.onFailure {
93+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
94+ }
95+ }
96+
97+ fun stop(context: Context) {
98+ context.stopService(Intent(context, RunForegroundService::class.java))
99+ }
100+ }
101+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktnew file+95
@@ -0,0 +1,95 @@
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+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
28+
29+ /** Derive a stable, collision-resistant notification id from a session id.
30+ * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
31+ * bits makes accidental collisions astronomically unlikely. */
32+ private fun notifIdFor(sessionId: String): Int {
33+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
35+ // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
36+ // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
37+ // left-to-right, so the shifts MUST be parenthesized individually β€” otherwise
38+ // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
39+ // byte out of the Int entirely and collapsing the id's entropy.
40+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
41+ ((digest[1].toInt() and 0xFF) shl 16) or
42+ ((digest[2].toInt() and 0xFF) shl 8) or
43+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
44+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
45+ // Using modulo ensures the prefix is preserved without Int overflow.
46+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
47+ }
48+
49+ fun postCompleted(context: Context, sessionId: String, title: String) {
50+ // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
51+ // denied it, skip the post instead of throwing. Foreground-service notifications
52+ // are exempt, so the running indicator still shows during a run.
53+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
54+ androidx.core.content.ContextCompat.checkSelfPermission(
55+ context, android.Manifest.permission.POST_NOTIFICATIONS,
56+ ) != android.content.pm.PackageManager.PERMISSION_GRANTED
57+ ) {
58+ return
59+ }
60+ val notifId = notifIdFor(sessionId)
61+ // Sanitize the session id before embedding it in the URI so characters like
62+ // /, ?, # can't inject path segments or query parameters.
63+ val safeSessionId = notifIdRegex.replace(sessionId, "")
64+ val openIntent = Intent(context, MainActivity::class.java).apply {
65+ action = Intent.ACTION_VIEW
66+ data = android.net.Uri.parse("opencode://session/$safeSessionId")
67+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
68+ }
69+ val pendingIntent = PendingIntent.getActivity(
70+ context, notifId, openIntent,
71+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
72+ )
73+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
74+ .setSmallIcon(R.drawable.ic_launcher_foreground)
75+ .setContentTitle(context.getString(R.string.notif_completed_title))
76+ // No % escaping here: getString(id, arg) inserts the argument verbatim and
77+ // never re-scans it for format specifiers, so escaping the title would show
78+ // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
79+ .setContentText(context.getString(R.string.notif_completed_text, title))
80+ .setAutoCancel(true)
81+ .setContentIntent(pendingIntent)
82+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
83+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
84+ .build()
85+
86+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
87+ .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
88+ }
89+
90+ /** Cancel a session's completion notification (e.g. when the user opens it). */
91+ fun cancel(context: Context, sessionId: String) {
92+ val notifId = notifIdFor(sessionId)
93+ NotificationManagerCompat.from(context).cancel(notifId)
94+ }
95+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+215βˆ’100
@@ -1,124 +1,239 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3βˆ’import androidx.activity.compose.BackHandler
3+import androidx.compose.animation.core.tween
4+import androidx.compose.animation.fadeIn
4βˆ’import androidx.compose.animation.AnimatedContent
5+import androidx.compose.animation.fadeOut
5import androidx.compose.animation.slideInHorizontally
6import androidx.compose.animation.slideInHorizontally
6import androidx.compose.animation.slideOutHorizontally
7import androidx.compose.animation.slideOutHorizontally
7βˆ’import androidx.compose.animation.togetherWith
8+import androidx.compose.foundation.layout.BoxWithConstraints
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
17import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
18import androidx.compose.runtime.LaunchedEffect
10import androidx.compose.runtime.LaunchedEffect
19import androidx.compose.runtime.getValue
11import androidx.compose.runtime.getValue
20import androidx.compose.runtime.mutableStateOf
12import androidx.compose.runtime.mutableStateOf
21import androidx.compose.runtime.remember
13import androidx.compose.runtime.remember
14+import androidx.compose.runtime.rememberCoroutineScope
22import androidx.compose.runtime.setValue
15import androidx.compose.runtime.setValue
23βˆ’import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
24βˆ’import soy.iko.opencode.core.Event
17+import androidx.navigation.NavType
18+import androidx.navigation.compose.NavHost
19+import androidx.navigation.compose.composable
20+import androidx.navigation.compose.rememberNavController
21+import androidx.navigation.navArgument
22+import androidx.lifecycle.compose.collectAsStateWithLifecycle
23+import kotlinx.coroutines.launch
25βˆ’import soy.iko.opencode.core.Screen
24+import soy.iko.opencode.di.AppContainer
26βˆ’import soy.iko.opencode.core.UiState
25+import soy.iko.opencode.data.network.NetworkConfig
27βˆ’import soy.iko.opencode.ui.screens.ChatScreen
26+import soy.iko.opencode.ui.chat.ChatScreen
28βˆ’import soy.iko.opencode.ui.screens.ConnectScreen
27+import soy.iko.opencode.ui.file.FileBrowserScreen
28+import soy.iko.opencode.ui.file.FileViewScreen
29+import soy.iko.opencode.ui.server.ServerEditScreen
30+import soy.iko.opencode.ui.server.ServerListScreen
31+import soy.iko.opencode.ui.session.SessionListScreen
32+import soy.iko.opencode.ui.session.TwoPaneSessionChat
33+import soy.iko.opencode.ui.settings.DiagnosticsScreen
29βˆ’import soy.iko.opencode.ui.screens.SessionsScreen
34+import soy.iko.opencode.ui.settings.SettingsScreen
35+import soy.iko.opencode.util.runCatchingCancellable
30
36
31@Composable
37@Composable
32βˆ’fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
38+fun OpencodeApp(container: AppContainer) {
33βˆ’ val snackbar = remember { SnackbarHostState() }
39+ val navController = rememberNavController()
34βˆ’ var showStopConfirm by remember { mutableStateOf(false) }
40+ val scope = rememberCoroutineScope()
35βˆ’ var showDisconnectConfirm by remember { mutableStateOf(false) }
41+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
43+ val connection by container.activeConnection.collectAsStateWithLifecycle()
36
44
37βˆ’ LaunchedEffect(state.error) {
45+ // When text is shared into the app, surface the session list so the user can pick
38βˆ’ state.error?.let {
46+ // (or create) a conversation to drop it into. The chosen session's draft is set
39βˆ’ snackbar.showSnackbar(
47+ // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
40βˆ’ message = it,
48+ // before auto-reconnect completes is retried once the connection is established.
41βˆ’ actionLabel = "Dismiss",
49+ // Tracks the last connection id seen so a server switch (a non-null -> non-null
42βˆ’ duration = SnackbarDuration.Short,
50+ // change) doesn't re-bounce the user to the session list for a share they've
43βˆ’ )
51+ // already decided to abandon; only the null -> non-null initial connect retries.
44βˆ’ // Always clear the error from state after showing.
52+ var lastConnectionId by remember { mutableStateOf<String?>(null) }
45βˆ’ dispatch(Event.DismissError)
53+ LaunchedEffect(pendingShare, connection) {
54+ if (pendingShare == null) return@LaunchedEffect
55+ if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
56+ val currentId = connection?.profile?.id
57+ // Only (re)navigate on the initial null -> non-null connect. A subsequent
58+ // server switch while a share is pending (rare) leaves the user where they
59+ // are instead of yanking them back to the session list.
60+ if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
61+ lastConnectionId = currentId
62+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
63+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
46 }
64 }
47 }
65 }
48
66
49βˆ’ BackHandler(enabled = state.screen != Screen.Connect) {
67+ // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
50βˆ’ when (state.screen) {
68+ // the open conversation side by side instead of a single-pane back stack.
51βˆ’ // Leaving mid-generation would silently drop the reply, so confirm first.
69+ BoxWithConstraints {
52βˆ’ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
70+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
53βˆ’ // Disconnecting drops the server session, so guard the back button.
54βˆ’ Screen.Sessions -> showDisconnectConfirm = true
55βˆ’ Screen.Connect -> Unit
56βˆ’ }
57βˆ’ }
58
71
59βˆ’ if (showStopConfirm) {
72+ // Open a session requested by a notification tap or deep link, once connected.
60βˆ’ AlertDialog(
73+ LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
61βˆ’ onDismissRequest = { showStopConfirm = false },
74+ val id = pendingOpenSession ?: return@LaunchedEffect
62βˆ’ title = { Text("Stop generation?") },
75+ if (connection == null) return@LaunchedEffect
63βˆ’ text = { Text("Leaving will stop the current generation.") },
76+ // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
64βˆ’ confirmButton = {
77+ // route) consumes the request and opens it in the detail pane. But it only
65βˆ’ TextButton(onClick = {
78+ // exists while the NavHost is on SESSIONS, so if the user is currently on
66βˆ’ showStopConfirm = false
79+ // Files/Settings/FileView bring SESSIONS back to front first β€” otherwise the
67βˆ’ dispatch(Event.CancelGeneration)
80+ // pending request is never consumed and the session never opens. Don't consume
68βˆ’ dispatch(Event.NavigateToSessions)
81+ // here; let TwoPaneSessionChat's effect do it once it's composed.
69βˆ’ }) {
82+ if (isTwoPane) {
70βˆ’ Text("Stop and leave")
83+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
84+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
71 }
85 }
86+ return@LaunchedEffect
72βˆ’ },
87+ }
73βˆ’ dismissButton = {
88+ container.consumePendingOpenSession()
74βˆ’ TextButton(onClick = { showStopConfirm = false }) {
89+ // Ensure SESSIONS sits on the back stack below the chat destination so backing
75βˆ’ Text("Stay")
90+ // out of the chat lands on the session list (not the server list). Bring SESSIONS
91+ // to front (or push it if a cold-start deep link left only SERVERS on the stack),
92+ // then push chat on top β€” yielding SERVERS -> SESSIONS -> chat.
93+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
94+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
76βˆ’ }
95+ }
77βˆ’ },
96+ navController.navigate(Routes.chat(id)) { launchSingleTop = true }
78βˆ’ )
79βˆ’ }
97+ }
80
98
81βˆ’ if (showDisconnectConfirm) {
99+ NavHost(
82βˆ’ AlertDialog(
100+ navController = navController,
83βˆ’ onDismissRequest = { showDisconnectConfirm = false },
101+ startDestination = Routes.SERVERS,
84βˆ’ title = { Text("Disconnect?") },
102+ // Slide horizontally for forward/back navigation to feel native; fade for
85βˆ’ text = { Text("You will be disconnected from the server.") },
103+ // the root so the first frame doesn't slide in from off-screen.
86βˆ’ confirmButton = {
104+ enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
87βˆ’ TextButton(onClick = {
105+ exitTransition = { fadeOut(tween(180)) },
88βˆ’ showDisconnectConfirm = false
106+ popEnterTransition = { fadeIn(tween(180)) },
89βˆ’ dispatch(Event.NavigateToConnect)
107+ popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
90βˆ’ }) {
91βˆ’ Text("Disconnect")
92βˆ’ }
93βˆ’ },
94βˆ’ dismissButton = {
95βˆ’ TextButton(onClick = { showDisconnectConfirm = false }) {
96βˆ’ Text("Cancel")
97βˆ’ }
98βˆ’ },
99βˆ’ )
108+ ) {
100βˆ’ }
101
109
102βˆ’ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
110+ composable(Routes.SERVERS) {
103βˆ’ AnimatedContent(
111+ ServerListScreen(
104βˆ’ targetState = state.screen,
112+ container = container,
105βˆ’ modifier = Modifier.fillMaxSize().padding(padding),
113+ onConnected = {
106βˆ’ transitionSpec = {
114+ // Only leave the server list if we're still on it. A pending deep-link /
107βˆ’ // Compare screen ordinals to decide slide direction: forward navigation
115+ // notification open sets activeConnection (which drives the open-session
108βˆ’ // (Connect β†’ Sessions β†’ Chat) slides in from the right, back slides from the left.
116+ // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
109βˆ’ if (targetState.ordinal > initialState.ordinal) {
117+ // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
110βˆ’ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
118+ // on top of the chat it just opened, burying it. Order-independent: if the
119+ // deep link already navigated away from SERVERS, this is a no-op.
120+ if (navController.currentDestination?.route == Routes.SERVERS) {
121+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
122+ }
123+ },
124+ onAddProfile = { navController.navigate(Routes.serverEdit()) },
125+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
126+ onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
127+ )
128+ }
129+
130+ composable(
131+ route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
132+ arguments = listOf(
133+ navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
134+ navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
135+ ),
136+ ) { entry ->
137+ ServerEditScreen(
138+ container = container,
139+ profileId = entry.arguments?.getString("id"),
140+ sourceId = entry.arguments?.getString("dup"),
141+ onDone = { navController.popBackStack() },
142+ )
143+ }
144+
145+ composable(Routes.SESSIONS) {
146+ if (isTwoPane) {
147+ TwoPaneSessionChat(
148+ container = container,
149+ onOpenFiles = { navController.navigate(Routes.FILES) },
150+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
151+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
152+ onDisconnect = {
153+ scope.launch { runCatchingCancellable { container.disconnect() } }
154+ navController.popBackStack(Routes.SERVERS, inclusive = false)
155+ },
156+ onAddServer = { navController.navigate(Routes.serverEdit()) },
157+ )
111βˆ’ } else {
158+ } else {
112βˆ’ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
159+ SessionListScreen(
113βˆ’ }
160+ container = container,
114βˆ’ },
161+ onOpenSession = { id ->
115βˆ’ label = "screen",
162+ // Set the draft synchronously in-memory before navigating so the
116βˆ’ ) { screen ->
163+ // ChatScreen sees it on first composition. The async persistence
117βˆ’ when (screen) {
164+ // to disk happens in the background via draftStore.set.
118βˆ’ Screen.Connect -> ConnectScreen(state, dispatch)
165+ container.consumePendingShare()?.let { shareText ->
119βˆ’ Screen.Sessions -> SessionsScreen(state, dispatch)
166+ container.draftStore.setImmediate(id, shareText)
120βˆ’ Screen.Chat -> ChatScreen(state, dispatch)
167+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
168+ }
169+ navController.navigate(Routes.chat(id))
170+ },
171+ onDisconnect = {
172+ scope.launch { runCatchingCancellable { container.disconnect() } }
173+ navController.popBackStack(Routes.SERVERS, inclusive = false)
174+ },
175+ onOpenFiles = { navController.navigate(Routes.FILES) },
176+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
177+ onAddServer = { navController.navigate(Routes.serverEdit()) },
178+ )
121 }
179 }
122 }
180 }
181+
182+ composable(
183+ route = "${Routes.CHAT}/{sessionId}",
184+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
185+ ) { entry ->
186+ ChatScreen(
187+ container = container,
188+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
189+ onBack = { navController.popBackStack() },
190+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
191+ )
192+ }
193+
194+ composable(Routes.FILES) {
195+ FileBrowserScreen(
196+ container = container,
197+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
198+ onBack = { navController.popBackStack() },
199+ )
200+ }
201+
202+ composable(
203+ route = "${Routes.FILE_VIEW}?path={path}",
204+ arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
205+ ) { entry ->
206+ FileViewScreen(
207+ container = container,
208+ path = entry.arguments?.getString("path").orEmpty(),
209+ onBack = { navController.popBackStack() },
210+ )
211+ }
212+
213+ composable(Routes.SETTINGS) {
214+ SettingsScreen(
215+ container = container,
216+ onBack = { navController.popBackStack() },
217+ // Navigate to the server list as a new entry on top of Sessions so the
218+ // user can back out to their conversations. Previously this popped back
219+ // to the root SERVERS destination, which removed Sessions from the stack
220+ // and left the user unable to return to their conversations without
221+ // reconnecting. Pop only Settings, then push SERVERS; back from the
222+ // server list lands on Sessions (the screen the user was on before
223+ // Settings), and connecting pushes a fresh SESSIONS as usual.
224+ onManageServers = {
225+ navController.navigate(Routes.SERVERS) {
226+ popUpTo(Routes.SETTINGS) { inclusive = true }
227+ launchSingleTop = true
228+ }
229+ },
230+ onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
231+ )
232+ }
233+
234+ composable(Routes.DIAGNOSTICS) {
235+ DiagnosticsScreen(onBack = { navController.popBackStack() })
236+ }
237+ }
123 }
238 }
124}
239}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+23
@@ -0,0 +1,23 @@
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+ /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
16+ fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
17+
18+ const val CHAT = "chat"
19+ fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
20+
21+ const val FILE_VIEW = "file_view"
22+ fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
23+}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktnew file+16
@@ -0,0 +1,16 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewmodel.CreationExtras
5+import androidx.lifecycle.viewmodel.initializer
6+import androidx.lifecycle.viewmodel.viewModelFactory
7+
8+/**
9+ * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10+ * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11+ */
12+inline fun <reified VM : ViewModel> vmFactory(
13+ crossinline create: (CreationExtras) -> VM,
14+) = viewModelFactory {
15+ initializer { create(this) }
16+}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktnew file+213
@@ -0,0 +1,213 @@
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.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
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.saveable.rememberSaveable
32+import androidx.compose.runtime.setValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.Agent
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every agent and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun AgentPickerSheet(
52+ agents: List<Agent>,
53+ selected: String?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (Agent?) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.agent),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && agents.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (agents.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_agents),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_agents)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(agents, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) agents
116+ else agents.filter {
117+ it.name.contains(q, ignoreCase = true) ||
118+ it.displayDescription.contains(q, ignoreCase = true)
119+ }
120+ }
121+ // The "Default" option participates in the search too: show it when the query is
122+ // empty or matches its label/description. Without this it either always showed
123+ // (ignoring the filter) or, when a query matched no named agent, disappeared with
124+ // the whole list β€” leaving the default unselectable.
125+ val defaultLabel = stringResource(R.string.default_agent)
126+ val defaultDesc = stringResource(R.string.default_agent_desc)
127+ val q = query.trim()
128+ val defaultMatches = q.isEmpty() ||
129+ defaultLabel.contains(q, ignoreCase = true) ||
130+ defaultDesc.contains(q, ignoreCase = true)
131+ if (filtered.isEmpty() && !defaultMatches) {
132+ Text(
133+ stringResource(R.string.no_agents_match, query.trim()),
134+ style = MaterialTheme.typography.bodyMedium,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
137+ )
138+ } else {
139+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
140+ if (defaultMatches) {
141+ item(key = "__default") {
142+ Column(
143+ modifier = Modifier
144+ .fillMaxWidth()
145+ .semantics(mergeDescendants = true) {
146+ if (selected == null) this.selected = true
147+ }
148+ .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
149+ .padding(horizontal = 24.dp, vertical = 12.dp),
150+ ) {
151+ Row(verticalAlignment = Alignment.CenterVertically) {
152+ Text(
153+ stringResource(R.string.default_agent),
154+ style = MaterialTheme.typography.bodyLarge,
155+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
156+ modifier = Modifier.weight(1f),
157+ )
158+ if (selected == null) {
159+ Icon(
160+ Icons.Filled.Check,
161+ contentDescription = null,
162+ tint = MaterialTheme.colorScheme.primary,
163+ modifier = Modifier.size(18.dp),
164+ )
165+ }
166+ }
167+ Text(
168+ stringResource(R.string.default_agent_desc),
169+ style = MaterialTheme.typography.bodySmall,
170+ color = MaterialTheme.colorScheme.onSurfaceVariant,
171+ )
172+ }
173+ }
174+ }
175+ items(filtered, key = { it.name }) { agent ->
176+ val isSelected = agent.name == selected
177+ Column(
178+ modifier = Modifier
179+ .fillMaxWidth()
180+ .semantics(mergeDescendants = true) {
181+ if (isSelected) this.selected = true
182+ }
183+ .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
184+ .padding(horizontal = 24.dp, vertical = 12.dp),
185+ ) {
186+ Row(verticalAlignment = Alignment.CenterVertically) {
187+ Text(
188+ agent.name,
189+ style = MaterialTheme.typography.bodyLarge,
190+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
191+ modifier = Modifier.weight(1f),
192+ )
193+ if (isSelected) {
194+ Icon(
195+ Icons.Filled.Check,
196+ contentDescription = null,
197+ tint = MaterialTheme.colorScheme.primary,
198+ modifier = Modifier.size(18.dp),
199+ )
200+ }
201+ }
202+ Text(
203+ agent.displayDescription,
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ )
207+ }
208+ }
209+ }
210+ }
211+ }
212+ }
213+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+1204
@@ -0,0 +1,1204 @@
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.FlowRow
10+import androidx.compose.foundation.layout.PaddingValues
11+import androidx.compose.foundation.layout.Row
12+import androidx.compose.foundation.layout.Spacer
13+import androidx.compose.foundation.layout.WindowInsets
14+import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.imePadding
17+import androidx.compose.foundation.layout.navigationBars
18+import androidx.compose.foundation.layout.padding
19+import androidx.compose.foundation.layout.size
20+import androidx.compose.foundation.layout.union
21+import androidx.compose.foundation.layout.windowInsetsPadding
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.material.icons.Icons
26+import androidx.compose.material.icons.automirrored.filled.ArrowBack
27+import androidx.compose.material.icons.automirrored.filled.Send
28+import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
31+import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
33+import androidx.compose.material.icons.filled.Refresh
34+import androidx.compose.material.icons.filled.Stop
35+import androidx.compose.material3.AlertDialog
36+import androidx.compose.material3.Button
37+import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
40+import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.ExtendedFloatingActionButton
42+import androidx.compose.material3.FloatingActionButton
43+import androidx.compose.material3.Icon
44+import androidx.compose.material3.IconButton
45+import androidx.compose.material3.MaterialTheme
46+import androidx.compose.material3.OutlinedTextField
47+import androidx.compose.material3.Scaffold
48+import androidx.compose.material3.SnackbarHost
49+import androidx.compose.material3.SnackbarHostState
50+import androidx.compose.material3.Surface
51+import androidx.compose.material3.Text
52+import androidx.compose.material3.TextButton
53+import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
55+import androidx.compose.runtime.Composable
56+import androidx.compose.runtime.CompositionLocalProvider
57+import androidx.compose.runtime.DisposableEffect
58+import androidx.compose.runtime.LaunchedEffect
59+import androidx.compose.runtime.derivedStateOf
60+import androidx.compose.runtime.getValue
61+import androidx.compose.runtime.mutableStateOf
62+import androidx.compose.runtime.remember
63+import androidx.compose.runtime.rememberCoroutineScope
64+import androidx.compose.runtime.rememberUpdatedState
65+import androidx.compose.runtime.saveable.rememberSaveable
66+import androidx.compose.runtime.setValue
67+import androidx.compose.runtime.snapshotFlow
68+import androidx.compose.foundation.text.KeyboardActions
69+import androidx.compose.foundation.text.KeyboardOptions
70+import androidx.compose.ui.text.input.KeyboardCapitalization
71+import androidx.compose.ui.Alignment
72+import androidx.compose.ui.Modifier
73+import androidx.compose.ui.focus.focusRequester
74+import androidx.compose.ui.input.key.Key
75+import androidx.compose.ui.input.key.KeyEventType
76+import androidx.compose.ui.input.key.isCtrlPressed
77+import androidx.compose.ui.input.key.isShiftPressed
78+import androidx.compose.ui.input.key.key
79+import androidx.compose.ui.input.key.onPreviewKeyEvent
80+import androidx.compose.ui.input.key.type
81+import androidx.compose.ui.platform.LocalContext
82+import androidx.compose.ui.platform.LocalHapticFeedback
83+import androidx.compose.ui.platform.LocalView
84+import androidx.compose.ui.platform.testTag
85+import androidx.compose.ui.res.stringResource
86+import androidx.compose.ui.semantics.Role
87+import androidx.compose.ui.semantics.contentDescription
88+import androidx.compose.ui.semantics.semantics
89+import androidx.compose.ui.text.input.ImeAction
90+import androidx.compose.ui.text.style.TextOverflow
91+import androidx.compose.ui.unit.dp
92+import androidx.lifecycle.compose.collectAsStateWithLifecycle
93+import androidx.lifecycle.viewmodel.compose.viewModel
94+import kotlinx.coroutines.Dispatchers
95+import kotlinx.coroutines.launch
96+import kotlinx.coroutines.withContext
97+import soy.iko.opencode.data.model.Part
98+import soy.iko.opencode.data.model.MessageWithParts
99+import soy.iko.opencode.data.model.ReasoningPart
100+import soy.iko.opencode.data.model.TextPart
101+import soy.iko.opencode.data.model.ToolCompleted
102+import soy.iko.opencode.data.model.ToolError
103+import soy.iko.opencode.data.model.ToolPart
104+import soy.iko.opencode.data.model.ToolRunning
105+import soy.iko.opencode.data.network.EventStreamClient
106+import soy.iko.opencode.data.network.NetworkConfig
107+import soy.iko.opencode.di.AppContainer
108+import soy.iko.opencode.R
109+import soy.iko.opencode.ui.components.ConnectionBanner
110+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112+import soy.iko.opencode.ui.components.showToast
113+import soy.iko.opencode.ui.components.toImageContext
114+import soy.iko.opencode.ui.vmFactory
115+import soy.iko.opencode.util.runCatchingCancellable
116+
117+@OptIn(ExperimentalMaterial3Api::class)
118+@Composable
119+fun ChatScreen(
120+ container: AppContainer,
121+ sessionId: String,
122+ onBack: () -> Unit,
123+ onOpenFile: ((String) -> Unit)? = null,
124+) {
125+ val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
126+ val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
127+ val running by vm.running.collectAsStateWithLifecycle()
128+ val aborting by vm.aborting.collectAsStateWithLifecycle()
129+ val loading by vm.loading.collectAsStateWithLifecycle()
130+ val loadError by vm.loadError.collectAsStateWithLifecycle()
131+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
132+ val models by vm.models.collectAsStateWithLifecycle()
133+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
134+ val modelsError by vm.modelsError.collectAsStateWithLifecycle()
135+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
136+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
137+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
138+ val agents by vm.agents.collectAsStateWithLifecycle()
139+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
140+ val agentsError by vm.agentsError.collectAsStateWithLifecycle()
141+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
142+ val commands by vm.commands.collectAsStateWithLifecycle()
143+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
144+ val commandsError by vm.commandsError.collectAsStateWithLifecycle()
145+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147+ val draft by vm.draft.collectAsStateWithLifecycle()
148+ val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
151+ val haptics = LocalHapticFeedback.current
152+ val scope = rememberCoroutineScope()
153+ val shareContext = LocalContext.current
154+ val defaultShareSubject = stringResource(R.string.share_subject)
155+ val sessionLabel = stringResource(R.string.session)
156+ val defaultModelLabel = stringResource(R.string.default_model)
157+ val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
158+ buildString {
159+ append(selectedModel?.modelLabel ?: defaultModelLabel)
160+ selectedAgent?.let { append(" Β· $it") }
161+ }
162+ }
163+
164+ // Connection profile (with auth) for rendering inline image attachments.
165+ // Collect as state so a server switch recomposes the image context.
166+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
167+ val imageContext = remember(activeConnection?.profile) {
168+ activeConnection?.profile?.toImageContext()
169+ }
170+
171+ // Mark this session as read while the user is viewing it; clear on leave so new
172+ // background activity can badge it again.
173+ DisposableEffect(sessionId) {
174+ container.setCurrentSession(sessionId)
175+ onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
176+ }
177+
178+ // Navigate away when the session is deleted via SSE so the user isn't left
179+ // on a zombie screen showing a conversation that no longer exists. Surface a
180+ // toast (not a snackbar) because the screen is about to be popped β€” a snackbar
181+ // would be torn down before the user could read it, while a toast survives.
182+ // Covers both SSE-driven deletion (deleted on another device) and a user-
183+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
184+ // sets the same flag on success).
185+ val deletedContext = LocalContext.current
186+ LaunchedEffect(sessionDeleted) {
187+ if (sessionDeleted) {
188+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
189+ onBack()
190+ }
191+ }
192+
193+ val snackbar = remember { SnackbarHostState() }
194+ val retryLabel = stringResource(R.string.retry)
195+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
196+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
197+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
198+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
199+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
200+ var showStopConfirm by rememberSaveable { mutableStateOf(false) }
201+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
204+
205+ // Keep the screen awake and hold a foreground priority while the agent is working,
206+ // so backgrounding mid-run doesn't let Doze choke the SSE stream.
207+ val currentView = LocalView.current
208+ val appContext = LocalContext.current.applicationContext
209+ DisposableEffect(running) {
210+ currentView.keepScreenOn = running
211+ if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
212+ onDispose {
213+ currentView.keepScreenOn = false
214+ soy.iko.opencode.notification.RunForegroundService.stop(appContext)
215+ }
216+ }
217+
218+ LaunchedEffect(Unit) {
219+ vm.errorEvents.collect { event ->
220+ // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
221+ // message-load failure) would silently re-submit the last prompt on tap.
222+ val result = if (event.retryable) {
223+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
224+ } else {
225+ snackbar.showSnackbar(event.message)
226+ }
227+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
228+ }
229+ }
230+
231+ fun doSend() {
232+ if (vm.send(draft)) {
233+ // TextHandleMove (a light tick) is semantically right for a send commit;
234+ // LongPress is reserved for long-press/swipe gestures.
235+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
236+ }
237+ }
238+
239+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
240+
241+ Scaffold(
242+ topBar = {
243+ TopAppBar(
244+ title = {
245+ val changeLabel = stringResource(R.string.change_model_agent)
246+ Row(
247+ modifier = Modifier
248+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
249+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
250+ verticalAlignment = Alignment.CenterVertically,
251+ ) {
252+ Column(modifier = Modifier.weight(1f, fill = false)) {
253+ // maxLines=1 + Ellipsis so a long session title can't wrap and
254+ // push the top bar to multiple lines, shifting the whole layout.
255+ Text(
256+ sessionTitle ?: sessionLabel,
257+ maxLines = 1,
258+ overflow = TextOverflow.Ellipsis,
259+ )
260+ Text(
261+ subtitle,
262+ style = MaterialTheme.typography.labelSmall,
263+ color = MaterialTheme.colorScheme.onSurfaceVariant,
264+ maxLines = 1,
265+ overflow = TextOverflow.Ellipsis,
266+ )
267+ }
268+ // Visual affordance that the title opens the model picker;
269+ // without it the tappable title is indistinguishable from plain
270+ // text and the picker is effectively undiscoverable.
271+ if (models.isNotEmpty()) {
272+ Icon(
273+ Icons.Filled.KeyboardArrowDown,
274+ contentDescription = null,
275+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
276+ modifier = Modifier.size(20.dp),
277+ )
278+ }
279+ }
280+ },
281+ navigationIcon = {
282+ IconButton(onClick = {
283+ if (running) showExitConfirm = true else onBack()
284+ }) {
285+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
286+ }
287+ },
288+ actions = {
289+ // At-a-glance run status in the top bar. When the user is scrolled
290+ // up (jump-to-latest FAB visible), the input-bar Stop button and the
291+ // trailing "working…" row are off-screen, so a top-bar spinner is the
292+ // only signal the agent is still working.
293+ if (running) {
294+ val workingLabel = stringResource(R.string.working)
295+ CircularProgressIndicator(
296+ Modifier.size(18.dp).semantics { contentDescription = workingLabel },
297+ strokeWidth = 2.dp,
298+ )
299+ }
300+ // Manual refresh: forces an SSE reconnect, which re-seeds messages
301+ // from REST. A recovery path when the stream silently drops and the
302+ // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
303+ // immediate tap feedback β€” the SSE reconnect may not visibly change
304+ // the connection state when already Connected, so without it the tap
305+ // appears to do nothing.
306+ val refreshLabel = stringResource(R.string.refresh)
307+ IconButton(
308+ onClick = { vm.refreshMessages() },
309+ enabled = activeConnection != null && !refreshing,
310+ ) {
311+ if (refreshing) {
312+ CircularProgressIndicator(
313+ Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
314+ strokeWidth = 2.dp,
315+ )
316+ } else {
317+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
318+ }
319+ }
320+ // Overflow menu consolidates the less-frequent actions (share,
321+ // commands, agent, model, rename, delete) so the top bar stays
322+ // scannable on narrow phones β€” previously 5 icons crowded the bar
323+ // alongside the back button and tappable title. Catalog-loading
324+ // spinners stay inline (next to the overflow) so a load state is
325+ // visible without opening the menu.
326+ val moreLabel = stringResource(R.string.more)
327+ val loadingLabel = stringResource(R.string.loading)
328+ val shareLabel = stringResource(R.string.share_conversation)
329+ val commandsLabel = stringResource(R.string.commands)
330+ val agentLabel = stringResource(R.string.choose_agent)
331+ val modelLabel = stringResource(R.string.choose_model)
332+ val renameLabel = stringResource(R.string.rename_session_chat)
333+ val deleteLabel = stringResource(R.string.delete_session_chat)
334+ Box {
335+ IconButton(onClick = { showOverflowMenu = true }) {
336+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
337+ }
338+ DropdownMenu(
339+ expanded = showOverflowMenu,
340+ onDismissRequest = { showOverflowMenu = false },
341+ ) {
342+ DropdownMenuItem(
343+ text = { Text(shareLabel) },
344+ enabled = hasMessages,
345+ onClick = {
346+ showOverflowMenu = false
347+ scope.launch {
348+ val md = withContext(Dispatchers.Default) {
349+ buildConversationMarkdown(vm.messages.value, sessionTitle)
350+ }
351+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
352+ type = "text/markdown"
353+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
354+ putExtra(android.content.Intent.EXTRA_TEXT, md)
355+ }
356+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
357+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
358+ }
359+ },
360+ )
361+ DropdownMenuItem(
362+ text = { Text(commandsLabel) },
363+ enabled = !commandsLoading || commands.isNotEmpty(),
364+ onClick = { showOverflowMenu = false; showCommandPicker = true },
365+ )
366+ DropdownMenuItem(
367+ text = { Text(agentLabel) },
368+ enabled = !agentsLoading || agents.isNotEmpty(),
369+ onClick = { showOverflowMenu = false; showAgentPicker = true },
370+ )
371+ DropdownMenuItem(
372+ text = { Text(modelLabel) },
373+ enabled = !modelsLoading || models.isNotEmpty(),
374+ onClick = { showOverflowMenu = false; showModelPicker = true },
375+ )
376+ androidx.compose.material3.HorizontalDivider()
377+ DropdownMenuItem(
378+ text = { Text(renameLabel) },
379+ onClick = { showOverflowMenu = false; showRenameDialog = true },
380+ )
381+ DropdownMenuItem(
382+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
383+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
384+ )
385+ }
386+ // While a catalog is initially loading (empty list + loading flag),
387+ // show a tiny inline spinner beside the overflow so the user can see
388+ // a load is in progress without opening the menu. Once loaded the
389+ // spinner disappears; the menu items are always enabled (tapping a
390+ // picker with no items shows the "no models/agents/commands" state).
391+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
392+ (agentsLoading && agents.isEmpty()) ||
393+ (modelsLoading && models.isEmpty())
394+ if (catalogLoading) {
395+ CircularProgressIndicator(
396+ Modifier
397+ .align(Alignment.TopEnd)
398+ .size(10.dp)
399+ .semantics { contentDescription = loadingLabel },
400+ strokeWidth = 1.dp,
401+ )
402+ }
403+ }
404+ },
405+ )
406+ },
407+ snackbarHost = { SnackbarHost(snackbar) },
408+ bottomBar = {
409+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
410+ ChatInputBar(
411+ value = draft,
412+ onValueChange = vm::updateDraft,
413+ running = running,
414+ aborting = aborting,
415+ enabled = activeConnection != null,
416+ sendOnEnter = sendOnEnter,
417+ onSend = ::doSend,
418+ onAbort = { showStopConfirm = true },
419+ queuedFollowUp = queuedFollowUp,
420+ onQueueFollowUp = vm::queueFollowUp,
421+ onCancelQueue = { vm.queueFollowUp("") },
422+ focusRequester = inputFocusRequester,
423+ )
424+ },
425+ ) { padding ->
426+ // Collect messages inside the content lambda so streaming token updates
427+ // recompose only this subtree, not the top bar / input bar / 20+ other
428+ // state reads in the ChatScreen body.
429+ val messages by vm.messages.collectAsStateWithLifecycle()
430+ val listState = rememberLazyListState()
431+ val contentScope = rememberCoroutineScope()
432+ val timeTick = rememberRelativeTimeTick()
433+ // Group messages by day and interleave date separators so a long conversation
434+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
435+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
436+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
437+ // the same list the LazyColumn renders (separators shift the indices vs. the
438+ // raw messages list, so scroll targets must be in listItems space).
439+ val todayLabel = stringResource(R.string.today)
440+ val yesterdayLabel = stringResource(R.string.yesterday)
441+ val listItems = remember(messages, todayLabel, yesterdayLabel) {
442+ buildMessageListItems(messages, todayLabel, yesterdayLabel)
443+ }
444+ // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
445+ // derivedStateOf and snapshotFlow lambdas below are created once (keyless
446+ // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
447+ // would capture the first composition's list β€” which is empty, since `messages`
448+ // starts empty β€” freezing it forever. That pinned isPinnedToBottom at true and
449+ // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
450+ // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
451+ // whose value tracks the latest list.
452+ val currentListItems by rememberUpdatedState(listItems)
453+
454+ val isPinnedToBottom by remember {
455+ derivedStateOf {
456+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
457+ // When a run is active, the LazyColumn has one trailing "__typing"
458+ // item after the last list item. Account for it so "pinned to bottom"
459+ // includes the working indicator β€” otherwise the pin check targets
460+ // listItems.lastIndex, the typing row sits just below the viewport,
461+ // and the user never sees the progress indicator even when pinned.
462+ val items = currentListItems
463+ val effectiveLast = if (running) items.size else items.lastIndex
464+ lastVisible >= effectiveLast || items.isEmpty()
465+ }
466+ }
467+
468+ // Jump to the newest message once, when the conversation first loads. Guarded by a
469+ // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
470+ // (rotation) β€” rememberLazyListState restores the user's scroll offset, and an
471+ // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
472+ var didInitialScroll by rememberSaveable { mutableStateOf(false) }
473+ LaunchedEffect(listItems.isNotEmpty()) {
474+ if (listItems.isNotEmpty() && !didInitialScroll) {
475+ // When a run is active the LazyColumn has a trailing "__typing" row at
476+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
477+ // running session lands truly pinned to bottom and streaming auto-scroll engages.
478+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
479+ didInitialScroll = true
480+ }
481+ }
482+
483+ // When a run starts, the LazyColumn gains a trailing "__typing" row, so
484+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
485+ // isPinnedToBottom check flips to false the instant you send β€” freezing
486+ // auto-scroll. If the user was pinned to the last item just before the run
487+ // began, bring the typing row into view so the pin (and streaming follow) is
488+ // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
489+ // recomputed to false by now) so we don't scroll when the user had scrolled up.
490+ LaunchedEffect(running) {
491+ if (running && listItems.isNotEmpty()) {
492+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
493+ if (lastVisible >= listItems.lastIndex) {
494+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
495+ }
496+ }
497+ }
498+
499+ LaunchedEffect(Unit) {
500+ snapshotFlow {
501+ // Track list size + the streaming length of the last part so we auto-scroll
502+ // as content arrives, plus the pinned flag. A small data class with primitive
503+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
504+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
505+ // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
506+ // and tool output β€” not just TextPart β€” keeps a pinned view following a long
507+ // reasoning/tool block while it streams (previously it stalled at 0).
508+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
509+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
510+ }.collect { signal ->
511+ if (signal.size > 0 && signal.pinned) {
512+ // Scroll to the effective last index, including the trailing
513+ // "__typing" row when a run is active so the working indicator
514+ // is brought into view (not just the last message).
515+ val items = currentListItems
516+ val target = if (running) items.size else items.lastIndex
517+ listState.scrollToItem(target)
518+ }
519+ }
520+ }
521+
522+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
523+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
524+ // content padding accounts for the bottomBar's raised height, so the message
525+ // list is already above the keyboard. Adding imePadding here would double-apply
526+ // the IME inset and push messages too far up.
527+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
528+ if (activeConnection == null) {
529+ Column(
530+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
531+ horizontalAlignment = Alignment.CenterHorizontally,
532+ ) {
533+ Text(
534+ stringResource(R.string.not_connected),
535+ color = MaterialTheme.colorScheme.error,
536+ )
537+ Spacer(Modifier.size(12.dp))
538+ Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
539+ if (reconnecting) {
540+ CircularProgressIndicator(
541+ Modifier.size(20.dp),
542+ color = MaterialTheme.colorScheme.onPrimary,
543+ strokeWidth = 2.dp,
544+ )
545+ } else {
546+ Text(stringResource(R.string.reconnect))
547+ }
548+ }
549+ }
550+ } else {
551+ ConnectionBanner(
552+ state = connectionState,
553+ modifier = Modifier.align(Alignment.TopCenter),
554+ isOnline = isOnline,
555+ // On a hard failure, retry by forcing an SSE reconnect (which
556+ // re-seeds from REST). refreshMessages is the right recovery path
557+ // when the connection is present but the stream died; reconnect()
558+ // is the path when the whole connection is gone (handled by the
559+ // separate "Not connected" state below).
560+ onRetry = { vm.refreshMessages() },
561+ )
562+ if (loading && messages.isEmpty()) {
563+ val loadingLabel = stringResource(R.string.loading)
564+ CircularProgressIndicator(
565+ Modifier
566+ .align(Alignment.Center)
567+ .semantics { contentDescription = loadingLabel },
568+ )
569+ } else if (loadError && messages.isEmpty()) {
570+ // A failed load with nothing to show. Distinct from the empty
571+ // conversation state below β€” the conversation isn't empty, it
572+ // failed to load, and offering "Start a conversation" here is
573+ // misleading. Offer a Retry instead, mirroring SessionListScreen.
574+ Column(
575+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
576+ horizontalAlignment = Alignment.CenterHorizontally,
577+ ) {
578+ Text(
579+ stringResource(R.string.load_messages_failed),
580+ color = MaterialTheme.colorScheme.error,
581+ )
582+ Spacer(Modifier.size(12.dp))
583+ Button(onClick = { vm.refreshMessages() }) {
584+ Text(stringResource(R.string.retry))
585+ }
586+ }
587+ } else if (messages.isEmpty() && running) {
588+ // A run just started but no parts have arrived yet β€” the empty
589+ // list with only the trailing "working…" row looks broken, so
590+ // surface a clear starting state until the first part streams in.
591+ val workingText = stringResource(R.string.working)
592+ Column(
593+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
594+ horizontalAlignment = Alignment.CenterHorizontally,
595+ ) {
596+ CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
597+ Spacer(Modifier.size(12.dp))
598+ Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
599+ }
600+ } else if (messages.isEmpty()) {
601+ EmptyConversation(
602+ onSuggestion = {
603+ vm.updateDraft(it)
604+ // Focus the input so the user can edit the suggestion before
605+ // sending, instead of having to manually tap the field.
606+ runCatching { inputFocusRequester.requestFocus() }
607+ },
608+ modifier = Modifier.align(Alignment.Center),
609+ )
610+ } else {
611+ // Pull-to-refresh wraps the message list so the user can force an SSE
612+ // reconnect + re-seed with the same gesture they use on the session and
613+ // file lists, instead of hunting for the top-bar refresh icon. The empty
614+ // / loading / error states above stay outside the PTR box since there's
615+ // no list content to pull against.
616+ PullToRefreshBox(
617+ isRefreshing = refreshing,
618+ onRefresh = { vm.refreshMessages() },
619+ modifier = Modifier.fillMaxSize(),
620+ ) {
621+ val lastMessageId = messages.lastOrNull()?.info?.id
622+ LazyColumn(
623+ state = listState,
624+ modifier = Modifier.fillMaxSize(),
625+ contentPadding = PaddingValues(16.dp),
626+ verticalArrangement = Arrangement.spacedBy(16.dp),
627+ ) {
628+ items(
629+ items = listItems,
630+ key = { it.key },
631+ contentType = { it.contentType },
632+ ) { item ->
633+ when (item) {
634+ is MessageListItem.Separator -> DateSeparator(item.label)
635+ is MessageListItem.Message -> {
636+ val message = item.message
637+ // Only the last (streaming) message needs isRunning β€” it drives the
638+ // reasoning-block spinner. Passing the live flag to every bubble
639+ // makes all visible messages recompose whenever a run starts or stops.
640+ val modelLabel = remember(message.info, models) {
641+ (message.info as? soy.iko.opencode.data.model.AssistantMessage)
642+ ?.let { resolveModelLabel(it, models) }
643+ }
644+ MessageBubble(
645+ message,
646+ isRunning = running && message.info.id == lastMessageId,
647+ imageContext = imageContext,
648+ modelLabel = modelLabel,
649+ onOpenFile = onOpenFile,
650+ )
651+ }
652+ }
653+ }
654+ if (running) {
655+ item(key = "__typing") {
656+ val workingText = stringResource(R.string.working)
657+ Row(
658+ verticalAlignment = Alignment.CenterVertically,
659+ modifier = Modifier.semantics {
660+ contentDescription = workingText
661+ },
662+ ) {
663+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
664+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
665+ }
666+ }
667+ }
668+ }
669+ }
670+ // Jump-to-latest affordance when the user has scrolled away during a stream.
671+ AnimatedVisibility(
672+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
673+ modifier = Modifier.align(Alignment.BottomEnd),
674+ ) {
675+ ExtendedFloatingActionButton(
676+ onClick = {
677+ if (listItems.isNotEmpty()) {
678+ // Scroll to the effective last index, including the
679+ // trailing "__typing" row when a run is active.
680+ val target = if (running) listItems.size else listItems.lastIndex
681+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
682+ }
683+ },
684+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
685+ text = { Text(stringResource(R.string.latest)) },
686+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
687+ )
688+ }
689+ }
690+ }
691+ }
692+ }
693+ }
694+
695+ if (showExitConfirm) {
696+ AlertDialog(
697+ onDismissRequest = { showExitConfirm = false },
698+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
699+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
700+ confirmButton = {
701+ TextButton(onClick = {
702+ showExitConfirm = false
703+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
704+ vm.abort()
705+ onBack()
706+ }) { Text(stringResource(R.string.stop_and_exit)) }
707+ },
708+ dismissButton = {
709+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
710+ },
711+ )
712+ }
713+
714+ if (showStopConfirm) {
715+ AlertDialog(
716+ onDismissRequest = { showStopConfirm = false },
717+ title = { Text(stringResource(R.string.stop_run_title)) },
718+ text = { Text(stringResource(R.string.stop_run_text)) },
719+ confirmButton = {
720+ TextButton(onClick = {
721+ showStopConfirm = false
722+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
723+ vm.abort()
724+ }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
725+ },
726+ dismissButton = {
727+ TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
728+ },
729+ )
730+ }
731+
732+ if (showRenameDialog) {
733+ RenameSessionChatDialog(
734+ initialTitle = sessionTitle ?: "",
735+ onDismiss = { showRenameDialog = false },
736+ onConfirm = { newName ->
737+ showRenameDialog = false
738+ vm.renameSession(newName)
739+ },
740+ )
741+ }
742+
743+ if (showDeleteDialog) {
744+ AlertDialog(
745+ onDismissRequest = { showDeleteDialog = false },
746+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
747+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
748+ confirmButton = {
749+ TextButton(onClick = {
750+ showDeleteDialog = false
751+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
752+ vm.deleteSession()
753+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
754+ },
755+ dismissButton = {
756+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
757+ },
758+ )
759+ }
760+
761+ if (showModelPicker) {
762+ ModelPickerSheet(
763+ options = models,
764+ selected = selectedModel,
765+ loading = modelsLoading,
766+ error = modelsError,
767+ onSelect = { vm.selectModel(it) },
768+ onRetry = { vm.reloadModels() },
769+ onDismiss = { showModelPicker = false },
770+ )
771+ }
772+
773+ if (showAgentPicker) {
774+ AgentPickerSheet(
775+ agents = agents,
776+ selected = selectedAgent,
777+ loading = agentsLoading,
778+ error = agentsError,
779+ onSelect = { vm.selectAgent(it?.name) },
780+ onRetry = { vm.reloadAgents() },
781+ onDismiss = { showAgentPicker = false },
782+ )
783+ }
784+
785+ if (showCommandPicker) {
786+ CommandPickerSheet(
787+ commands = commands,
788+ loading = commandsLoading,
789+ error = commandsError,
790+ onSelect = { vm.runCommand(it) },
791+ onRetry = { vm.reloadCommands() },
792+ onDismiss = { showCommandPicker = false },
793+ )
794+ }
795+
796+ pendingPermission?.let { permission ->
797+ PermissionDialog(
798+ permission = permission,
799+ onRespond = { response -> vm.respondPermission(permission, response) },
800+ )
801+ }
802+}
803+
804+@Composable
805+private fun ChatInputBar(
806+ value: String,
807+ onValueChange: (String) -> Unit,
808+ running: Boolean,
809+ aborting: Boolean,
810+ enabled: Boolean,
811+ sendOnEnter: Boolean,
812+ onSend: () -> Unit,
813+ onAbort: () -> Unit,
814+ queuedFollowUp: String?,
815+ onQueueFollowUp: (String) -> Unit,
816+ onCancelQueue: () -> Unit,
817+ focusRequester: androidx.compose.ui.focus.FocusRequester,
818+) {
819+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
820+ Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
821+ // A queued follow-up replaces the Stop button with a "queued" chip so the
822+ // user sees their message will be sent when the run finishes, and can cancel.
823+ if (queuedFollowUp != null) {
824+ Row(
825+ modifier = Modifier
826+ .fillMaxWidth()
827+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
828+ verticalAlignment = Alignment.CenterVertically,
829+ ) {
830+ Column(modifier = Modifier.weight(1f)) {
831+ Text(
832+ stringResource(R.string.queued),
833+ style = MaterialTheme.typography.labelSmall,
834+ color = MaterialTheme.colorScheme.onSurfaceVariant,
835+ )
836+ Text(
837+ queuedFollowUp,
838+ style = MaterialTheme.typography.bodySmall,
839+ color = MaterialTheme.colorScheme.onSurfaceVariant,
840+ maxLines = 1,
841+ overflow = TextOverflow.Ellipsis,
842+ )
843+ }
844+ TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
845+ Text(stringResource(R.string.cancel))
846+ }
847+ }
848+ }
849+ Row(
850+ modifier = Modifier.fillMaxWidth().padding(8.dp),
851+ verticalAlignment = Alignment.Bottom,
852+ ) {
853+ OutlinedTextField(
854+ value = value,
855+ onValueChange = { v ->
856+ // Cap input length so a huge paste can't stall the UI. Shares are
857+ // capped separately in MainActivity; this guards typed/pasted drafts.
858+ onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
859+ },
860+ modifier = Modifier
861+ .weight(1f)
862+ .focusRequester(focusRequester)
863+ .testTag("chat_input")
864+ .onPreviewKeyEvent { event ->
865+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
866+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
867+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
868+ val send = when {
869+ !enabled || value.isBlank() -> false
870+ sendOnEnter -> !event.isShiftPressed
871+ else -> event.isCtrlPressed
872+ }
873+ if (send) {
874+ if (running) onQueueFollowUp(value) else onSend()
875+ true
876+ } else {
877+ false
878+ }
879+ },
880+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
881+ enabled = enabled,
882+ maxLines = 6,
883+ // Show a "N / max" countdown once the draft crosses a high fraction of
884+ // the cap, so the user knows a paste is about to be truncated instead
885+ // of being silently cut off. Hidden for normal short prompts to avoid
886+ // clutter under a typical one-line message.
887+ supportingText = {
888+ val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
889+ if (value.length >= threshold) {
890+ Text(
891+ stringResource(
892+ R.string.draft_chars_remaining,
893+ NetworkConfig.maxDraftLengthChars - value.length,
894+ NetworkConfig.maxDraftLengthChars,
895+ ),
896+ style = MaterialTheme.typography.labelSmall,
897+ color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
898+ else MaterialTheme.colorScheme.onSurfaceVariant,
899+ )
900+ }
901+ },
902+ keyboardOptions = KeyboardOptions(
903+ imeAction = ImeAction.Send,
904+ capitalization = KeyboardCapitalization.Sentences,
905+ ),
906+ keyboardActions = KeyboardActions(onSend = {
907+ if (enabled && value.isNotBlank()) {
908+ if (running) onQueueFollowUp(value) else onSend()
909+ }
910+ }),
911+ )
912+ if (running) {
913+ // Show a spinner while the abort REST call is in flight so the user
914+ // sees the stop was sent, and disable to prevent a double-tap.
915+ IconButton(
916+ onClick = onAbort,
917+ enabled = !aborting,
918+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
919+ ) {
920+ if (aborting) {
921+ val stopLabel = stringResource(R.string.stop)
922+ CircularProgressIndicator(
923+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924+ strokeWidth = 2.dp,
925+ )
926+ } else {
927+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
928+ }
929+ }
930+ } else {
931+ IconButton(
932+ onClick = onSend,
933+ enabled = enabled && value.isNotBlank(),
934+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
935+ ) {
936+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
937+ }
938+ }
939+ }
940+ }
941+ }
942+}
943+
944+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
945+@Composable
946+private fun EmptyConversation(
947+ onSuggestion: (String) -> Unit,
948+ modifier: Modifier = Modifier,
949+) {
950+ // A few example prompts so a first-time user has a one-tap path to their first
951+ // message instead of staring at a blank input. Tapping a chip fills the draft
952+ // (without sending) so the user can edit it before sending.
953+ val suggestions = remember {
954+ listOf(
955+ R.string.empty_chat_suggest_1,
956+ R.string.empty_chat_suggest_2,
957+ R.string.empty_chat_suggest_3,
958+ )
959+ }
960+ Column(
961+ modifier = modifier.padding(32.dp),
962+ horizontalAlignment = Alignment.CenterHorizontally,
963+ ) {
964+ Icon(
965+ Icons.Filled.AutoAwesome,
966+ contentDescription = null,
967+ modifier = Modifier.size(48.dp),
968+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
969+ )
970+ Spacer(Modifier.size(16.dp))
971+ Text(
972+ stringResource(R.string.empty_chat_title),
973+ style = MaterialTheme.typography.titleMedium,
974+ )
975+ Spacer(Modifier.size(8.dp))
976+ Text(
977+ stringResource(R.string.empty_chat_hint),
978+ style = MaterialTheme.typography.bodyMedium,
979+ color = MaterialTheme.colorScheme.onSurfaceVariant,
980+ )
981+ Spacer(Modifier.size(20.dp))
982+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
983+ // so a longer localized prompt doesn't push the chips off-screen on a phone.
984+ FlowRow(
985+ modifier = Modifier.fillMaxWidth(),
986+ horizontalArrangement = Arrangement.spacedBy(8.dp),
987+ verticalArrangement = Arrangement.spacedBy(8.dp),
988+ ) {
989+ suggestions.forEach { resId ->
990+ val text = stringResource(resId)
991+ androidx.compose.material3.AssistChip(
992+ onClick = { onSuggestion(text) },
993+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
994+ )
995+ }
996+ }
997+ }
998+}
999+
1000+/**
1001+ * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1002+ * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1003+ * equality is structural so `snapshotFlow` emits only when something relevant changes.
1004+ */
1005+private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
1006+
1007+/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
1008+ * Covers text, reasoning, and tool output so a pinned view keeps following non-text
1009+ * streaming, not just plain assistant text. O(1). */
1010+private fun streamingContentLength(part: Part?): Int = when (part) {
1011+ is TextPart -> part.text.length
1012+ is ReasoningPart -> part.text.length
1013+ is ToolPart -> when (val st = part.state) {
1014+ is ToolCompleted -> st.output?.length ?: 0
1015+ is ToolError -> st.error?.length ?: 0
1016+ is ToolRunning -> st.title?.length ?: 0
1017+ else -> 0
1018+ }
1019+ else -> 0
1020+}
1021+
1022+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1023+ * dialog but lives in the chat screen so the user can rename without backing out to
1024+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
1025+@Composable
1026+private fun RenameSessionChatDialog(
1027+ initialTitle: String,
1028+ onDismiss: () -> Unit,
1029+ onConfirm: (String) -> Unit,
1030+) {
1031+ var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1032+ AlertDialog(
1033+ onDismissRequest = onDismiss,
1034+ title = { Text(stringResource(R.string.rename_session_chat)) },
1035+ text = {
1036+ OutlinedTextField(
1037+ value = title,
1038+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1039+ modifier = Modifier.fillMaxWidth(),
1040+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
1041+ label = { Text(stringResource(R.string.rename_session_chat)) },
1042+ singleLine = true,
1043+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1044+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1045+ )
1046+ },
1047+ confirmButton = {
1048+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1049+ Text(stringResource(R.string.save))
1050+ }
1051+ },
1052+ dismissButton = {
1053+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1054+ },
1055+ )
1056+}
1057+
1058+/**
1059+ * A single item in the chat message list β€” either a [Message] bubble or a day
1060+ * [Separator] inserted before the first message of a new day. The separator shifts
1061+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1062+ * the [List] of these items (not `messages.size`/`lastIndex`).
1063+ */
1064+private sealed interface MessageListItem {
1065+ val key: Any
1066+ val contentType: Any
1067+
1068+ data class Message(val message: MessageWithParts) : MessageListItem {
1069+ override val key: Any get() = message.info.id
1070+ override val contentType: Any get() = message.info::class
1071+ }
1072+
1073+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1074+ // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1075+ // can share a label (e.g. an untimestamped message β€” empty label β€” between two
1076+ // same-day messages, or two untimestamped groups), and a label-only key would
1077+ // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1078+ // stable for a given message ordering so slots aren't needlessly recreated.
1079+ override val key: Any get() = "sep_${ordinal}_$label"
1080+ // Separators share a contentType so the LazyColumn can recycle their slots.
1081+ override val contentType: Any get() = "separator"
1082+ }
1083+}
1084+
1085+/**
1086+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1087+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1088+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1089+ * Messages with no timestamp are grouped under an empty-label separator only if they
1090+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
1091+ *
1092+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1093+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1094+ * labels; older days fall back to a locale-stable medium-date format.
1095+ */
1096+private fun buildMessageListItems(
1097+ messages: List<MessageWithParts>,
1098+ todayLabel: String,
1099+ yesterdayLabel: String,
1100+): List<MessageListItem> {
1101+ if (messages.isEmpty()) return emptyList()
1102+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1103+ val dateFmt = mediumDateFormat()
1104+ val result = ArrayList<MessageListItem>(messages.size + 4)
1105+ var lastDayKey: String? = null
1106+ var sepOrdinal = 0
1107+ for (message in messages) {
1108+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1109+ val dayKey = ts?.let { dayKey(it) } ?: ""
1110+ if (dayKey != lastDayKey) {
1111+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1112+ lastDayKey = dayKey
1113+ }
1114+ result.add(MessageListItem.Message(message))
1115+ }
1116+ return result
1117+}
1118+
1119+/**
1120+ * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
1121+ *
1122+ * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
1123+ * whole `messages` list, which must stay so the live message's fresh parts render), so a
1124+ * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
1125+ * every message on every token. Message timestamps are immutable once assigned, so we
1126+ * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
1127+ * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
1128+ * thread during composition, so the coarse lock is uncontended.
1129+ */
1130+private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
1131+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
1132+}
1133+
1134+private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
1135+ dayKeyCache.getOrPut(epochMillis) {
1136+ java.time.Instant.ofEpochMilli(epochMillis)
1137+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
1138+ }
1139+}
1140+
1141+// The medium-date formatter is an ICU locale lookup; cache one instance instead of
1142+// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
1143+// locale changes at runtime. Main-thread-only, so no synchronization is needed.
1144+private var cachedDateFmt: java.text.DateFormat? = null
1145+private var cachedDateFmtLocale: java.util.Locale? = null
1146+
1147+private fun mediumDateFormat(): java.text.DateFormat {
1148+ val locale = java.util.Locale.getDefault()
1149+ val existing = cachedDateFmt
1150+ if (existing != null && cachedDateFmtLocale == locale) return existing
1151+ return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
1152+ cachedDateFmt = it
1153+ cachedDateFmtLocale = locale
1154+ }
1155+}
1156+
1157+/**
1158+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1159+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1160+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1161+ * messages.
1162+ */
1163+private fun dayLabel(
1164+ dayKey: String,
1165+ ts: Long,
1166+ today: java.time.LocalDate,
1167+ todayLabel: String,
1168+ yesterdayLabel: String,
1169+ dateFmt: java.text.DateFormat,
1170+): String {
1171+ if (dayKey.isEmpty()) return ""
1172+ val epochDay = dayKey.toLongOrNull() ?: return ""
1173+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1174+ return when {
1175+ date == today -> todayLabel
1176+ date == today.minusDays(1) -> yesterdayLabel
1177+ else -> dateFmt.format(java.util.Date(ts))
1178+ }
1179+}
1180+
1181+/**
1182+ * A centered day divider in the message list. Renders nothing for an empty label
1183+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1184+ */
1185+@Composable
1186+private fun DateSeparator(label: String) {
1187+ if (label.isBlank()) return
1188+ Row(
1189+ modifier = Modifier.fillMaxWidth(),
1190+ horizontalArrangement = Arrangement.Center,
1191+ ) {
1192+ Surface(
1193+ color = MaterialTheme.colorScheme.surfaceVariant,
1194+ shape = MaterialTheme.shapes.small,
1195+ ) {
1196+ Text(
1197+ label,
1198+ style = MaterialTheme.typography.labelSmall,
1199+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1200+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1201+ )
1202+ }
1203+ }
1204+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+767
@@ -0,0 +1,767 @@
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.network.NetworkConfig
20+import soy.iko.opencode.data.repo.SessionRepository
21+import soy.iko.opencode.di.AppContainer
22+import soy.iko.opencode.R
23+import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
25+import android.util.Log
26+import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.channels.Channel
29+import kotlinx.coroutines.flow.Flow
30+import kotlinx.coroutines.flow.MutableStateFlow
31+import kotlinx.coroutines.flow.SharingStarted
32+import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asStateFlow
34+import kotlinx.coroutines.flow.receiveAsFlow
35+import kotlinx.coroutines.CancellationException
36+import kotlinx.coroutines.flow.collectLatest
37+import kotlinx.coroutines.flow.debounce
38+import kotlinx.coroutines.flow.drop
39+import kotlinx.coroutines.flow.first
40+import kotlinx.coroutines.flow.filterIsInstance
41+import kotlinx.coroutines.flow.flatMapLatest
42+import kotlinx.coroutines.flow.flowOf
43+import kotlinx.coroutines.flow.map
44+import kotlinx.coroutines.flow.merge
45+import kotlinx.coroutines.flow.onEach
46+import kotlinx.coroutines.flow.retryWhen
47+import kotlinx.coroutines.flow.stateIn
48+import kotlinx.coroutines.delay
49+import kotlinx.coroutines.launch
50+
51+/**
52+ * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
53+ * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
54+ * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
55+ * inherit a Retry that silently re-submits the last prompt.
56+ */
57+data class ChatError(val message: String, val retryable: Boolean = false)
58+
59+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
60+class ChatViewModel(
61+ private val container: AppContainer,
62+ private val sessionId: String,
63+) : ViewModel() {
64+
65+ private val connection get() = container.activeConnection.value
66+
67+ val connected: Boolean get() = connection != null
68+
69+ private val _loading = MutableStateFlow(true)
70+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
71+
72+ /** Set when the messages flow failed to load (and the list is empty), so the UI
73+ * can render a distinct error state instead of masquerading as an empty
74+ * conversation. Cleared on a successful non-empty emission. */
75+ private val _loadError = MutableStateFlow(false)
76+ val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
77+
78+ /** Tracks whether the messages flow has ever emitted a non-empty list, so
79+ * [messages]' retryWhen can decide whether a re-fetch failure should set
80+ * [loadError] (nothing shown yet β†’ error screen) or just emit a snackbar
81+ * (already showing messages β†’ don't replace the conversation with an error). */
82+ private var hasShownMessages = false
83+
84+ /** True once we've surfaced a snackbar for the current message-load failure streak.
85+ * Reset on any successful emission so a persistent failure doesn't spam a fresh
86+ * snackbar every retry cycle (every few seconds). */
87+ private var loadErrorSnackbarShown = false
88+
89+ /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
90+ * doesn't conflict with the messages flow's loading state. */
91+ private val _reconnecting = MutableStateFlow(false)
92+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
93+
94+ /** When true, the debounced draft collector skips persisting an empty draft so
95+ * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
96+ * debounce timer firing before the send completes. */
97+ private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
98+
99+ /** The draft value this VM last persisted, so the drafts observer can tell its own
100+ * (debounced, possibly stale) echo apart from a genuine external write. Without this
101+ * a debounced persist of an older value could echo back and overwrite newer text the
102+ * user has since typed. */
103+ @Volatile private var lastPersistedDraft: String? = null
104+
105+ /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
106+ * collectLatest to cancel any in-flight fetch and start a fresh one, so a
107+ * manual reload supersedes a stale observeCatalog fetch. */
108+ private val _modelsReload = MutableStateFlow(0)
109+ private val _agentsReload = MutableStateFlow(0)
110+ private val _commandsReload = MutableStateFlow(0)
111+
112+ val messages: StateFlow<List<MessageWithParts>> =
113+ container.activeConnection
114+ .flatMapLatest { conn ->
115+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
116+ }
117+ .onEach {
118+ _loading.value = false
119+ // A value flowed through: the stream recovered, so allow the next distinct
120+ // failure to surface a fresh snackbar again.
121+ loadErrorSnackbarShown = false
122+ if (it.isNotEmpty()) {
123+ hasShownMessages = true
124+ _loadError.value = false
125+ }
126+ }
127+ .retryWhen { cause, _ ->
128+ _loading.value = false
129+ // Only surface the persistent error state when there's nothing to
130+ // show β€” if we already have messages, a transient re-fetch failure
131+ // is better surfaced as a snackbar (via errorEvents) than by
132+ // replacing the visible conversation with an error screen.
133+ if (!hasShownMessages) _loadError.value = true
134+ // Surface the snackbar only once per failure streak β€” retryWhen loops
135+ // every retryInitialDelayMs, so emitting here unconditionally would spam a
136+ // new snackbar every few seconds on a persistent load failure.
137+ if (!loadErrorSnackbarShown) {
138+ _errorEvents.trySend(ChatError(container.friendlyError(cause)))
139+ loadErrorSnackbarShown = true
140+ }
141+ // Delay before retrying to avoid a tight loop on persistent errors.
142+ delay(NetworkConfig.retryInitialDelayMs)
143+ true
144+ }
145+ .stateIn(
146+ scope = viewModelScope,
147+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
148+ initialValue = emptyList(),
149+ )
150+
151+ /** Whether the conversation has any messages. Derived separately so the top bar
152+ * (share button enabled state) can observe this cheap boolean instead of the
153+ * full messages list, avoiding per-token recomposition of the app bar during
154+ * streaming. */
155+ val hasMessages: StateFlow<Boolean> = messages
156+ .map { it.isNotEmpty() }
157+ .stateIn(
158+ scope = viewModelScope,
159+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
160+ initialValue = false,
161+ )
162+
163+ private val _running = MutableStateFlow(false)
164+ val running: StateFlow<Boolean> = _running.asStateFlow()
165+
166+ /** True while an abort REST call is in flight, so the Stop button can show a
167+ * spinner and prevent double-taps from firing a second abort. */
168+ private val _aborting = MutableStateFlow(false)
169+ val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
170+
171+ /** A follow-up the user typed while a run was active. send() queues it here and
172+ * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
173+ * no way to send and no indication why the Send button is gone. */
174+ private val _queuedFollowUp = MutableStateFlow<String?>(null)
175+ val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
176+
177+ /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
178+ * emitted before the UI subscribes is buffered and still delivered β€” a SharedFlow with
179+ * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
180+ * composition). Each event is delivered exactly once and not replayed to a
181+ * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
182+ private val _errorEvents = Channel<ChatError>(
183+ capacity = NetworkConfig.snackbarEventBufferCapacity,
184+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
185+ )
186+ val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
187+
188+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
189+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
190+
191+ private val _modelsLoading = MutableStateFlow(true)
192+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
193+
194+ private val _modelsError = MutableStateFlow(false)
195+ val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
196+
197+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
198+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
199+
200+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
201+ container.activeConnection
202+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
203+ .stateIn(
204+ scope = viewModelScope,
205+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
206+ initialValue = EventStreamClient.ConnectionState.Disconnected,
207+ )
208+
209+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
210+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
211+
212+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
213+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
214+
215+ private val _agentsLoading = MutableStateFlow(true)
216+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
217+
218+ private val _agentsError = MutableStateFlow(false)
219+ val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
220+
221+ private val _selectedAgent = MutableStateFlow<String?>(null)
222+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
223+
224+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
225+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
226+
227+ private val _commandsLoading = MutableStateFlow(true)
228+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
229+
230+ private val _commandsError = MutableStateFlow(false)
231+ val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
232+
233+ private val _sessionTitle = MutableStateFlow<String?>(null)
234+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
235+
236+ /** Set when the current session is deleted via SSE, so the UI can navigate away. */
237+ private val _sessionDeleted = MutableStateFlow(false)
238+ val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
239+
240+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
241+ private val _failedDraft = MutableStateFlow<String?>(null)
242+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
243+
244+ /** Per-session draft, persisted so it survives navigation/process death. */
245+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
246+ val draft: StateFlow<String> = _draft.asStateFlow()
247+
248+ init {
249+ // DraftStore loads SharedPreferences asynchronously; on a cold start the
250+ // synchronous get() above returns "" until the background load completes.
251+ // Observe the ready signal and re-seed the draft so it appears in the UI.
252+ viewModelScope.launch {
253+ container.draftStore.ready.collect { ready ->
254+ if (!ready) return@collect
255+ if (_draft.value.isEmpty()) {
256+ _draft.value = container.draftStore.get(sessionId)
257+ }
258+ // Recover a follow-up queued before process death (its in-memory StateFlow
259+ // is gone). If the input is now free, drop it back there for review/send and
260+ // consume the stored copy; otherwise re-queue it so it auto-sends when the
261+ // current run next idles. Guarded on == null so it never clobbers a
262+ // follow-up the user queued after opening this session.
263+ val savedFollowUp = container.draftStore.getFollowUp(sessionId)
264+ if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
265+ if (_draft.value.isEmpty()) {
266+ _draft.value = savedFollowUp
267+ container.draftStore.flushFollowUp(sessionId, "")
268+ } else {
269+ _queuedFollowUp.value = savedFollowUp
270+ }
271+ }
272+ }
273+ }
274+ // Observe the drafts store for external mutations to this session's draft
275+ // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
276+ // before navigation in single-pane mode). Without this, a share injected into
277+ // an already-open session never appears in the input field β€” the init-time
278+ // read and the ready-only re-seed miss it when the draft is non-empty.
279+ // The VM's own debounced persist writes the same value _draft already holds,
280+ // so those updates are no-ops here (storeValue == _draft.value). The
281+ // suppressDraftPersist guard prevents a stale store value from clobbering
282+ // the deliberate draft clear during a send (the store still holds the
283+ // pre-send draft until the send resolves).
284+ viewModelScope.launch {
285+ container.draftStore.drafts.collect { drafts ->
286+ // Ignore emissions until the initial disk load completes: the load itself
287+ // emits the persisted snapshot, and applying it here would clobber text the
288+ // user typed during the async load (the `ready` observer above seeds an
289+ // empty draft). After ready, emissions are external writes.
290+ if (!container.draftStore.ready.value) return@collect
291+ val storeValue = drafts[sessionId].orEmpty()
292+ // Ignore this VM's own (debounced, possibly stale) persistence echo β€” only a
293+ // genuine external write (e.g. a two-pane share injection) should overwrite
294+ // the live draft.
295+ if (storeValue == lastPersistedDraft) return@collect
296+ if (storeValue != _draft.value && !suppressDraftPersist.get()) {
297+ _draft.value = storeValue
298+ }
299+ }
300+ }
301+ }
302+
303+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
304+
305+ fun selectAgent(name: String?) { _selectedAgent.value = name }
306+
307+ fun reloadModels() {
308+ val conn = connection ?: return
309+ viewModelScope.launch {
310+ conn.api.invalidateProvidersCache()
311+ _modelsReload.value++
312+ }
313+ }
314+
315+ fun reloadAgents() {
316+ val conn = connection ?: return
317+ viewModelScope.launch {
318+ conn.api.invalidateAgentsCache()
319+ _agentsReload.value++
320+ }
321+ }
322+
323+ fun reloadCommands() {
324+ val conn = connection ?: return
325+ viewModelScope.launch {
326+ conn.api.invalidateCommandsCache()
327+ _commandsReload.value++
328+ }
329+ }
330+
331+ /**
332+ * Shared helper for init-block catalog observers: re-fetches whenever the active
333+ * connection or the catalog's reload trigger changes, invoking [onNull] to clear
334+ * state on null so stale data from the old server doesn't persist. The
335+ * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
336+ * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
337+ * via collectLatest, preventing a stale observe result from overwriting a fresh
338+ * reload result. Failure is non-fatal β€” the error flag is surfaced to the UI.
339+ */
340+ private fun <T> observeCatalog(
341+ tag: String,
342+ loading: MutableStateFlow<Boolean>,
343+ error: MutableStateFlow<Boolean>,
344+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
345+ onSuccess: (T) -> Unit,
346+ onNull: () -> Unit,
347+ reloadTrigger: StateFlow<Int>,
348+ ) {
349+ viewModelScope.launch {
350+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
351+ val conn = container.activeConnection.value
352+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
353+ loading.value = true
354+ error.value = false
355+ runCatchingCancellable { fetch(conn.api) }
356+ // fetch() does HTTP via withRetry, so the failure can be a
357+ // ClientRequestException whose message embeds the full request URL
358+ // (may contain auth/paths). Log only a scrubbed summary.
359+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
360+ .getOrNull()?.let(onSuccess)
361+ loading.value = false
362+ }
363+ }
364+ }
365+
366+ fun updateDraft(text: String) {
367+ _draft.value = text
368+ }
369+
370+ init {
371+ // Debounce draft persistence so we don't write to disk on every keystroke.
372+ // Skip persisting empty drafts that were set by send() (suppressed via
373+ // suppressDraftPersist) β€” send() clears the in-memory draft immediately for
374+ // UI feedback but deliberately doesn't persist the clear until the send
375+ // succeeds, so a failed send can restore the draft.
376+ viewModelScope.launch {
377+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
378+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
379+ lastPersistedDraft = text
380+ runCatchingCancellable { container.draftStore.set(sessionId, text) }
381+ .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
382+ }
383+ }
384+ // Reset per-connection state when the active server changes so stale spinners,
385+ // permission dialogs, errors, and agent selections from the old server don't
386+ // persist into the new one.
387+ viewModelScope.launch {
388+ container.activeConnection.collectLatest { conn ->
389+ if (conn == null) {
390+ // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
391+ // arrive to deliver SessionIdle/SessionError, so reset the run state here β€”
392+ // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
393+ // the RunForegroundService alive, and the Stop button no-ops (abort() early-
394+ // returns with no connection) until a later reconnect happens to clear it.
395+ _running.value = false
396+ _aborting.value = false
397+ _pendingPermission.value = null
398+ return@collectLatest
399+ }
400+ _running.value = false
401+ _pendingPermission.value = null
402+ _failedDraft.value = null
403+ // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
404+ // user intent that is now persisted; wiping it on every (re)connect β€” which
405+ // this collector does, including transient SSE drops and the cold-start
406+ // restore path β€” would drop a legitimately queued/recovered follow-up.
407+ _selectedAgent.value = null
408+ _sessionTitle.value = null
409+ try {
410+ conn.events.events.collect { event ->
411+ if (SessionRepository.isIdle(event, sessionId)) {
412+ _running.value = false
413+ // Auto-send a queued follow-up once the previous run finishes,
414+ // so the user's drafted-while-running message isn't lost.
415+ val queued = _queuedFollowUp.value
416+ if (queued != null) {
417+ setQueuedFollowUp(null)
418+ send(queued)
419+ }
420+ }
421+ if (SessionRepository.isError(event, sessionId)) {
422+ _running.value = false
423+ // The run errored, so the queued follow-up won't auto-send.
424+ // Restore it to the input (if free) so the user's typed text
425+ // isn't silently lost; otherwise just clear the queue.
426+ val queued = _queuedFollowUp.value
427+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
428+ _draft.value = queued
429+ }
430+ setQueuedFollowUp(null)
431+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
432+ }
433+ when (event) {
434+ is PermissionUpdated ->
435+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
436+ is PermissionReplied ->
437+ if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
438+ is SessionUpdated ->
439+ if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
440+ is SessionDeleted ->
441+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
442+ _sessionDeleted.value = true
443+ }
444+ else -> {}
445+ }
446+ }
447+ } catch (e: CancellationException) {
448+ throw e
449+ } catch (e: Exception) {
450+ Log.w("ChatViewModel", "SSE event collector error", e)
451+ }
452+ }
453+ }
454+ // Load the model catalog; preselect the server default. Failure is non-fatal β€”
455+ // sending with no model just uses the server's default agent/model.
456+ observeCatalog(
457+ tag = "model catalog",
458+ loading = _modelsLoading,
459+ error = _modelsError,
460+ fetch = { it.providers() },
461+ onSuccess = { resp ->
462+ val options = resp.toOptions()
463+ _models.value = options
464+ // Only (re)apply the server default when there's no valid current
465+ // selection. On re-fetch/reconnect/Retry the user's chosen model must
466+ // survive β€” keep it as long as it's still present in the refreshed list.
467+ val current = _selectedModel.value
468+ if (current == null || options.none { it.ref == current.ref }) {
469+ _selectedModel.value = resp.defaultOption(options)
470+ }
471+ },
472+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
473+ reloadTrigger = _modelsReload,
474+ )
475+ // Load the agent catalog (non-fatal).
476+ observeCatalog(
477+ tag = "agent catalog",
478+ loading = _agentsLoading,
479+ error = _agentsError,
480+ fetch = { it.agents() },
481+ onSuccess = { _agents.value = it },
482+ onNull = { _agents.value = emptyList() },
483+ reloadTrigger = _agentsReload,
484+ )
485+ // Load the command catalog (non-fatal).
486+ observeCatalog(
487+ tag = "command catalog",
488+ loading = _commandsLoading,
489+ error = _commandsError,
490+ fetch = { it.commands() },
491+ onSuccess = { _commands.value = it },
492+ onNull = { _commands.value = emptyList() },
493+ reloadTrigger = _commandsReload,
494+ )
495+ // Resolve the human-readable session title for the app bar (non-fatal).
496+ // Skips the REST call when the SSE event collector already delivered the title
497+ // (SessionUpdated fires shortly after connect), avoiding a redundant full
498+ // session-list download.
499+ viewModelScope.launch {
500+ container.activeConnection.collectLatest { conn ->
501+ if (conn == null) return@collectLatest
502+ if (_sessionTitle.value != null) return@collectLatest
503+ runCatchingCancellable { conn.repository.listSessions() }
504+ .getOrNull()
505+ ?.firstOrNull { it.id == sessionId }
506+ ?.let { _sessionTitle.value = it.displayTitle }
507+ }
508+ }
509+ // If the SSE stream drops mid-run, the run indicator would spin forever;
510+ // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
511+ viewModelScope.launch {
512+ container.activeConnection.collectLatest { conn ->
513+ if (conn == null) return@collectLatest
514+ try {
515+ conn.events.state.collect { state ->
516+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
517+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
518+ _running.value = false
519+ }
520+ }
521+ } catch (e: CancellationException) {
522+ throw e
523+ } catch (e: Exception) {
524+ Log.w("ChatViewModel", "SSE state collector error", e)
525+ }
526+ }
527+ }
528+ }
529+
530+ /** Sends [text]; returns true on success so the caller can clear the draft only then. */
531+ fun send(text: String): Boolean {
532+ val conn = connection ?: return false
533+ val trimmed = text.trim()
534+ if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
535+ _failedDraft.value = null
536+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
537+ // yet β€” if the send fails and the process dies before we restore, the draft
538+ // would be lost forever. The persisted draft is cleared only on success.
539+ // suppressDraftPersist prevents the debounced collector from persisting the
540+ // empty draft before the send resolves.
541+ //
542+ // Only clear when the text being sent IS the current draft. send() is also
543+ // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
544+ // the user may have typed a NEW draft since β€” wiping it would lose that text.
545+ if (_draft.value.trim() == trimmed) {
546+ suppressDraftPersist.set(true)
547+ _draft.value = ""
548+ }
549+ viewModelScope.launch {
550+ runCatchingCancellable {
551+ conn.repository.sendPrompt(
552+ sessionId,
553+ trimmed,
554+ model = _selectedModel.value?.ref,
555+ agent = _selectedAgent.value,
556+ )
557+ }.onFailure {
558+ suppressDraftPersist.set(false)
559+ _failedDraft.value = trimmed
560+ // Only restore the draft if the user hasn't typed anything new since.
561+ if (_draft.value.isBlank()) updateDraft(trimmed)
562+ // Retryable: this is the failed send whose prompt Retry re-submits.
563+ _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
564+ _running.value = false
565+ }.onSuccess {
566+ suppressDraftPersist.set(false)
567+ // Send succeeded β€” persist the cleared draft, but only if the user hasn't
568+ // typed a new one while the send was in flight. Otherwise clearing the
569+ // store echoes back through the draft observer and wipes the in-progress
570+ // text (data loss). Mirrors the guard on the failure path above.
571+ if (_draft.value.isBlank()) {
572+ lastPersistedDraft = ""
573+ container.draftStore.set(sessionId, "")
574+ }
575+ // Don't reset _running here: the agent continues streaming via SSE.
576+ // _running is cleared on SessionIdle/SessionError (see event collector)
577+ // or when the SSE stream drops (see connection state watcher below).
578+ }
579+ }
580+ return true
581+ }
582+
583+ /** Re-send the last draft whose send failed, if any. */
584+ fun retryFailed() {
585+ val draft = _failedDraft.value ?: return
586+ // Don't clear _failedDraft until send() accepts the text β€” if _running is
587+ // already true, send() returns false and the draft would be lost forever.
588+ if (send(draft)) {
589+ _failedDraft.value = null
590+ }
591+ }
592+
593+ /**
594+ * Queue [text] to be sent automatically when the current run finishes, or clear any
595+ * queued follow-up when [text] is blank. Used when the user taps Send while a run
596+ * is already active β€” the Send button is replaced by Stop during a run, but the
597+ * input field stays enabled, so a follow-up typed mid-run would otherwise have
598+ * nowhere to go.
599+ */
600+ fun queueFollowUp(text: String) {
601+ val trimmed = text.trim()
602+ setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
603+ // The text now lives in the queued chip, so clear the input field just as a
604+ // normal send would. No-op for the cancel case (blank text), which must leave
605+ // whatever the user has since typed untouched.
606+ if (trimmed.isNotEmpty()) _draft.value = ""
607+ }
608+
609+ /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
610+ * queued mid-run survives process death (recovered on the next open of this session).
611+ * Passing null / blank clears both the in-memory value and the persisted copy. */
612+ private fun setQueuedFollowUp(text: String?) {
613+ _queuedFollowUp.value = text
614+ container.draftStore.flushFollowUp(sessionId, text.orEmpty())
615+ }
616+
617+ /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
618+ * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
619+ * refreshMessages may not visibly change [connectionState] (it's already
620+ * Connected), so without this the tap appears to do nothing. Clears after a
621+ * short delay or when the connection state next becomes Connected. */
622+ private val _refreshing = MutableStateFlow(false)
623+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
624+
625+ /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
626+ * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
627+ * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
628+ * or fails β€” a pull-to-refresh or top-bar refresh forces a fresh fetch under the
629+ * existing generation-guarded merge. Also clears [loadError] optimistically and
630+ * sets [refreshing] for immediate tap feedback. */
631+ fun refreshMessages() {
632+ val conn = connection ?: return
633+ _loadError.value = false
634+ _refreshing.value = true
635+ conn.events.triggerReconnect()
636+ viewModelScope.launch {
637+ delay(NetworkConfig.refreshFeedbackMs)
638+ _refreshing.value = false
639+ }
640+ }
641+
642+ /** Invoke a slash-command by name via the server's /command endpoint. */
643+ fun runCommand(command: Command) {
644+ val conn = connection ?: return
645+ // A run is already in flight β€” surface feedback instead of silently dropping the
646+ // command (the picker stays openable during a run and dismisses on select).
647+ if (!_running.compareAndSet(false, true)) {
648+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
649+ return
650+ }
651+ viewModelScope.launch {
652+ runCatchingCancellable {
653+ conn.repository.runCommand(sessionId, command.name, agent = command.agent)
654+ }.onFailure {
655+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
656+ _running.value = false
657+ }
658+ }
659+ }
660+
661+ fun abort() {
662+ val conn = connection ?: return
663+ // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
664+ // auto-send it β€” the user tapped Stop to halt work, not to trigger the next turn.
665+ setQueuedFollowUp(null)
666+ if (!_aborting.compareAndSet(false, true)) return
667+ viewModelScope.launch {
668+ try {
669+ runCatchingCancellable { conn.repository.abort(sessionId) }
670+ .onSuccess { _running.value = false }
671+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
672+ } finally {
673+ _aborting.value = false
674+ }
675+ }
676+ }
677+
678+ /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
679+ * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
680+ * the caller keeps the dialog open so the user can retry without retyping. */
681+ fun renameSession(newTitle: String) {
682+ val conn = connection ?: return
683+ val title = newTitle.trim()
684+ if (title.isEmpty() || title == _sessionTitle.value) return
685+ viewModelScope.launch {
686+ runCatchingCancellable { conn.api.updateSession(sessionId, title) }
687+ .onSuccess { _sessionTitle.value = it.displayTitle }
688+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
689+ }
690+ }
691+
692+ /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
693+ * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
694+ * Unlike [SessionListViewModel.deleteSession] there's no undo window β€” the user is
695+ * already viewing the session, so a confirmation dialog guards the action instead. */
696+ fun deleteSession() {
697+ val conn = connection ?: return
698+ viewModelScope.launch {
699+ runCatchingCancellable { conn.repository.deleteSession(sessionId) }
700+ .onSuccess {
701+ container.draftStore.remove(sessionId)
702+ _sessionDeleted.value = true
703+ }
704+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
705+ }
706+ }
707+
708+ /** Reconnect to the most recently used server profile (used when the connection is gone). */
709+ fun reconnect() {
710+ if (container.activeConnection.value != null) return
711+ // Guard against concurrent reconnect calls: the user can tap the button
712+ // multiple times while _reconnecting is true, which would launch parallel
713+ // connect() coroutines that race to set the active connection, leaking the
714+ // intermediate connections.
715+ if (!_reconnecting.compareAndSet(false, true)) return
716+ viewModelScope.launch {
717+ try {
718+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
719+ .getOrDefault(emptyList())
720+ .firstOrNull()
721+ if (recent == null) {
722+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
723+ return@launch
724+ }
725+ runCatchingCancellable {
726+ val conn = container.connect(recent)
727+ conn.api.ping()
728+ }.onFailure {
729+ container.disconnect()
730+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
731+ }
732+ } finally {
733+ _reconnecting.value = false
734+ }
735+ }
736+ }
737+
738+ fun respondPermission(permission: Permission, response: PermissionResponse) {
739+ val conn = connection ?: return
740+ // Clear optimistically; permission.replied will confirm. If the call fails we
741+ // restore the pending permission so the user can retry instead of being stuck
742+ // with a dismissed dialog and a paused tool run.
743+ _pendingPermission.value = null
744+ viewModelScope.launch {
745+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
746+ .onFailure {
747+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
748+ // Only restore if no new permission has arrived in the meantime.
749+ if (_pendingPermission.value == null) _pendingPermission.value = permission
750+ }
751+ }
752+ }
753+
754+ override fun onCleared() {
755+ super.onCleared()
756+ // Flush any pending debounced draft so it survives navigation. Uses an
757+ // asynchronous apply() (not a synchronous commit) so the main thread isn't
758+ // blocked on disk I/O β€” Android's SharedPreferences framework guarantees
759+ // pending apply() writes are flushed before the process exits.
760+ // Always flush β€” including an empty draft. Persistence is otherwise debounced, so if
761+ // the user cleared the input and navigated back within the debounce window, the
762+ // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
763+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
764+ // unconditional flush commits the clear instead of resurrecting the deleted text.
765+ container.draftStore.flushDraft(sessionId, _draft.value)
766+ }
767+}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+202
@@ -0,0 +1,202 @@
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.foundation.text.KeyboardActions
11+import androidx.compose.foundation.text.KeyboardOptions
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
14+import androidx.compose.material.icons.filled.Close
15+import androidx.compose.material.icons.filled.Search
16+import androidx.compose.material3.CircularProgressIndicator
17+import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.Icon
19+import androidx.compose.material3.IconButton
20+import androidx.compose.material3.MaterialTheme
21+import androidx.compose.material3.ModalBottomSheet
22+import androidx.compose.material3.OutlinedTextField
23+import androidx.compose.material3.Text
24+import androidx.compose.material3.AlertDialog
25+import androidx.compose.material3.TextButton
26+import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.saveable.rememberSaveable
31+import androidx.compose.runtime.setValue
32+import androidx.compose.ui.Alignment
33+import androidx.compose.ui.Modifier
34+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.text.input.ImeAction
40+import androidx.compose.ui.text.style.TextOverflow
41+import androidx.compose.ui.unit.dp
42+import androidx.compose.foundation.layout.Box
43+import soy.iko.opencode.data.model.Command
44+import soy.iko.opencode.R
45+
46+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
47+@OptIn(ExperimentalMaterial3Api::class)
48+@Composable
49+fun CommandPickerSheet(
50+ commands: List<Command>,
51+ loading: Boolean,
52+ error: Boolean,
53+ onSelect: (Command) -> Unit,
54+ onRetry: () -> Unit,
55+ onDismiss: () -> Unit,
56+) {
57+ // Persist only the command *name* (a savable String), not the Command itself: a
58+ // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
59+ // routing it through rememberSaveable crashes on rotation while the confirm dialog
60+ // is open. Re-resolve the full Command from the current list for rendering.
61+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
62+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
63+ ModalBottomSheet(onDismissRequest = onDismiss) {
64+ Text(
65+ stringResource(R.string.commands),
66+ style = MaterialTheme.typography.titleMedium,
67+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
68+ )
69+ if (loading) {
70+ val loadingLabel = stringResource(R.string.loading)
71+ Box(
72+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
73+ contentAlignment = Alignment.Center,
74+ ) {
75+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
76+ }
77+ } else if (error && commands.isEmpty()) {
78+ Column(
79+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
80+ horizontalAlignment = Alignment.CenterHorizontally,
81+ ) {
82+ Text(
83+ stringResource(R.string.load_failed),
84+ style = MaterialTheme.typography.bodyMedium,
85+ color = MaterialTheme.colorScheme.error,
86+ )
87+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
88+ }
89+ } else if (commands.isEmpty()) {
90+ Text(
91+ stringResource(R.string.no_commands),
92+ style = MaterialTheme.typography.bodyMedium,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
95+ )
96+ } else {
97+ var query by rememberSaveable { mutableStateOf("") }
98+ val keyboardController = LocalSoftwareKeyboardController.current
99+ OutlinedTextField(
100+ value = query,
101+ onValueChange = { query = it },
102+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
103+ placeholder = { Text(stringResource(R.string.search_commands)) },
104+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105+ trailingIcon = if (query.isNotEmpty()) {
106+ {
107+ IconButton(onClick = { query = "" }) {
108+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
109+ }
110+ }
111+ } else null,
112+ singleLine = true,
113+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
114+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
115+ )
116+ val filtered = remember(commands, query) {
117+ val q = query.trim()
118+ if (q.isEmpty()) commands
119+ else commands.filter {
120+ it.name.contains(q, ignoreCase = true) ||
121+ it.displayDescription.contains(q, ignoreCase = true)
122+ }
123+ }
124+ if (filtered.isEmpty()) {
125+ Text(
126+ stringResource(R.string.no_commands_match, query.trim()),
127+ style = MaterialTheme.typography.bodyMedium,
128+ color = MaterialTheme.colorScheme.onSurfaceVariant,
129+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
130+ )
131+ } else {
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ items(filtered, key = { it.name }) { cmd ->
134+ Column(
135+ modifier = Modifier
136+ .fillMaxWidth()
137+ .clickable(role = Role.Button) {
138+ if (isDestructiveCommand(cmd.name)) {
139+ pendingCommandName = cmd.name
140+ } else {
141+ onSelect(cmd)
142+ onDismiss()
143+ }
144+ }
145+ .padding(horizontal = 24.dp, vertical = 12.dp),
146+ ) {
147+ androidx.compose.foundation.layout.Row(
148+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
149+ ) {
150+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
151+ Text(
152+ "/${cmd.name}",
153+ style = MaterialTheme.typography.bodyLarge,
154+ modifier = Modifier.padding(start = 8.dp),
155+ )
156+ }
157+ Text(
158+ cmd.displayDescription,
159+ style = MaterialTheme.typography.bodySmall,
160+ color = MaterialTheme.colorScheme.onSurfaceVariant,
161+ maxLines = 2,
162+ overflow = TextOverflow.Ellipsis,
163+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
164+ )
165+ }
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ // Confirmation for commands whose name suggests they may discard work or reset state,
173+ // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
174+ pendingCommand?.let { cmd ->
175+ AlertDialog(
176+ onDismissRequest = { pendingCommandName = null },
177+ title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
178+ text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
179+ confirmButton = {
180+ TextButton(onClick = {
181+ pendingCommandName = null
182+ onSelect(cmd)
183+ onDismiss()
184+ }) { Text(stringResource(R.string.run)) }
185+ },
186+ dismissButton = {
187+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
188+ },
189+ )
190+ }
191+}
192+
193+/**
194+ * Heuristic for commands that may be destructive (discard work / reset state) and so
195+ * warrant a confirmation step before invocation. Matches common opencode slash-command
196+ * names; non-matching commands invoke immediately.
197+ */
198+private fun isDestructiveCommand(name: String): Boolean {
199+ val n = name.lowercase()
200+ return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
201+ n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
202+}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktnew file+124
@@ -0,0 +1,124 @@
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.ToolCompleted
8+import soy.iko.opencode.data.model.ToolError
9+import soy.iko.opencode.data.model.ToolPart
10+import soy.iko.opencode.data.model.ToolRunning
11+import soy.iko.opencode.data.model.UserMessage
12+
13+/**
14+ * Render a conversation as Markdown for sharing/exporting. Each message becomes a
15+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
17+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
18+ */
19+fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
20+ val sb = StringBuilder(messages.size * 128)
21+ if (!title.isNullOrBlank()) {
22+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
23+ }
24+ for (message in messages) {
25+ val heading = when (message.info) {
26+ is UserMessage -> "## You"
27+ is AssistantMessage -> "## opencode"
28+ else -> null
29+ } ?: continue
30+ // The assistant's reply text is already Markdown (code fences, bold, headings);
31+ // escaping it would render the export as backslash noise. Only escape user-authored
32+ // text, which isn't expected to be Markdown.
33+ val escapeText = message.info is UserMessage
34+ val body = message.parts
35+ .mapNotNull { part ->
36+ when (part) {
37+ is TextPart -> part.text.takeIf { it.isNotBlank() }
38+ ?.let { if (escapeText) escapeMarkdown(it) else it }
39+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
40+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
41+ is ToolPart -> formatToolCall(part)
42+ else -> null
43+ }
44+ }
45+ .joinToString("\n\n")
46+ .trim()
47+ if (body.isEmpty()) continue
48+ sb.append(heading).append("\n\n").append(body).append("\n\n")
49+ }
50+ return sb.toString().trimEnd()
51+}
52+
53+/** Render a tool call as a compact, readable blockquote summary: the tool name, its
54+ * human-readable title (if any), and a truncated output/error. Keeps the exported
55+ * transcript useful when the agent's work (edits, command runs) is the substance. */
56+private fun formatToolCall(part: ToolPart): String? {
57+ val title = when (val s = part.state) {
58+ is ToolRunning -> s.title
59+ is ToolCompleted -> s.title
60+ else -> null
61+ }
62+ val detail = when (val s = part.state) {
63+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
64+ is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
65+ else -> null
66+ }
67+ val status = when (part.state) {
68+ is ToolCompleted -> null
69+ is ToolError -> " β€” error"
70+ is ToolRunning -> " β€” running"
71+ else -> null
72+ }
73+ val head = "**${part.tool}**${status ?: ""}"
74+ val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
75+ return buildList {
76+ add("> $head")
77+ if (titleLine != null) add("> $titleLine")
78+ // Prefix every detail line with "> " so the fenced code block stays inside the
79+ // blockquote β€” otherwise lines after the first fall out of the quote.
80+ if (detail != null) {
81+ val quoted = detail.lines().joinToString("\n") { "> $it" }
82+ // Use a fence longer than the longest backtick run in the content, so tool output
83+ // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
84+ // prematurely close the block and make the rest of the transcript render broken.
85+ val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
86+ add(">\n> $fence\n$quoted\n> $fence")
87+ }
88+ }.joinToString("\n").takeIf { it.isNotBlank() }
89+}
90+
91+/** Length of the longest consecutive run of backticks in [text] (0 if none). */
92+private fun longestBacktickRun(text: String): Int {
93+ var max = 0
94+ var current = 0
95+ for (c in text) {
96+ if (c == '`') {
97+ current++
98+ if (current > max) max = current
99+ } else {
100+ current = 0
101+ }
102+ }
103+ return max
104+}
105+
106+private fun truncateOutput(output: String): String {
107+ if (output.length <= TOOL_OUTPUT_LIMIT) return output
108+ return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
109+}
110+
111+private const val TOOL_OUTPUT_LIMIT = 2000
112+
113+/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
114+private fun escapeMarkdown(text: String): String {
115+ if (text.isEmpty()) return text
116+ val sb = StringBuilder(text.length + 16)
117+ for (c in text) {
118+ when (c) {
119+ '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
120+ }
121+ sb.append(c)
122+ }
123+ return sb.toString()
124+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+278
@@ -0,0 +1,278 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.HelpOutline
12+import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.remember
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
23+import androidx.compose.ui.res.stringResource
24+import androidx.compose.ui.unit.dp
25+import soy.iko.opencode.R
26+import soy.iko.opencode.data.model.AssistantMessage
27+import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.ModelOption
29+import soy.iko.opencode.data.model.TextPart
30+import soy.iko.opencode.data.model.Tokens
31+import soy.iko.opencode.data.model.UnknownMessage
32+import soy.iko.opencode.data.model.UserMessage
33+import soy.iko.opencode.data.network.NetworkConfig
34+import soy.iko.opencode.ui.components.ImageLoadContext
35+import soy.iko.opencode.ui.components.copyToClipboard
36+import soy.iko.opencode.ui.components.RelativeTimeText
37+
38+/**
39+ * Resolve an assistant message's model id to a friendly label from the loaded catalog,
40+ * falling back to the raw id (so a model not in the catalog still shows something
41+ * meaningful instead of being hidden). Returns null only when the message has no model.
42+ */
43+fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
44+ val id = info.modelID ?: return null
45+ if (models.isEmpty()) return id
46+ val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
47+ return byPair?.modelLabel
48+ ?: models.firstOrNull { it.modelID == id }?.modelLabel
49+ ?: id
50+}
51+
52+/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
53+@Composable
54+fun MessageBubble(
55+ message: MessageWithParts,
56+ modifier: Modifier = Modifier,
57+ isRunning: Boolean = false,
58+ imageContext: ImageLoadContext? = null,
59+ modelLabel: String? = null,
60+ onOpenFile: ((String) -> Unit)? = null,
61+) {
62+ when (message.info) {
63+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
64+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
65+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
66+ }
67+}
68+
69+@Composable
70+private fun UnknownMessageBlock(
71+ message: MessageWithParts,
72+ imageContext: ImageLoadContext?,
73+ modifier: Modifier,
74+ onOpenFile: ((String) -> Unit)? = null,
75+) {
76+ // Forward-compat: a role the client doesn't model. Render a muted note so the user
77+ // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
78+ // server attached, so content isn't silently dropped.
79+ Column(
80+ modifier = modifier.fillMaxWidth(),
81+ verticalArrangement = Arrangement.spacedBy(8.dp),
82+ ) {
83+ Row(verticalAlignment = Alignment.CenterVertically) {
84+ Icon(
85+ Icons.AutoMirrored.Filled.HelpOutline,
86+ contentDescription = null,
87+ modifier = Modifier.size(14.dp),
88+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
89+ )
90+ Text(
91+ stringResource(R.string.unknown_message),
92+ style = MaterialTheme.typography.labelSmall,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ modifier = Modifier.padding(start = 6.dp),
95+ )
96+ }
97+ for (part in message.parts) {
98+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
99+ }
100+ MessageTimestampText(message.info)
101+ }
102+}
103+
104+@Composable
105+private fun UserBubble(
106+ message: MessageWithParts,
107+ imageContext: ImageLoadContext?,
108+ modifier: Modifier,
109+ onOpenFile: ((String) -> Unit)? = null,
110+) {
111+ val context = LocalContext.current
112+ val copyLabel = stringResource(R.string.copy)
113+ // Collect text from all TextParts for copying, so a user can reuse/repost their
114+ // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
115+ val textToCopy = remember(message.parts) {
116+ message.parts
117+ .filterIsInstance<TextPart>()
118+ .joinToString("\n\n") { it.text }
119+ .takeIf { it.isNotBlank() }
120+ }
121+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
122+ Column(
123+ modifier = Modifier
124+ .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
125+ .clip(MaterialTheme.shapes.medium)
126+ .background(MaterialTheme.colorScheme.primaryContainer)
127+ .padding(12.dp),
128+ verticalArrangement = Arrangement.spacedBy(6.dp),
129+ ) {
130+ for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
131+ Row(
132+ modifier = Modifier.fillMaxWidth(),
133+ horizontalArrangement = Arrangement.SpaceBetween,
134+ verticalAlignment = Alignment.CenterVertically,
135+ ) {
136+ MessageTimestampText(message.info)
137+ // Copy affordance for user prompts, mirroring the assistant block.
138+ // Without it, reusing a prior prompt requires discovering the
139+ // long-press on the markdown text (and only TextParts support that).
140+ if (textToCopy != null) {
141+ IconButton(
142+ onClick = { copyToClipboard(context, "message", textToCopy) },
143+ ) {
144+ Icon(
145+ Icons.Filled.ContentCopy,
146+ contentDescription = copyLabel,
147+ modifier = Modifier.size(18.dp),
148+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
149+ )
150+ }
151+ }
152+ }
153+ }
154+ }
155+}
156+
157+@Composable
158+private fun AssistantBlock(
159+ message: MessageWithParts,
160+ isRunning: Boolean,
161+ imageContext: ImageLoadContext?,
162+ modifier: Modifier,
163+ modelLabel: String? = null,
164+ onOpenFile: ((String) -> Unit)? = null,
165+) {
166+ Column(
167+ modifier = modifier.fillMaxWidth(),
168+ verticalArrangement = Arrangement.spacedBy(8.dp),
169+ ) {
170+ val info = message.info
171+ if (info is AssistantMessage) {
172+ val label = modelLabel ?: info.modelID
173+ if (label != null) {
174+ Text(
175+ label,
176+ style = MaterialTheme.typography.labelSmall,
177+ color = MaterialTheme.colorScheme.onSurfaceVariant,
178+ )
179+ }
180+ }
181+ message.parts.forEachIndexed { index, part ->
182+ // Only the final part of a running message is actively streaming; earlier parts
183+ // (a finished reasoning block, a completed tool call) are already done, so passing
184+ // isRunning to all of them would keep a completed reasoning block showing the
185+ // "Thinking…" spinner until the whole message finishes.
186+ val partStreaming = isRunning && index == message.parts.lastIndex
187+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
188+ }
189+ if (info is AssistantMessage) {
190+ val cost = info.cost
191+ val tokens = info.tokens
192+ val tokenFormat = stringResource(R.string.tokens_in_out)
193+ val costShort = stringResource(R.string.cost_format_short)
194+ val costLong = stringResource(R.string.cost_format_long)
195+ // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
196+ // state-flip recomposition doesn't re-run NumberFormat + buildList +
197+ // joinToString for every visible assistant bubble.
198+ val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
199+ if (!info.isComplete || (cost == null && tokens == null)) null
200+ else buildList {
201+ // Skip an all-zero token count (e.g. a completed message that reported no
202+ // usage) so the bubble doesn't show a meaningless "0 in Β· 0 out".
203+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
204+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
205+ }.takeIf { it.isNotEmpty() }?.joinToString(" β€’ ")
206+ }
207+ Row(
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.SpaceBetween,
210+ verticalAlignment = Alignment.CenterVertically,
211+ ) {
212+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
213+ costSummary?.let {
214+ Text(
215+ it,
216+ style = MaterialTheme.typography.labelSmall,
217+ color = MaterialTheme.colorScheme.onSurfaceVariant,
218+ )
219+ }
220+ MessageTimestampText(message.info)
221+ }
222+ val context = LocalContext.current
223+ val copyLabel = stringResource(R.string.copy)
224+ // Collect text from all TextParts for copying. Memoized so a
225+ // scroll-induced recomposition doesn't re-scan the parts list.
226+ val textToCopy = remember(message.parts) {
227+ message.parts
228+ .filterIsInstance<TextPart>()
229+ .joinToString("\n\n") { it.text }
230+ .takeIf { it.isNotBlank() }
231+ }
232+ if (textToCopy != null) {
233+ IconButton(
234+ onClick = { copyToClipboard(context, "message", textToCopy) },
235+ ) {
236+ Icon(
237+ Icons.Filled.ContentCopy,
238+ contentDescription = copyLabel,
239+ modifier = Modifier.size(18.dp),
240+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
241+ )
242+ }
243+ }
244+ }
245+ } else {
246+ MessageTimestampText(message.info)
247+ }
248+ }
249+}
250+
251+@Composable
252+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
253+ // Compact relative label by default; long-press reveals the full absolute timestamp
254+ // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
255+ RelativeTimeText(info.time?.created)
256+}
257+
258+// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
259+// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
260+// when the message list re-seeds after a reconnect and every visible assistant bubble
261+// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
262+// not thread-safe.
263+private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
264+ java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
265+}
266+
267+private fun formatTokens(tokens: Tokens, format: String): String {
268+ val nf = tokenNumberFormat.get()!!
269+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
270+}
271+
272+private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
273+ // Locale.US so the formatting is stable regardless of device locale (avoids
274+ // non-ASCII digits or comma decimal separators in a dollar amount). The format
275+ // string itself is localized via strings.xml so the currency symbol can be
276+ // adapted by translators.
277+ if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
278+ else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+180
@@ -0,0 +1,180 @@
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.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
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.saveable.rememberSaveable
32+import androidx.compose.runtime.setValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.ModelOption
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every provider/model and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun ModelPickerSheet(
52+ options: List<ModelOption>,
53+ selected: ModelOption?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (ModelOption) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.model),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && options.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (options.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_models),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_models)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(options, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) options
116+ else options.filter {
117+ it.modelLabel.contains(q, ignoreCase = true) ||
118+ it.providerLabel.contains(q, ignoreCase = true)
119+ }
120+ }
121+ if (filtered.isEmpty()) {
122+ Text(
123+ stringResource(R.string.no_models_match, query.trim()),
124+ style = MaterialTheme.typography.bodyMedium,
125+ color = MaterialTheme.colorScheme.onSurfaceVariant,
126+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
127+ )
128+ } else {
129+ // Group by provider so a long catalog is scannable: a provider header
130+ // followed by its models, instead of a flat list mixing providers.
131+ val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ grouped.forEach { (providerID, opts) ->
134+ item(key = "header_$providerID") {
135+ Text(
136+ opts.firstOrNull()?.providerLabel ?: providerID,
137+ style = MaterialTheme.typography.labelMedium,
138+ color = MaterialTheme.colorScheme.primary,
139+ modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
140+ )
141+ }
142+ items(opts, key = { it.providerID to it.modelID }) { option ->
143+ val isSelected = option.providerID == selected?.providerID &&
144+ option.modelID == selected?.modelID
145+ Column(
146+ modifier = Modifier
147+ .fillMaxWidth()
148+ .semantics(mergeDescendants = true) {
149+ if (isSelected) this.selected = true
150+ }
151+ .clickable(role = Role.RadioButton) {
152+ onSelect(option)
153+ onDismiss()
154+ }
155+ .padding(horizontal = 24.dp, vertical = 12.dp),
156+ ) {
157+ Row(verticalAlignment = Alignment.CenterVertically) {
158+ Text(
159+ option.modelLabel,
160+ style = MaterialTheme.typography.bodyLarge,
161+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
162+ modifier = Modifier.weight(1f),
163+ )
164+ if (isSelected) {
165+ Icon(
166+ Icons.Filled.Check,
167+ contentDescription = null,
168+ tint = MaterialTheme.colorScheme.primary,
169+ modifier = Modifier.size(18.dp),
170+ )
171+ }
172+ }
173+ }
174+ }
175+ }
176+ }
177+ }
178+ }
179+ }
180+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+494
@@ -0,0 +1,494 @@
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.combinedClickable
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.defaultMinSize
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.text.selection.SelectionContainer
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.HelpOutline
16+import androidx.compose.material.icons.filled.Bolt
17+import androidx.compose.material.icons.filled.CheckCircle
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.Description
20+import androidx.compose.material.icons.filled.Error
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.Psychology
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.saveable.rememberSaveable
35+import androidx.compose.runtime.setValue
36+import androidx.compose.ui.Alignment
37+import androidx.compose.ui.Modifier
38+import androidx.compose.ui.draw.clip
39+import androidx.compose.ui.platform.LocalContext
40+import androidx.compose.ui.semantics.Role
41+import androidx.compose.ui.semantics.contentDescription
42+import androidx.compose.ui.semantics.semantics
43+import androidx.compose.ui.semantics.stateDescription
44+import androidx.compose.ui.text.font.FontFamily
45+import androidx.compose.ui.res.stringResource
46+import androidx.compose.ui.unit.dp
47+import kotlinx.serialization.json.Json
48+import kotlinx.serialization.json.JsonElement
49+import soy.iko.opencode.data.model.FilePart
50+import soy.iko.opencode.data.model.Part
51+import soy.iko.opencode.data.model.ReasoningPart
52+import soy.iko.opencode.data.model.StepFinishPart
53+import soy.iko.opencode.data.model.StepStartPart
54+import soy.iko.opencode.data.model.TextPart
55+import soy.iko.opencode.data.model.ToolCompleted
56+import soy.iko.opencode.data.model.ToolError
57+import soy.iko.opencode.data.model.ToolPart
58+import soy.iko.opencode.data.model.ToolPending
59+import soy.iko.opencode.data.model.ToolRunning
60+import soy.iko.opencode.data.model.ToolState
61+import soy.iko.opencode.data.model.ToolUnknown
62+import soy.iko.opencode.data.model.UnknownPart
63+import soy.iko.opencode.ui.components.DiffView
64+import soy.iko.opencode.ui.components.ImageLoadContext
65+import soy.iko.opencode.ui.components.MarkdownText
66+import soy.iko.opencode.ui.components.RemoteImage
67+import soy.iko.opencode.ui.components.copyToClipboard
68+import soy.iko.opencode.ui.components.isImage
69+import soy.iko.opencode.ui.components.looksLikeDiff
70+import soy.iko.opencode.ui.components.showToast
71+import soy.iko.opencode.R
72+
73+private const val COLLAPSED_LIMIT = 600
74+
75+// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
76+// resilient decoding) so this stays human-readable; constructed lazily and memoized.
77+private val prettyJson: Json = Json { prettyPrint = true }
78+
79+/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
80+private fun ToolState.titleText(): String? = when (this) {
81+ is ToolRunning -> title
82+ is ToolCompleted -> title
83+ else -> null
84+}
85+
86+/** Extract the raw input arguments of a tool call, if any. */
87+private fun ToolState.inputJson(): JsonElement? = when (this) {
88+ is ToolRunning -> input
89+ is ToolCompleted -> input
90+ is ToolError -> input
91+ else -> null
92+}
93+
94+/**
95+ * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
96+ * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
97+ *
98+ * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
99+ * source path the viewer can open; null leaves the chip as a copy-path affordance
100+ * (used when no file navigation is wired, e.g. in tests).
101+ */
102+@Composable
103+fun PartView(
104+ part: Part,
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
109+) {
110+ when (part) {
111+ is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
112+ MarkdownText(part.text, modifier = modifier, streaming = isRunning)
113+ }
114+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
115+ is ToolPart -> ToolCallView(part, modifier)
116+ is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
117+ RemoteImage(part, imageContext, modifier)
118+ } else {
119+ FileChip(part, modifier, onOpenFile)
120+ }
121+ is StepStartPart -> {} // boundary marker β€” nothing to draw
122+ is StepFinishPart -> {} // metrics handled at message level
123+ is UnknownPart -> UnknownPartNote(modifier)
124+ }
125+}
126+
127+@Composable
128+private fun UnknownPartNote(modifier: Modifier) {
129+ // Forward-compat: a part type the client doesn't model. Render a muted note (matching
130+ // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
131+ // the part vanishing silently β€” important when a new server release adds a part type.
132+ Row(
133+ modifier = modifier.fillMaxWidth(),
134+ verticalAlignment = Alignment.CenterVertically,
135+ ) {
136+ Icon(
137+ Icons.AutoMirrored.Filled.HelpOutline,
138+ contentDescription = null,
139+ modifier = Modifier.size(14.dp),
140+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
141+ )
142+ Text(
143+ stringResource(R.string.unknown_part),
144+ style = MaterialTheme.typography.labelSmall,
145+ color = MaterialTheme.colorScheme.onSurfaceVariant,
146+ modifier = Modifier.padding(start = 6.dp),
147+ )
148+ }
149+}
150+
151+@Composable
152+private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
153+ if (text.isBlank()) return
154+ val context = LocalContext.current
155+ // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
156+ // inherit the positional saveable key of a prior block. A content-prefix key would
157+ // change on nearly every token while streaming and snap the block back to collapsed;
158+ // fall back to a content prefix only when the part has no id.
159+ val saveableKey = keyId.ifBlank { text.take(64) }
160+ var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
161+ val expandedState = stringResource(R.string.state_expanded)
162+ val collapsedState = stringResource(R.string.state_collapsed)
163+ val thinkingLabel = stringResource(R.string.thinking)
164+ val thoughtsLabel = stringResource(R.string.thoughts)
165+ Column(modifier = modifier.fillMaxWidth()) {
166+ Row(
167+ modifier = Modifier
168+ .fillMaxWidth()
169+ .defaultMinSize(minHeight = 48.dp)
170+ .clickable(role = Role.Button) { expanded = !expanded }
171+ .padding(vertical = 4.dp)
172+ .semantics {
173+ stateDescription = if (expanded) expandedState else collapsedState
174+ },
175+ verticalAlignment = Alignment.CenterVertically,
176+ ) {
177+ if (streaming) {
178+ CircularProgressIndicator(
179+ Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
180+ strokeWidth = 2.dp,
181+ color = MaterialTheme.colorScheme.onSurfaceVariant,
182+ )
183+ } else {
184+ Icon(
185+ Icons.Filled.Psychology,
186+ contentDescription = null,
187+ modifier = Modifier.size(16.dp),
188+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
189+ )
190+ }
191+ Text(
192+ if (streaming) thinkingLabel else thoughtsLabel,
193+ style = MaterialTheme.typography.labelMedium,
194+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195+ modifier = Modifier.padding(start = 6.dp),
196+ )
197+ }
198+ AnimatedVisibility(visible = expanded) {
199+ Column {
200+ // Wrap in SelectionContainer so the user can select a portion of the
201+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
202+ // button below. Matches the markdown text's selectability.
203+ SelectionContainer {
204+ Text(
205+ text,
206+ style = MaterialTheme.typography.bodySmall,
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ )
209+ }
210+ TextButton(
211+ onClick = { copyToClipboard(context, "reasoning", text) },
212+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213+ modifier = Modifier.semantics(mergeDescendants = true) {},
214+ ) {
215+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
216+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
217+ }
218+ }
219+ }
220+ }
221+}
222+
223+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
224+@Composable
225+private fun ToolCallView(part: ToolPart, modifier: Modifier) {
226+ val context = LocalContext.current
227+ Column(
228+ modifier = modifier
229+ .fillMaxWidth()
230+ .clip(MaterialTheme.shapes.small)
231+ .background(MaterialTheme.colorScheme.surfaceVariant)
232+ .padding(10.dp),
233+ ) {
234+ Row(verticalAlignment = Alignment.CenterVertically) {
235+ ToolStatusIcon(part.state)
236+ Text(
237+ part.tool,
238+ style = MaterialTheme.typography.labelLarge,
239+ fontFamily = FontFamily.Monospace,
240+ modifier = Modifier.padding(start = 6.dp),
241+ )
242+ }
243+ // A human-readable summary of what the tool was asked to do (e.g. "Reading
244+ // src/main.kt"), when the server provides one. Sits directly under the tool
245+ // name so the user understands the call before diving into input/output.
246+ part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
247+ Text(
248+ title,
249+ style = MaterialTheme.typography.bodySmall,
250+ color = MaterialTheme.colorScheme.onSurfaceVariant,
251+ modifier = Modifier.padding(start = 22.dp, top = 2.dp),
252+ )
253+ }
254+ // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
255+ // output so the call reads top-to-bottom: name β†’ what β†’ input β†’ result.
256+ part.state.inputJson()?.let { input ->
257+ val inputLabel = stringResource(R.string.tool_input)
258+ val pretty = remember(input) {
259+ runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
260+ }
261+ CollapsibleDetail(
262+ label = inputLabel,
263+ detail = pretty,
264+ isDiff = false,
265+ keySuffix = "input",
266+ // The input block has a label row whose collapse toggle announces its state
267+ // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
268+ // rather than an empty state (the defaults are "").
269+ expandedState = stringResource(R.string.state_expanded),
270+ collapsedState = stringResource(R.string.state_collapsed),
271+ )
272+ }
273+ val detail = when (val s = part.state) {
274+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
275+ is ToolError -> s.error ?: stringResource(R.string.error_generic)
276+ else -> null
277+ }
278+ if (detail != null) {
279+ val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
280+ var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
281+ val expandedState = stringResource(R.string.state_expanded)
282+ val collapsedState = stringResource(R.string.state_collapsed)
283+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
284+ // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
285+ // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
286+ val isDiff = remember(display) { looksLikeDiff(display) }
287+ // How many lines are hidden while collapsed. detail.lines() splits the whole
288+ // (potentially multi-KB) output, so memoize it β€” otherwise it re-splits on
289+ // every recomposition, including each streaming update of a running tool.
290+ // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
291+ // 0 (a single long line cut mid-way) falls back to the generic label.
292+ val moreLines = remember(detail, collapsed) {
293+ if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
294+ }
295+ CollapsibleDetail(
296+ label = null,
297+ detail = display,
298+ isDiff = isDiff,
299+ keySuffix = "output",
300+ expanded = expanded || detail.length <= COLLAPSED_LIMIT,
301+ onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
302+ { expanded = !expanded }
303+ } else null,
304+ moreLines = moreLines,
305+ expandedState = expandedState,
306+ collapsedState = collapsedState,
307+ onCopy = { copyToClipboard(context, "output", detail) },
308+ )
309+ }
310+ }
311+}
312+
313+/**
314+ * A collapsible monospace detail block shared by tool input and output. [label] is an
315+ * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
316+ * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
317+ * with an expand/collapse affordance when [onToggleExpand] is supplied.
318+ */
319+@Composable
320+private fun CollapsibleDetail(
321+ label: String?,
322+ detail: String,
323+ isDiff: Boolean,
324+ keySuffix: String,
325+ expanded: Boolean = false,
326+ onToggleExpand: (() -> Unit)? = null,
327+ moreLines: Int = 0,
328+ expandedState: String = "",
329+ collapsedState: String = "",
330+ onCopy: (() -> Unit)? = null,
331+) {
332+ Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
333+ if (label != null) {
334+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
335+ Row(
336+ modifier = Modifier
337+ .fillMaxWidth()
338+ .defaultMinSize(minHeight = 48.dp)
339+ .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
340+ .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
341+ verticalAlignment = Alignment.CenterVertically,
342+ ) {
343+ Icon(
344+ if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
345+ contentDescription = null,
346+ modifier = Modifier.size(16.dp),
347+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
348+ )
349+ Text(
350+ label,
351+ style = MaterialTheme.typography.labelSmall,
352+ color = MaterialTheme.colorScheme.onSurfaceVariant,
353+ modifier = Modifier.padding(start = 4.dp),
354+ )
355+ }
356+ if (!inputExpanded) return@Column
357+ }
358+ if (isDiff) {
359+ DiffView(detail)
360+ } else {
361+ // SelectionContainer so the user can select a portion of the output (e.g.
362+ // a single line of stdout) instead of only copy-all via the button below.
363+ SelectionContainer {
364+ Text(
365+ detail,
366+ style = MaterialTheme.typography.bodySmall,
367+ fontFamily = FontFamily.Monospace,
368+ )
369+ }
370+ }
371+ if (onToggleExpand != null) {
372+ TextButton(
373+ onClick = onToggleExpand,
374+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
375+ modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
376+ ) {
377+ Icon(
378+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
379+ contentDescription = null,
380+ modifier = Modifier.size(16.dp),
381+ )
382+ Text(
383+ when {
384+ expanded -> stringResource(R.string.show_less)
385+ moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
386+ else -> stringResource(R.string.show_more)
387+ },
388+ style = MaterialTheme.typography.labelSmall,
389+ modifier = Modifier.padding(start = 4.dp),
390+ )
391+ }
392+ }
393+ if (onCopy != null) {
394+ TextButton(
395+ onClick = onCopy,
396+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
397+ modifier = Modifier.semantics(mergeDescendants = true) {},
398+ ) {
399+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
400+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
401+ }
402+ }
403+ }
404+}
405+
406+@Composable
407+private fun ToolStatusIcon(state: ToolState) {
408+ when (state) {
409+ is ToolPending, is ToolRunning -> {
410+ val label = stringResource(R.string.tool_running)
411+ CircularProgressIndicator(
412+ modifier = Modifier.size(16.dp).semantics { contentDescription = label },
413+ strokeWidth = 2.dp,
414+ )
415+ }
416+ is ToolCompleted ->
417+ Icon(
418+ Icons.Filled.CheckCircle,
419+ contentDescription = stringResource(R.string.tool_completed),
420+ modifier = Modifier.size(16.dp),
421+ tint = MaterialTheme.colorScheme.primary,
422+ )
423+ is ToolError ->
424+ Icon(
425+ Icons.Filled.Error,
426+ contentDescription = stringResource(R.string.tool_error),
427+ modifier = Modifier.size(16.dp),
428+ tint = MaterialTheme.colorScheme.error,
429+ )
430+ is ToolUnknown ->
431+ Icon(
432+ Icons.Filled.Bolt,
433+ contentDescription = stringResource(R.string.tool_unknown),
434+ modifier = Modifier.size(16.dp),
435+ )
436+ }
437+}
438+
439+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
440+@Composable
441+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
442+ val context = LocalContext.current
443+ val path = part.source ?: part.url ?: part.filename
444+ val copyLabel = stringResource(R.string.copy_path)
445+ val openLabel = stringResource(R.string.open_file)
446+ // When a source path is available and a navigation callback is wired, tapping the
447+ // chip opens the file in the viewer (the action a user tapping a file reference
448+ // most likely expects). Long-press still copies the path to the clipboard, so the
449+ // copy affordance is preserved without being the default tap action. When no
450+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
451+ val source = part.source
452+ val opener = onOpenFile
453+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
454+ val canOpen = openPath != null
455+ val semanticsLabel = if (canOpen) openLabel else copyLabel
456+ Row(
457+ modifier = modifier
458+ .clip(MaterialTheme.shapes.small)
459+ .background(MaterialTheme.colorScheme.surfaceVariant)
460+ .combinedClickable(
461+ role = Role.Button,
462+ onClick = {
463+ val toOpen = openPath
464+ if (toOpen != null) {
465+ opener?.invoke(toOpen)
466+ } else if (!path.isNullOrBlank()) {
467+ copyToClipboard(context, "path", path)
468+ showToast(context, context.getString(R.string.path_copied))
469+ }
470+ },
471+ onLongClick = {
472+ if (!path.isNullOrBlank()) {
473+ copyToClipboard(context, "path", path)
474+ showToast(context, context.getString(R.string.path_copied))
475+ }
476+ },
477+ )
478+ .semantics { contentDescription = semanticsLabel }
479+ .padding(horizontal = 12.dp, vertical = 6.dp),
480+ verticalAlignment = Alignment.CenterVertically,
481+ ) {
482+ Icon(
483+ Icons.Filled.Description,
484+ contentDescription = null,
485+ modifier = Modifier.size(16.dp),
486+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
487+ )
488+ Text(
489+ part.filename ?: part.url ?: stringResource(R.string.file),
490+ style = MaterialTheme.typography.bodyMedium,
491+ modifier = Modifier.padding(start = 6.dp),
492+ )
493+ }
494+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+118
@@ -0,0 +1,118 @@
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.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Shield
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.OutlinedButton
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.mutableStateOf
19+import androidx.compose.runtime.remember
20+import androidx.compose.runtime.saveable.rememberSaveable
21+import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.setValue
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.platform.LocalHapticFeedback
25+import androidx.compose.ui.res.stringResource
26+import androidx.compose.ui.text.font.FontFamily
27+import androidx.compose.ui.unit.dp
28+import soy.iko.opencode.data.model.Permission
29+import soy.iko.opencode.data.model.PermissionResponse
30+import soy.iko.opencode.R
31+
32+/**
33+ * Modal asking the user to approve a tool the agent wants to run. The agent run is
34+ * paused until one of these responses is sent. Non-dismissable by tap-outside so the
35+ * decision is explicit.
36+ */
37+@Composable
38+fun PermissionDialog(
39+ permission: Permission,
40+ onRespond: (PermissionResponse) -> Unit,
41+) {
42+ val haptics = LocalHapticFeedback.current
43+ // Guard against double-respond: back press + button tap, or rapid double-tap,
44+ // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45+ // Keyed on permission.id: the host mounts this dialog against a conflating StateFlow,
46+ // so a null -> permissionB transition can collapse to just permissionB, reusing this
47+ // composable for a different permission. Without the key `responded` would stay true
48+ // and every button (and back-press) would be a no-op β€” an undismissable modal.
49+ var responded by rememberSaveable(permission.id) { mutableStateOf(false) }
50+ val respond: (PermissionResponse) -> Unit = { response ->
51+ if (!responded) {
52+ responded = true
53+ onRespond(response)
54+ }
55+ }
56+ AlertDialog(
57+ // Back press routes here (dismissOnBackPress defaults true) and is treated as an
58+ // explicit reject β€” the safe default. Tap-outside is disabled below so it can't
59+ // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
60+ // renders in its own window whose back dispatcher never reaches the host.
61+ onDismissRequest = { respond(PermissionResponse.REJECT) },
62+ properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
63+ icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
64+ title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
65+ text = {
66+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
67+ permission.type?.let {
68+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
69+ }
70+ permission.patternText?.let {
71+ Text(
72+ it,
73+ style = MaterialTheme.typography.bodyMedium,
74+ fontFamily = FontFamily.Monospace,
75+ color = MaterialTheme.colorScheme.onSurfaceVariant,
76+ )
77+ }
78+ Text(
79+ stringResource(R.string.permission_text),
80+ style = MaterialTheme.typography.bodySmall,
81+ color = MaterialTheme.colorScheme.onSurfaceVariant,
82+ )
83+ }
84+ },
85+ confirmButton = {
86+ Column(
87+ modifier = Modifier.fillMaxWidth(),
88+ verticalArrangement = Arrangement.spacedBy(4.dp),
89+ ) {
90+ Button(
91+ onClick = {
92+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
93+ respond(PermissionResponse.ONCE)
94+ },
95+ modifier = Modifier.fillMaxWidth(),
96+ ) { Text(stringResource(R.string.allow_once)) }
97+ OutlinedButton(
98+ onClick = {
99+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
100+ respond(PermissionResponse.ALWAYS)
101+ },
102+ modifier = Modifier.fillMaxWidth(),
103+ ) { Text(stringResource(R.string.always_allow)) }
104+ // Reject is an OutlinedButton (not a low-prominence TextButton) in the
105+ // same column so the three actions are visually balanced β€” the prior
106+ // layout buried Reject as a small dismiss-button, nudging users toward
107+ // granting. Reject uses the error color to signal its consequence.
108+ OutlinedButton(
109+ onClick = {
110+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
111+ respond(PermissionResponse.REJECT)
112+ },
113+ modifier = Modifier.fillMaxWidth(),
114+ ) { Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error) }
115+ }
116+ },
117+ )
118+}
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktnew file+286
@@ -0,0 +1,286 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.runtime.remember
6+import androidx.compose.ui.graphics.Color
7+import androidx.compose.ui.text.AnnotatedString
8+import androidx.compose.ui.text.SpanStyle
9+import androidx.compose.ui.text.font.FontFamily
10+import androidx.compose.ui.text.font.FontStyle
11+import androidx.compose.ui.text.font.FontWeight
12+import androidx.compose.ui.text.withStyle
13+
14+/**
15+ * Lightweight, dependency-free syntax highlighting for the file viewer.
16+ *
17+ * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
18+ * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
19+ * heuristic highlighter instead colors comments, strings, numbers, and a per-language
20+ * keyword set via simple character/word scanning β€” enough to make a code file scannable
21+ * in the viewer without pulling in a parser.
22+ *
23+ * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
24+ * comments and triple-quoted strings are not handled; that's an accepted trade-off for
25+ * a viewer (not an editor) where the goal is visual orientation, not correctness.
26+ */
27+
28+/** Language families recognized by [highlightLine]. The set of keywords is shared
29+ * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30+ * common keywords overlap heavily; python has its own set; shell has its own. */
31+internal enum class Language {
32+ C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
33+ PYTHON,
34+ SHELL, // sh, bash, zsh
35+ MARKUP, // html, xml, svg
36+ NONE,
37+}
38+
39+private fun languageFor(filename: String): Language {
40+ val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41+ return when (ext) {
42+ "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
43+ "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
44+ "cs", "scala", "groovy", "dart", "gradle",
45+ -> Language.C_FAMILY
46+ "py", "pyw", "rb" -> Language.PYTHON
47+ "sh", "bash", "zsh", "fish" -> Language.SHELL
48+ "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
49+ else -> Language.NONE
50+ }
51+}
52+
53+private val cFamilyKeywords = setOf(
54+ "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
55+ "const", "continue", "default", "defer", "do", "else", "enum", "export",
56+ "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
57+ "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
58+ "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
59+ "override", "package", "private", "protected", "public", "pub", "raise", "return",
60+ "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
61+ "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
62+ "where", "while", "with", "yield", "true", "false", "null", "nil",
63+ "and", "or", "not", "lambda", "elif", "pass", "print",
64+)
65+
66+private val pythonKeywords = setOf(
67+ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
68+ "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
69+ "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
70+ "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
71+)
72+
73+private val shellKeywords = setOf(
74+ "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
75+ "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
76+ "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
77+)
78+
79+/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80+ * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81+data class HighlightPalette(
82+ val keyword: Color,
83+ val comment: Color,
84+ val string: Color,
85+ val number: Color,
86+ val annotation: Color,
87+ val tag: Color,
88+ val base: Color,
89+)
90+
91+@Composable
92+fun rememberHighlightPalette(): HighlightPalette {
93+ // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
94+ // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
95+ // number = tertiary. Falls back gracefully when dynamic color is on (the roles
96+ // come from the wallpaper-derived scheme).
97+ val scheme = MaterialTheme.colorScheme
98+ return remember(scheme) {
99+ HighlightPalette(
100+ keyword = scheme.primary,
101+ comment = scheme.outline,
102+ string = scheme.secondary,
103+ number = scheme.tertiary,
104+ annotation = scheme.tertiary,
105+ tag = scheme.primary,
106+ base = scheme.onSurface,
107+ )
108+ }
109+}
110+
111+/**
112+ * Produce a highlighted [AnnotatedString] for a single line of source code.
113+ *
114+ * The scanner walks the line character by character, classifying runs of chars as
115+ * comments, strings, numbers, identifiers (checked against the keyword set), or plain
116+ * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
117+ * which matters because the file viewer renders lazily but can scroll through
118+ * thousands of lines β€” a heavy highlighter would jank on fast scrolls.
119+ */
120+fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
121+ highlightLine(line, syntaxFor(filename), palette)
122+
123+/**
124+ * A file's language classification, resolved once from its name. Resolving this per file
125+ * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
126+ * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
127+ */
128+class FileSyntax internal constructor(
129+ internal val lang: Language,
130+ internal val keywords: Set<String>,
131+)
132+
133+/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
134+fun syntaxFor(filename: String): FileSyntax {
135+ val lang = languageFor(filename)
136+ return FileSyntax(lang, keywordsFor(lang))
137+}
138+
139+/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140+fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141+ val lang = syntax.lang
142+ if (lang == Language.NONE) return AnnotatedString(line)
143+ val keywords = syntax.keywords
144+ val builder = AnnotatedString.Builder(line.length + 8)
145+ val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
146+ val mono = FontFamily.Monospace
147+ var i = 0
148+ val n = line.length
149+ while (i < n) {
150+ val c = line[i]
151+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
152+ if (emitted.consumed) {
153+ i = emitted.nextIndex
154+ } else {
155+ builder.withStyle(baseStyle) { append(c) }
156+ i++
157+ }
158+ }
159+ return builder.toAnnotatedString()
160+}
161+
162+/** Result of attempting to emit one token starting at [start]: the next scan index and
163+ * whether a token was consumed (vs. falling through to the default plain-text emit). */
164+private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
165+
166+private fun emitToken(
167+ builder: AnnotatedString.Builder,
168+ line: String,
169+ start: Int,
170+ lang: Language,
171+ keywords: Set<String>,
172+ palette: HighlightPalette,
173+ baseStyle: SpanStyle,
174+ mono: FontFamily,
175+): EmitResult {
176+ val c = line[start]
177+ if (isLineCommentStart(c, line, start, lang)) {
178+ builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
179+ append(line.substring(start))
180+ }
181+ return EmitResult(line.length, true)
182+ }
183+ if (c == '"' || c == '\'') {
184+ val end = findStringEnd(line, start, c)
185+ builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
186+ return EmitResult(end, true)
187+ }
188+ if (isNumberStart(c, line, start)) {
189+ val end = findNumberEnd(line, start)
190+ builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
191+ return EmitResult(end, true)
192+ }
193+ if (c.isLetter() || c == '_' || c == '@') {
194+ val end = findIdentifierEnd(line, start)
195+ val word = line.substring(start, end)
196+ when {
197+ c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
198+ word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
199+ else -> builder.withStyle(baseStyle) { append(word) }
200+ }
201+ return EmitResult(end, true)
202+ }
203+ if (lang == Language.MARKUP && isMarkupDelim(c)) {
204+ builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
205+ return EmitResult(start + 1, true)
206+ }
207+ return EmitResult(start + 1, false)
208+}
209+
210+private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211+ Language.C_FAMILY -> cFamilyKeywords
212+ Language.PYTHON -> pythonKeywords
213+ Language.SHELL -> shellKeywords
214+ Language.MARKUP -> emptySet()
215+ Language.NONE -> emptySet()
216+}
217+
218+private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
219+ // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
220+ // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
221+ (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
222+ // `#` is a line comment only in Python and shell. It is NOT one in C-family
223+ // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
224+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
225+
226+private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
227+
228+private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
229+ c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
230+
231+/** Scan forward to the end of a string literal starting at [start] (the opening quote
232+ * is line[start]). Returns the index just past the closing quote (or line.length if
233+ * the string is unterminated on this line). Honors backslash escapes. */
234+private fun findStringEnd(line: String, start: Int, quote: Char): Int {
235+ var i = start + 1
236+ val n = line.length
237+ while (i < n && line[i] != quote) {
238+ if (line[i] == '\\' && i + 1 < n) i += 2 else i++
239+ }
240+ return if (i < n) i + 1 else n
241+}
242+
243+/** Scan forward to the end of a number literal starting at [start].
244+ *
245+ * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
246+ * and `123abc` must split rather than merge into one token.
247+ * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
248+ * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
249+ * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
250+ * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
251+ */
252+private fun findNumberEnd(line: String, start: Int): Int {
253+ val n = line.length
254+ // Detect a hex prefix ("0x"/"0X") right at the number start.
255+ val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
256+ var i = if (hex) start + 2 else start + 1
257+ var seenDot = line[start] == '.'
258+ var seenExp = false
259+ while (i < n) {
260+ val c = line[i]
261+ val prev = line[i - 1]
262+ when {
263+ c.isDigit() || c == '_' -> i++
264+ hex && isHexDigit(c) -> i++
265+ c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
266+ !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
267+ !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
268+ else -> break
269+ }
270+ }
271+ return i
272+}
273+
274+private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
275+
276+/** Scan forward to the end of an identifier starting at [start]. */
277+private fun findIdentifierEnd(line: String, start: Int): Int {
278+ var i = start
279+ if (line[i] == '@') i++
280+ val n = line.length
281+ while (i < n && isIdentifierPartChar(line[i])) i++
282+ return i
283+}
284+
285+private fun isIdentifierPartChar(c: Char): Boolean =
286+ c.isLetterOrDigit() || c == '_' || c == '$'
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktnew file+121
@@ -0,0 +1,121 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.foundation.layout.Arrangement
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.material3.CircularProgressIndicator
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Surface
14+import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.mutableStateOf
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.contentDescription
23+import androidx.compose.ui.semantics.liveRegion
24+import androidx.compose.ui.semantics.LiveRegionMode
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.unit.dp
27+import soy.iko.opencode.R
28+import soy.iko.opencode.data.network.EventStreamClient
29+
30+/**
31+ * Banner shown when the SSE event stream is not connected. Shared by the chat and the
32+ * session list so a dropped stream is visible everywhere, not just mid-conversation.
33+ *
34+ * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
35+ * when [onRetry] is supplied, so the user can re-connect without hunting for the
36+ * chat screen's reconnect button or navigating to the server list. Transient
37+ * connecting/reconnecting states remain non-interactive (the system is already trying).
38+ */
39+@Composable
40+fun ConnectionBanner(
41+ state: EventStreamClient.ConnectionState,
42+ modifier: Modifier = Modifier,
43+ isOnline: Boolean = true,
44+ onRetry: (() -> Unit)? = null,
45+) {
46+ // When the device itself has no connectivity, surface that distinctly (instead of
47+ // the SSE state) β€” a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
48+ // credentials", neither of which reflects the real problem.
49+ val text = if (!isOnline) {
50+ stringResource(R.string.offline)
51+ } else {
52+ when (state) {
53+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
54+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
55+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
56+ EventStreamClient.ConnectionState.Connected -> null
57+ }
58+ }
59+ val isOffline = !isOnline
60+ // AnimatedVisibility so the banner slides/fades in and out instead of appearing
61+ // and disappearing instantly β€” a state change that's especially jarring when the
62+ // connection flaps, and was called out as a rough edge in the UX audit.
63+ // Retain the last non-null banner text so the exit fade-out has content to render.
64+ // The content lambda recomposes with the now-null `text` during the exit animation;
65+ // early-returning on null there would leave nothing to fade, so the banner would just
66+ // vanish instead of fading out.
67+ val lastText = remember { mutableStateOf(text) }
68+ if (text != null) lastText.value = text
69+ AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
70+ val shown = lastText.value ?: return@AnimatedVisibility
71+ // Distinguish a hard failure (e.g. bad credentials) and an offline device from
72+ // transient connecting/reconnecting states by switching to the error palette,
73+ // so the banner conveys urgency without relying on text alone.
74+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
75+ val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
76+ else MaterialTheme.colorScheme.tertiaryContainer
77+ val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
78+ else MaterialTheme.colorScheme.onTertiaryContainer
79+ Surface(
80+ modifier = modifier
81+ .fillMaxWidth()
82+ .semantics {
83+ // Announce connection state changes to TalkBack users so they're
84+ // aware the stream dropped/reconnecting without visual cues.
85+ liveRegion = LiveRegionMode.Polite
86+ contentDescription = shown
87+ },
88+ color = container,
89+ ) {
90+ Row(
91+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
92+ horizontalArrangement = Arrangement.Center,
93+ verticalAlignment = Alignment.CenterVertically,
94+ ) {
95+ if (!isFailed) {
96+ CircularProgressIndicator(
97+ Modifier.size(16.dp),
98+ strokeWidth = 2.dp,
99+ )
100+ }
101+ Text(
102+ shown,
103+ style = MaterialTheme.typography.labelMedium,
104+ color = onContainer,
105+ modifier = Modifier
106+ .padding(start = 6.dp)
107+ .weight(1f, fill = false),
108+ )
109+ // Inline retry on the Failed banner so the user can recover without
110+ // leaving the screen. Hidden during transient states (the system is
111+ // already reconnecting), when offline (retry can't help), and when no
112+ // callback is wired (callers that don't have a reconnect path).
113+ if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
114+ TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
115+ Text(stringResource(R.string.retry_now), color = onContainer)
116+ }
117+ }
118+ }
119+ }
120+ }
121+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+222
@@ -0,0 +1,222 @@
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.height
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.width
13+import androidx.compose.foundation.rememberScrollState
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.material3.TextButton
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.rememberSaveable
26+import androidx.compose.runtime.setValue
27+import androidx.compose.ui.Alignment
28+import androidx.compose.ui.Modifier
29+import androidx.compose.ui.draw.clip
30+import androidx.compose.ui.graphics.Color
31+import androidx.compose.ui.platform.LocalContext
32+import androidx.compose.ui.text.font.FontFamily
33+import androidx.compose.ui.unit.dp
34+import androidx.compose.ui.res.stringResource
35+import soy.iko.opencode.R
36+
37+/** A single line of a parsed unified diff. */
38+sealed interface DiffLine {
39+ val text: String
40+
41+ data class Hunk(override val text: String) : DiffLine // @@ ... @@
42+ data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
43+ data class Context(override val text: String) : DiffLine
44+ data class Add(override val text: String) : DiffLine
45+ data class Remove(override val text: String) : DiffLine
46+ data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
47+}
48+
49+/** Git metadata line prefixes that aren't part of the diff content. */
50+private val gitMetaPrefixes = listOf(
51+ "new file mode ", "deleted file mode ", "old mode ", "new mode ",
52+ "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
53+ "copy from ", "copy to ", "Binary files ", "\\ No newline",
54+)
55+
56+/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
57+ * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
58+private fun isDiffFileHeader(raw: String): Boolean {
59+ val rest = when {
60+ raw.startsWith("--- ") -> raw.substring(4)
61+ raw.startsWith("+++ ") -> raw.substring(4)
62+ else -> return false
63+ }
64+ // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
65+ // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
66+ // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
67+ // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
68+ return rest.startsWith("a/") || rest.startsWith("b/") ||
69+ rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
70+}
71+
72+/** Parse a unified diff string into typed [DiffLine]s. */
73+fun parseDiff(diff: String): List<DiffLine> {
74+ val result = mutableListOf<DiffLine>()
75+ for (raw in diff.lineSequence()) {
76+ when {
77+ raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
78+ // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
79+ // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
80+ // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
81+ // lines like "-- TODO"/"++ x" β€” which appear on the wire as "--- TODO"/"+++ x" β€”
82+ // as headers; those fall through to the +/- branches below.
83+ isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
84+ raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
85+ raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
86+ raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
87+ raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
88+ gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
89+ raw.isNotBlank() -> result.add(DiffLine.Context(raw))
90+ }
91+ }
92+ return result
93+}
94+
95+/** Heuristic: does this string look like a unified diff? */
96+fun looksLikeDiff(text: String): Boolean {
97+ val lines = text.lineSequence()
98+ var hunkCount = 0
99+ var addCount = 0
100+ var removeCount = 0
101+ for (line in lines) {
102+ when {
103+ line.startsWith("@@") -> hunkCount++
104+ line.startsWith("+") && !line.startsWith("+++") -> addCount++
105+ line.startsWith("-") && !line.startsWith("---") -> removeCount++
106+ }
107+ if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
108+ }
109+ return false
110+}
111+
112+private const val COLLAPSED_DIFF_LINES = 200
113+
114+/**
115+ * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
116+ * safe to embed inside another vertically scrolling container (e.g. the chat message
117+ * list) β€” a nested `LazyColumn` would crash with an unbounded-height constraint.
118+ * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
119+ * [Modifier.verticalScroll] in [modifier].
120+ *
121+ * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
122+ * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
123+ * expands the view.
124+ */
125+@Composable
126+fun DiffView(diff: String, modifier: Modifier = Modifier) {
127+ val lines = remember(diff) { parseDiff(diff) }
128+ // Derive the four row colors once per color scheme instead of allocating fresh Color
129+ // objects (the .copy() calls) on every recomposition β€” a diff re-parses/recomposes
130+ // per token while its output streams under the collapse limit.
131+ val scheme = MaterialTheme.colorScheme
132+ val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
133+ val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
134+ val addText = scheme.primary
135+ val removeText = scheme.error
136+ val context = LocalContext.current
137+ val hScrollState = rememberScrollState()
138+ var expanded by rememberSaveable(diff) { mutableStateOf(false) }
139+
140+ Column(
141+ modifier = modifier
142+ .fillMaxWidth()
143+ .clip(MaterialTheme.shapes.small)
144+ .background(MaterialTheme.colorScheme.surfaceVariant),
145+ ) {
146+ Row(
147+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
148+ horizontalArrangement = Arrangement.End,
149+ ) {
150+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
151+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
152+ }
153+ }
154+ val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
155+ else lines.subList(0, COLLAPSED_DIFF_LINES)
156+ // One horizontalScroll on the container instead of one per row: each modifier
157+ // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
158+ // paying for 200 of them. The shared scroll state still synchronizes all rows.
159+ Column(modifier = Modifier.horizontalScroll(hScrollState)) {
160+ visibleLines.forEach { line ->
161+ Row(modifier = Modifier.padding(horizontal = 10.dp)) {
162+ when (line) {
163+ is DiffLine.Hunk -> Text(
164+ line.text,
165+ style = MaterialTheme.typography.bodySmall,
166+ fontFamily = FontFamily.Monospace,
167+ color = MaterialTheme.colorScheme.tertiary,
168+ modifier = Modifier.padding(vertical = 2.dp),
169+ )
170+ is DiffLine.FileHeader -> Text(
171+ line.text,
172+ style = MaterialTheme.typography.bodySmall,
173+ fontFamily = FontFamily.Monospace,
174+ color = MaterialTheme.colorScheme.onSurfaceVariant,
175+ modifier = Modifier.padding(vertical = 1.dp),
176+ )
177+ is DiffLine.Meta -> Text(
178+ line.text,
179+ style = MaterialTheme.typography.bodySmall,
180+ fontFamily = FontFamily.Monospace,
181+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
182+ modifier = Modifier.padding(vertical = 1.dp),
183+ )
184+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
185+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
186+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
187+ }
188+ }
189+ }
190+ }
191+ if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
192+ TextButton(
193+ onClick = { expanded = true },
194+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
195+ ) {
196+ Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
197+ }
198+ }
199+ Spacer(Modifier.height(10.dp))
200+ }
201+}
202+
203+@Composable
204+private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
205+ Row(
206+ modifier = Modifier.background(bg),
207+ ) {
208+ Text(
209+ prefix,
210+ style = MaterialTheme.typography.bodySmall,
211+ fontFamily = FontFamily.Monospace,
212+ color = textColor,
213+ modifier = Modifier.width(16.dp),
214+ )
215+ Text(
216+ text,
217+ style = MaterialTheme.typography.bodySmall,
218+ fontFamily = FontFamily.Monospace,
219+ color = textColor,
220+ )
221+ }
222+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+227
@@ -0,0 +1,227 @@
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.background
8+import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.layout.Box
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.rememberScrollState
14+import androidx.compose.foundation.text.selection.SelectionContainer
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.ContentCopy
17+import androidx.compose.material3.Icon
18+import androidx.compose.material3.IconButton
19+import androidx.compose.material3.MaterialTheme
20+import androidx.compose.material3.Surface
21+import androidx.compose.material3.Text
22+import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.LaunchedEffect
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.rememberUpdatedState
28+import androidx.compose.runtime.setValue
29+import androidx.compose.runtime.snapshotFlow
30+import androidx.compose.ui.Alignment
31+import androidx.compose.ui.Modifier
32+import androidx.compose.ui.draw.clip
33+import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.text.TextStyle
35+import androidx.compose.ui.text.font.FontFamily
36+import androidx.compose.ui.unit.dp
37+import kotlinx.coroutines.flow.conflate
38+import kotlinx.coroutines.delay
39+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
40+import com.mikepenz.markdown.compose.components.markdownComponents
41+import com.mikepenz.markdown.m3.Markdown
42+import org.intellij.markdown.MarkdownElementTypes
43+import org.intellij.markdown.ast.ASTNode
44+import org.intellij.markdown.ast.getTextInNode
45+import soy.iko.opencode.R
46+import soy.iko.opencode.data.network.NetworkConfig
47+
48+/**
49+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
50+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
51+ * Code blocks/fences get an inline copy button.
52+ *
53+ * The rendered text is wrapped in a [SelectionContainer] so the user can select and
54+ * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
55+ * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
56+ * copies all TextParts; this complements it with partial selection.
57+ *
58+ * During streaming, the full markdown is re-parsed on every token (the library re-parses
59+ * whenever the content string changes). To avoid O(nΒ²) work during long responses, the
60+ * rendered content is throttled β€” the latest [markdown] is committed to the renderer at
61+ * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
62+ *
63+ * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
64+ * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
65+ * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
66+ * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
67+ * starving the render and showing stale content until the stream pauses. The conflated
68+ * `collect` lets the delay run to completion, then picks up the most recent buffered value,
69+ * so rendering always progresses even under continuous fast streaming.
70+ *
71+ * When [streaming] is false (the common case: every non-active message in the chat list),
72+ * the throttle pipeline is skipped entirely β€” no `LaunchedEffect`, no `snapshotFlow`, no
73+ * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
74+ */
75+@Composable
76+fun MarkdownText(
77+ markdown: String,
78+ modifier: Modifier = Modifier,
79+ style: TextStyle = MaterialTheme.typography.bodyLarge,
80+ streaming: Boolean = false,
81+) {
82+ val context = LocalContext.current
83+ val components = remember {
84+ markdownComponents(
85+ codeFence = { CodeWithCopy(it) },
86+ codeBlock = { CodeWithCopy(it) },
87+ )
88+ }
89+ if (streaming) {
90+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
91+ val markdownState = rememberUpdatedState(markdown)
92+ // Keyed to a content-prefix so switching to a different message resets
93+ // immediately instead of showing the old message for one frame.
94+ val prefix = markdown.take(32)
95+ var renderedContent by remember(prefix) { mutableStateOf(markdown) }
96+ // conflate() coalesces a burst of token updates into a single emission so the
97+ // collector sees only the latest value after the previous delay completes. Plain
98+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
99+ // on every new value, reintroducing the same starvation the keyed effect had.
100+ LaunchedEffect(prefix) {
101+ snapshotFlow { markdownState.value }
102+ .conflate()
103+ .collect { md ->
104+ // Only throttle when the content is growing incrementally (streaming):
105+ // a content switch or initial render proceeds immediately. Checking
106+ // length is O(1) vs. startsWith which is O(n) in the rendered content
107+ // length β€” during a long streaming response this runs every throttle
108+ // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
109+ // content switches; within one keyed cycle, growing length means
110+ // appending (streaming), not a rewrite.
111+ if (renderedContent.isNotEmpty() &&
112+ md.length > renderedContent.length
113+ ) {
114+ delay(NetworkConfig.streamingThrottleMs)
115+ }
116+ renderedContent = md
117+ }
118+ }
119+ // SelectionContainer is intentionally omitted during streaming: the content is
120+ // changing every ~50ms, and an active selection would be invalidated (and the
121+ // selection handles would flicker) on each throttle commit. Partial selection
122+ // becomes available once the stream finishes; the per-message Copy button in
123+ // MessageBubble copies all TextParts at any time.
124+ Markdown(
125+ content = renderedContent,
126+ modifier = modifier,
127+ components = components,
128+ )
129+ } else {
130+ SelectionContainer {
131+ Markdown(
132+ content = markdown,
133+ modifier = modifier,
134+ components = components,
135+ )
136+ }
137+ }
138+}
139+
140+/**
141+ * Renders a fenced or indented code block with a small copy button in the top-right corner.
142+ * Self-contained so it doesn't depend on the library's internal code-block composables.
143+ */
144+@Composable
145+private fun CodeWithCopy(model: MarkdownComponentModel) {
146+ val context = LocalContext.current
147+ // Key on the content string + the AST node's offset range (stable ints) instead of
148+ // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
149+ // ASTNode), so remembering on it re-executes extractCode on every recomposition.
150+ val node = model.node
151+ val code = remember(model.content, node.startOffset, node.endOffset) {
152+ extractCode(model.content, node)
153+ }
154+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
155+ Box(
156+ modifier = Modifier
157+ .fillMaxWidth()
158+ .clip(MaterialTheme.shapes.small)
159+ .background(MaterialTheme.colorScheme.surfaceVariant),
160+ ) {
161+ Text(
162+ text = code,
163+ style = codeStyle,
164+ modifier = Modifier
165+ .horizontalScroll(rememberScrollState())
166+ .fillMaxWidth()
167+ .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
168+ )
169+ Surface(
170+ color = MaterialTheme.colorScheme.surface,
171+ shape = MaterialTheme.shapes.extraSmall,
172+ modifier = Modifier
173+ .align(Alignment.TopEnd)
174+ .padding(4.dp),
175+ ) {
176+ IconButton(
177+ onClick = { copyToClipboard(context, "code", code) },
178+ modifier = Modifier.size(48.dp),
179+ ) {
180+ Icon(
181+ Icons.Filled.ContentCopy,
182+ contentDescription = context.getString(R.string.copy),
183+ modifier = Modifier.size(14.dp),
184+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
185+ )
186+ }
187+ }
188+ }
189+}
190+
191+/**
192+ * Extract the raw code text from a markdown code node, stripping fence markers
193+ * (```/~~~ and the language tag) so only the code content is copied.
194+ */
195+private fun extractCode(content: String, node: ASTNode): String =
196+ extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
197+
198+/**
199+ * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
200+ * only the code content is returned. Extracted as a pure function for testability.
201+ *
202+ * @param raw the raw text of the code node (including fence markers for fenced blocks)
203+ * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
204+ */
205+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
206+ if (!isFenced) return raw.trimIndent()
207+ val lines = raw.lines()
208+ val body = lines.drop(1).toMutableList()
209+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
210+ body.removeAt(body.lastIndex)
211+ }
212+ return body.joinToString("\n").trimEnd()
213+}
214+
215+/** Copy [text] to the system clipboard and show a confirmation toast. */
216+internal fun copyToClipboard(context: Context, label: String, text: String = label) {
217+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
218+ clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
219+ showToast(context, context.getString(R.string.copied))
220+}
221+
222+private var lastToast: Toast? = null
223+
224+internal fun showToast(context: Context, message: String) {
225+ lastToast?.cancel()
226+ lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }
227+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktdeletedβˆ’479
@@ -1,479 +0,0 @@
1βˆ’package soy.iko.opencode.ui.components
2βˆ’
3βˆ’import android.content.ClipData
4βˆ’import android.content.ClipboardManager
5βˆ’import android.content.Context
6βˆ’import android.content.Intent
7βˆ’import android.net.Uri
8βˆ’import android.widget.Toast
9βˆ’import androidx.compose.foundation.ExperimentalFoundationApi
10βˆ’import androidx.compose.foundation.background
11βˆ’import androidx.compose.foundation.clickable
12βˆ’import androidx.compose.foundation.combinedClickable
13βˆ’import androidx.compose.foundation.layout.Arrangement
14βˆ’import androidx.compose.foundation.layout.Column
15βˆ’import androidx.compose.foundation.layout.Row
16βˆ’import androidx.compose.foundation.layout.fillMaxWidth
17βˆ’import androidx.compose.foundation.layout.padding
18βˆ’import androidx.compose.foundation.layout.size
19βˆ’import androidx.compose.foundation.layout.widthIn
20βˆ’import androidx.compose.foundation.shape.RoundedCornerShape
21βˆ’import androidx.compose.foundation.text.selection.SelectionContainer
22βˆ’import androidx.compose.material.icons.Icons
23βˆ’import androidx.compose.material.icons.filled.Build
24βˆ’import androidx.compose.material.icons.filled.Check
25βˆ’import androidx.compose.material.icons.filled.ErrorOutline
26βˆ’import androidx.compose.material.icons.filled.ExpandLess
27βˆ’import androidx.compose.material.icons.filled.ExpandMore
28βˆ’import androidx.compose.material.icons.filled.Schedule
29βˆ’import androidx.compose.material.icons.filled.SmartToy
30βˆ’import androidx.compose.material3.DropdownMenu
31βˆ’import androidx.compose.material3.DropdownMenuItem
32βˆ’import androidx.compose.material3.Icon
33βˆ’import androidx.compose.material3.MaterialTheme
34βˆ’import androidx.compose.material3.Text
35βˆ’import androidx.compose.runtime.Composable
36βˆ’import androidx.compose.runtime.getValue
37βˆ’import androidx.compose.runtime.mutableStateOf
38βˆ’import androidx.compose.runtime.remember
39βˆ’import androidx.compose.runtime.setValue
40βˆ’import androidx.compose.ui.Alignment
41βˆ’import androidx.compose.ui.Modifier
42βˆ’import androidx.compose.ui.draw.clip
43βˆ’import androidx.compose.ui.graphics.Color
44βˆ’import androidx.compose.ui.platform.LocalContext
45βˆ’import androidx.compose.ui.text.AnnotatedString
46βˆ’import androidx.compose.ui.text.LinkAnnotation
47βˆ’import androidx.compose.ui.text.SpanStyle
48βˆ’import androidx.compose.ui.text.TextLinkStyles
49βˆ’import androidx.compose.ui.text.buildAnnotatedString
50βˆ’import androidx.compose.ui.text.font.FontFamily
51βˆ’import androidx.compose.ui.text.font.FontStyle
52βˆ’import androidx.compose.ui.text.font.FontWeight
53βˆ’import androidx.compose.ui.text.withLink
54βˆ’import androidx.compose.ui.text.withStyle
55βˆ’import androidx.compose.ui.text.style.TextDecoration
56βˆ’import androidx.compose.ui.text.style.TextOverflow
57βˆ’import androidx.compose.ui.unit.dp
58βˆ’import androidx.compose.ui.unit.sp
59βˆ’import soy.iko.opencode.core.MessageStatus
60βˆ’import soy.iko.opencode.core.MessageView
61βˆ’import soy.iko.opencode.core.ToolView
62βˆ’import soy.iko.opencode.ui.theme.MonoStyle
63βˆ’
64βˆ’private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65βˆ’
66βˆ’@OptIn(ExperimentalFoundationApi::class)
67βˆ’@Composable
68βˆ’fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69βˆ’ val isUser = message.isUser
70βˆ’ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71βˆ’ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72βˆ’ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73βˆ’ val shape = RoundedCornerShape(
74βˆ’ topStart = 16.dp,
75βˆ’ topEnd = 16.dp,
76βˆ’ bottomStart = if (isUser) 16.dp else 4.dp,
77βˆ’ bottomEnd = if (isUser) 4.dp else 16.dp,
78βˆ’ )
79βˆ’
80βˆ’ val context = LocalContext.current
81βˆ’ var showMenu by remember { mutableStateOf(false) }
82βˆ’
83βˆ’ Row(
84βˆ’ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85βˆ’ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86βˆ’ ) {
87βˆ’ if (!isUser) {
88βˆ’ Icon(
89βˆ’ Icons.Filled.SmartToy,
90βˆ’ contentDescription = null,
91βˆ’ tint = MaterialTheme.colorScheme.primary,
92βˆ’ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93βˆ’ )
94βˆ’ }
95βˆ’ Column(
96βˆ’ modifier = Modifier
97βˆ’ .fillMaxWidth(0.85f)
98βˆ’ .widthIn(max = 320.dp)
99βˆ’ .clip(shape)
100βˆ’ .background(bubbleColor)
101βˆ’ .combinedClickable(
102βˆ’ onClick = {},
103βˆ’ onLongClick = {
104βˆ’ if (message.text.isNotEmpty()) {
105βˆ’ showMenu = true
106βˆ’ }
107βˆ’ },
108βˆ’ )
109βˆ’ .padding(horizontal = 14.dp, vertical = 10.dp),
110βˆ’ ) {
111βˆ’ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112βˆ’ DropdownMenuItem(
113βˆ’ text = { Text("Copy") },
114βˆ’ onClick = {
115βˆ’ copyToClipboard(context, message.text)
116βˆ’ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117βˆ’ showMenu = false
118βˆ’ },
119βˆ’ )
120βˆ’ DropdownMenuItem(
121βˆ’ text = { Text("Share") },
122βˆ’ onClick = {
123βˆ’ shareText(context, message.text)
124βˆ’ showMenu = false
125βˆ’ },
126βˆ’ )
127βˆ’ }
128βˆ’
129βˆ’ message.reasoning?.let { ReasoningBlock(it, context) }
130βˆ’
131βˆ’ // Give a quick overview of how many tools ran before listing them.
132βˆ’ if (message.tools.size > 1) {
133βˆ’ Text(
134βˆ’ text = "${message.tools.size} tool calls",
135βˆ’ style = MaterialTheme.typography.labelSmall,
136βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
137βˆ’ modifier = Modifier.padding(bottom = 4.dp),
138βˆ’ )
139βˆ’ }
140βˆ’
141βˆ’ message.tools.forEach { ToolRow(it) }
142βˆ’
143βˆ’ SelectionContainer {
144βˆ’ if (message.text.isNotEmpty()) {
145βˆ’ val linkColor = MaterialTheme.colorScheme.primary
146βˆ’ // Tint the code background off the bubble's own foreground colour so it
147βˆ’ // contrasts on both bubble types (user bubbles use primaryContainer).
148βˆ’ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149βˆ’ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150βˆ’ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151βˆ’ }
152βˆ’ Text(
153βˆ’ text = annotatedText,
154βˆ’ color = textColor,
155βˆ’ // Give assistant replies (often long, code-heavy) extra line
156βˆ’ // spacing for readability; user bubbles keep the default.
157βˆ’ style = if (isUser) {
158βˆ’ MaterialTheme.typography.bodyLarge
159βˆ’ } else {
160βˆ’ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161βˆ’ },
162βˆ’ )
163βˆ’ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164βˆ’ Text("β–Œ", color = textColor, style = MaterialTheme.typography.bodyLarge)
165βˆ’ }
166βˆ’ }
167βˆ’
168βˆ’ if (message.time > 0) {
169βˆ’ Text(
170βˆ’ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171βˆ’ style = MaterialTheme.typography.labelSmall,
172βˆ’ color = mutedColor,
173βˆ’ modifier = Modifier.padding(top = 4.dp),
174βˆ’ )
175βˆ’ }
176βˆ’
177βˆ’ if (isUser && message.status != MessageStatus.Sent) {
178βˆ’ StatusLine(message.status, onRetry = onRetry)
179βˆ’ }
180βˆ’ }
181βˆ’ }
182βˆ’}
183βˆ’
184βˆ’private fun copyToClipboard(context: Context, text: String) {
185βˆ’ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186βˆ’ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187βˆ’}
188βˆ’
189βˆ’private val urlRegex = Regex("""https?://[^\s]+""")
190βˆ’
191βˆ’/**
192βˆ’ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193βˆ’ * prose and code segments: code segments are rendered monospace on a tinted
194βˆ’ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195βˆ’ * clickable link. The streaming cursor (" β–Œ") is appended outside any annotation.
196βˆ’ */
197βˆ’private fun buildLinkedText(
198βˆ’ text: String,
199βˆ’ streaming: Boolean,
200βˆ’ linkColor: Color,
201βˆ’ codeBackground: Color,
202βˆ’ context: Context,
203βˆ’): AnnotatedString = buildAnnotatedString {
204βˆ’ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205βˆ’ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206βˆ’ val segments = text.split("```")
207βˆ’ segments.forEachIndexed { index, segment ->
208βˆ’ if (index % 2 == 1) {
209βˆ’ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210βˆ’ append(segment)
211βˆ’ }
212βˆ’ } else {
213βˆ’ appendLinkedText(segment, linkColor, codeBackground, context)
214βˆ’ }
215βˆ’ }
216βˆ’ if (streaming) append(" β–Œ")
217βˆ’}
218βˆ’
219βˆ’/**
220βˆ’ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221βˆ’ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222βˆ’ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223βˆ’ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224βˆ’ * inline spans reach here.
225βˆ’ */
226βˆ’private fun AnnotatedString.Builder.appendLinkedText(
227βˆ’ text: String,
228βˆ’ linkColor: Color,
229βˆ’ codeBackground: Color,
230βˆ’ context: Context,
231βˆ’) {
232βˆ’ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233βˆ’ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234βˆ’ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235βˆ’ // Only the non-bold (even) segments get single-* italic handling β€” splitting a bold
236βˆ’ // segment on "*" would misfire on the leftover asterisks from the ** markers.
237βˆ’ val segments = text.split("**")
238βˆ’ // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239βˆ’ // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240βˆ’ val unclosed = segments.size % 2 == 0
241βˆ’ segments.forEachIndexed { index, segment ->
242βˆ’ val isUnclosedTail = unclosed && index == segments.size - 1
243βˆ’ if (index % 2 == 1 && !isUnclosedTail) {
244βˆ’ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245βˆ’ appendInlineCode(segment, linkColor, codeBackground, context)
246βˆ’ }
247βˆ’ } else {
248βˆ’ if (isUnclosedTail) append("**")
249βˆ’ appendItalicText(segment, linkColor, codeBackground, context)
250βˆ’ }
251βˆ’ }
252βˆ’}
253βˆ’
254βˆ’/**
255βˆ’ * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256βˆ’ * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257βˆ’ * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258βˆ’ */
259βˆ’private fun AnnotatedString.Builder.appendItalicText(
260βˆ’ text: String,
261βˆ’ linkColor: Color,
262βˆ’ codeBackground: Color,
263βˆ’ context: Context,
264βˆ’) {
265βˆ’ // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266βˆ’ // (markers dropped); even segments are ordinary prose.
267βˆ’ val segments = text.split("*")
268βˆ’ // An even segment count means an odd number of * markers, so the final * is unclosed:
269βˆ’ // its trailing segment isn't italic. Restore the literal * and render it as prose.
270βˆ’ val unclosed = segments.size % 2 == 0
271βˆ’ segments.forEachIndexed { index, segment ->
272βˆ’ val isUnclosedTail = unclosed && index == segments.size - 1
273βˆ’ if (index % 2 == 1 && !isUnclosedTail) {
274βˆ’ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275βˆ’ appendInlineCode(segment, linkColor, codeBackground, context)
276βˆ’ }
277βˆ’ } else {
278βˆ’ if (isUnclosedTail) append("*")
279βˆ’ appendInlineCode(segment, linkColor, codeBackground, context)
280βˆ’ }
281βˆ’ }
282βˆ’}
283βˆ’
284βˆ’/**
285βˆ’ * Append [text] to the builder, rendering single-backtick pairs as inline code
286βˆ’ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287βˆ’ * into a clickable link.
288βˆ’ */
289βˆ’private fun AnnotatedString.Builder.appendInlineCode(
290βˆ’ text: String,
291βˆ’ linkColor: Color,
292βˆ’ codeBackground: Color,
293βˆ’ context: Context,
294βˆ’) {
295βˆ’ // Odd-indexed segments sit between a pair of single backticks, so they're inline
296βˆ’ // code. Even segments are ordinary prose and get URL linking applied.
297βˆ’ val segments = text.split("`")
298βˆ’ // An even segment count means an odd number of backtick markers, so the final backtick
299βˆ’ // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300βˆ’ val unclosed = segments.size % 2 == 0
301βˆ’ segments.forEachIndexed { index, segment ->
302βˆ’ val isUnclosedTail = unclosed && index == segments.size - 1
303βˆ’ if (index % 2 == 1 && !isUnclosedTail) {
304βˆ’ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305βˆ’ append(segment)
306βˆ’ }
307βˆ’ } else {
308βˆ’ if (isUnclosedTail) append("`")
309βˆ’ appendUrls(segment, linkColor, context)
310βˆ’ }
311βˆ’ }
312βˆ’}
313βˆ’
314βˆ’/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315βˆ’private fun AnnotatedString.Builder.appendUrls(
316βˆ’ text: String,
317βˆ’ linkColor: Color,
318βˆ’ context: Context,
319βˆ’) {
320βˆ’ val matches = urlRegex.findAll(text).toList()
321βˆ’ var cursor = 0
322βˆ’ for (match in matches) {
323βˆ’ append(text.substring(cursor, match.range.first))
324βˆ’ val url = match.value
325βˆ’ withLink(
326βˆ’ LinkAnnotation.Clickable(
327βˆ’ tag = url,
328βˆ’ styles = TextLinkStyles(
329βˆ’ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330βˆ’ ),
331βˆ’ linkInteractionListener = {
332βˆ’ runCatching {
333βˆ’ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334βˆ’ }
335βˆ’ },
336βˆ’ ),
337βˆ’ ) {
338βˆ’ append(url)
339βˆ’ }
340βˆ’ cursor = match.range.last + 1
341βˆ’ }
342βˆ’ append(text.substring(cursor))
343βˆ’}
344βˆ’
345βˆ’private fun shareText(context: Context, text: String) {
346βˆ’ val intent = Intent(Intent.ACTION_SEND).apply {
347βˆ’ type = "text/plain"
348βˆ’ putExtra(Intent.EXTRA_TEXT, text)
349βˆ’ }
350βˆ’ context.startActivity(Intent.createChooser(intent, null))
351βˆ’}
352βˆ’
353βˆ’@Composable
354βˆ’private fun ReasoningBlock(text: String, context: Context) {
355βˆ’ var expanded by remember { mutableStateOf(false) }
356βˆ’ Column(modifier = Modifier.padding(bottom = 6.dp)) {
357βˆ’ Row(
358βˆ’ verticalAlignment = Alignment.CenterVertically,
359βˆ’ modifier = Modifier.clickable { expanded = !expanded },
360βˆ’ ) {
361βˆ’ Text(
362βˆ’ text = if (expanded) "Show less" else "Reasoning",
363βˆ’ style = MaterialTheme.typography.bodySmall,
364βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
365βˆ’ )
366βˆ’ Icon(
367βˆ’ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368βˆ’ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369βˆ’ tint = MaterialTheme.colorScheme.onSurfaceVariant,
370βˆ’ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371βˆ’ )
372βˆ’ }
373βˆ’ if (expanded) {
374βˆ’ Text(
375βˆ’ text = text,
376βˆ’ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
378βˆ’ modifier = Modifier.padding(top = 4.dp),
379βˆ’ )
380βˆ’ Text(
381βˆ’ text = "Copy",
382βˆ’ style = MaterialTheme.typography.labelSmall,
383βˆ’ color = MaterialTheme.colorScheme.primary,
384βˆ’ modifier = Modifier
385βˆ’ .padding(top = 4.dp)
386βˆ’ .clickable {
387βˆ’ copyToClipboard(context, text)
388βˆ’ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389βˆ’ },
390βˆ’ )
391βˆ’ }
392βˆ’ }
393βˆ’}
394βˆ’
395βˆ’@Composable
396βˆ’private fun ToolRow(tool: ToolView) {
397βˆ’ var expanded by remember { mutableStateOf(false) }
398βˆ’ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399βˆ’ val statusColor = when (tool.status) {
400βˆ’ "completed", "success" -> MaterialTheme.colorScheme.primary
401βˆ’ "error", "failed" -> MaterialTheme.colorScheme.error
402βˆ’ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403βˆ’ else -> MaterialTheme.colorScheme.onSurfaceVariant
404βˆ’ }
405βˆ’ Row(
406βˆ’ verticalAlignment = Alignment.CenterVertically,
407βˆ’ modifier = Modifier
408βˆ’ .fillMaxWidth()
409βˆ’ .clickable { expanded = !expanded }
410βˆ’ .padding(bottom = 6.dp),
411βˆ’ ) {
412βˆ’ Icon(
413βˆ’ Icons.Filled.Build,
414βˆ’ contentDescription = null,
415βˆ’ tint = MaterialTheme.colorScheme.primary,
416βˆ’ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417βˆ’ )
418βˆ’ Text(
419βˆ’ text = buildAnnotatedString {
420βˆ’ withStyle(SpanStyle(color = baseColor)) {
421βˆ’ append(tool.name)
422βˆ’ append(" Β· ")
423βˆ’ }
424βˆ’ withStyle(SpanStyle(color = statusColor)) {
425βˆ’ append(tool.status)
426βˆ’ }
427βˆ’ if (expanded) tool.title?.let {
428βˆ’ withStyle(SpanStyle(color = baseColor)) {
429βˆ’ append(" β€” ")
430βˆ’ append(it)
431βˆ’ }
432βˆ’ }
433βˆ’ },
434βˆ’ style = MonoStyle,
435βˆ’ color = baseColor,
436βˆ’ maxLines = if (expanded) Int.MAX_VALUE else 1,
437βˆ’ overflow = TextOverflow.Ellipsis,
438βˆ’ modifier = Modifier.weight(1f),
439βˆ’ )
440βˆ’ Icon(
441βˆ’ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442βˆ’ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443βˆ’ tint = MaterialTheme.colorScheme.onSurfaceVariant,
444βˆ’ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445βˆ’ )
446βˆ’ }
447βˆ’}
448βˆ’
449βˆ’@Composable
450βˆ’private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451βˆ’ val (icon, label, tint) = when (status) {
452βˆ’ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453βˆ’ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454βˆ’ MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455βˆ’ }
456βˆ’ Row(
457βˆ’ verticalAlignment = Alignment.CenterVertically,
458βˆ’ horizontalArrangement = Arrangement.End,
459βˆ’ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460βˆ’ ) {
461βˆ’ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462βˆ’ Text(
463βˆ’ text = label,
464βˆ’ style = MaterialTheme.typography.labelSmall,
465βˆ’ color = tint,
466βˆ’ modifier = Modifier.padding(start = 4.dp),
467βˆ’ )
468βˆ’ if (status == MessageStatus.Failed) {
469βˆ’ Text(
470βˆ’ text = "Retry",
471βˆ’ style = MaterialTheme.typography.labelSmall,
472βˆ’ color = MaterialTheme.colorScheme.primary,
473βˆ’ modifier = Modifier
474βˆ’ .padding(start = 12.dp)
475βˆ’ .clickable { onRetry() },
476βˆ’ )
477βˆ’ }
478βˆ’ }
479βˆ’}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktnew file+140
@@ -0,0 +1,140 @@
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.material3.MaterialTheme
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.Immutable
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.semantics.contentDescription
18+import androidx.compose.ui.semantics.semantics
19+import androidx.compose.ui.unit.dp
20+import coil.compose.SubcomposeAsyncImage
21+import coil.request.ImageRequest
22+import androidx.compose.material3.CircularProgressIndicator
23+import androidx.compose.material3.Icon
24+import androidx.compose.material.icons.Icons
25+import androidx.compose.material.icons.filled.BrokenImage
26+import soy.iko.opencode.R
27+import soy.iko.opencode.data.model.FilePart
28+import soy.iko.opencode.data.model.ServerProfile
29+import soy.iko.opencode.data.network.HttpClientFactory
30+
31+/**
32+ * Carries the bits needed to load an image off the opencode server: the base URL (for
33+ * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
34+ */
35+@Immutable
36+data class ImageLoadContext(
37+ val baseUrl: String,
38+ val basicAuthHeader: String?,
39+)
40+
41+/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
42+fun ServerProfile.toImageContext(): ImageLoadContext {
43+ val auth = if (hasAuth && !password.isNullOrEmpty()) {
44+ val raw = "$username:$password".toByteArray()
45+ "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
46+ } else {
47+ null
48+ }
49+ return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
50+}
51+
52+/** True if the part references a raster/vector image we can render. */
53+val FilePart.isImage: Boolean
54+ get() = mime?.startsWith("image/") == true
55+
56+/**
57+ * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
58+ * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
59+ */
60+private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
61+ val src = source
62+ if (!src.isNullOrBlank() && src.startsWith("data:")) {
63+ val comma = src.indexOf(',')
64+ if (comma < 0) return null
65+ val payload = src.substring(comma + 1)
66+ return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
67+ }
68+ val url = url ?: return null
69+ // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
70+ // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71+ // same-origin check below β€” an absolute foreign URL (e.g. "https://evil.com/x.png") would
72+ // otherwise get the server's Basic auth attached and leak the credentials to that host.
73+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
74+ val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
75+ val resolved = runCatching {
76+ java.net.URI(base).resolve(url).normalize()
77+ }.getOrElse { return null }
78+ // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
79+ // sending the request (with its Basic auth) to another host. A server-absolute path
80+ // resolves to a different base *path* but the same origin, so it must still be allowed.
81+ val sameOrigin = resolved.host != null &&
82+ resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
83+ resolved.host.equals(baseUri.host, ignoreCase = true) &&
84+ resolved.port == baseUri.port
85+ return if (sameOrigin) resolved.toString() else null
86+}
87+
88+/**
89+ * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
90+ * and server-relative URLs (fetched with Basic auth when the server requires it).
91+ * Falls back to nothing when the part has no loadable source.
92+ */
93+@Composable
94+fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
95+ val context = LocalContext.current
96+ val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
97+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
98+ ImageRequest.Builder(context)
99+ .data(model)
100+ .apply {
101+ // Only send Basic auth over HTTPS; sending credentials over
102+ // cleartext HTTP would expose them on the network.
103+ val isHttps = (model as? String)?.startsWith("https://") == true
104+ if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
105+ }
106+ .crossfade(true)
107+ .build()
108+ }
109+ SubcomposeAsyncImage(
110+ model = request,
111+ contentDescription = part.filename ?: stringResource(R.string.image),
112+ contentScale = ContentScale.FillWidth,
113+ modifier = modifier
114+ .heightIn(max = 320.dp)
115+ .clip(MaterialTheme.shapes.small),
116+ loading = {
117+ Box(
118+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
119+ contentAlignment = Alignment.Center,
120+ ) {
121+ val loadingLabel = stringResource(R.string.loading)
122+ CircularProgressIndicator(
123+ modifier = Modifier.semantics { contentDescription = loadingLabel },
124+ )
125+ }
126+ },
127+ error = {
128+ Box(
129+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
130+ contentAlignment = Alignment.Center,
131+ ) {
132+ Icon(
133+ Icons.Filled.BrokenImage,
134+ contentDescription = stringResource(R.string.image_failed),
135+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
136+ )
137+ }
138+ },
139+ )
140+}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+216
@@ -0,0 +1,216 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.Context
4+import android.text.format.DateUtils
5+import androidx.compose.material3.ExperimentalMaterial3Api
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.PlainTooltip
8+import androidx.compose.material3.Text
9+import androidx.compose.material3.TooltipBox
10+import androidx.compose.material3.TooltipDefaults
11+import androidx.compose.material3.rememberTooltipState
12+import androidx.compose.runtime.Composable
13+import androidx.compose.runtime.compositionLocalOf
14+import androidx.compose.runtime.derivedStateOf
15+import androidx.compose.runtime.getValue
16+import androidx.compose.runtime.mutableLongStateOf
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
19+import androidx.compose.ui.Modifier
20+import androidx.compose.ui.graphics.Color
21+import androidx.compose.ui.platform.LocalContext
22+import androidx.compose.ui.text.TextStyle
23+import androidx.lifecycle.Lifecycle
24+import androidx.lifecycle.compose.LocalLifecycleOwner
25+import androidx.lifecycle.repeatOnLifecycle
26+import kotlinx.coroutines.delay
27+import java.util.concurrent.TimeUnit
28+
29+/**
30+ * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
31+ *
32+ * For coarse units (minutes/hours/days) prefers Android's locale-aware
33+ * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34+ * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35+ * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36+ * `isReturnDefaultValues = true` stubs the framework call to null). For
37+ * weeks/months/years β€” which [DateUtils] doesn't provide short abbreviations for β€”
38+ * uses the compact numeric form with a locale-stable unit suffix. Always returns a
39+ * non-empty string for a valid timestamp.
40+ */
41+fun relativeTime(epochMillis: Long?): String {
42+ if (epochMillis == null || epochMillis <= 0) return ""
43+ val now = System.currentTimeMillis()
44+ val diff = now - epochMillis
45+ if (diff < 0) return "now"
46+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
47+ if (minutes < 1) return "now"
48+ if (minutes < 60) {
49+ val locale = DateUtils.getRelativeTimeSpanString(
50+ epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
51+ )?.toString()
52+ return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
53+ }
54+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
55+ if (hours < 24) {
56+ val locale = DateUtils.getRelativeTimeSpanString(
57+ epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
58+ )?.toString()
59+ return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
60+ }
61+ val days = TimeUnit.MILLISECONDS.toDays(diff)
62+ if (days < 7) {
63+ val locale = DateUtils.getRelativeTimeSpanString(
64+ epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
65+ )?.toString()
66+ return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
67+ }
68+ // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
69+ // numeric form. The suffix is locale-stable (English letter) but these are
70+ // rare timestamps (older than a week) and the numeric value is the primary signal.
71+ val weeks = days / 7
72+ if (weeks < 5) return "${weeks}w"
73+ // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
74+ // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
75+ // through to years, rendering "1y" for a span that's under a year (~11.9 months).
76+ if (days < 365) return "${days / 30}mo"
77+ val years = days / 365
78+ return "${years}y"
79+}
80+
81+/** Re-evaluation interval for relative-time labels β€” coarse enough for battery, fine
82+ * enough for the smallest unit we show (minutes). */
83+private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
84+
85+/**
86+ * A shared, screen-level "tick" that advances periodically so every relative-time label
87+ * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
88+ *
89+ * val tick = rememberRelativeTimeTick()
90+ * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
91+ *
92+ * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
93+ * up its own coroutine + lifecycle observer β€” a list with many timestamped rows avoids a
94+ * timer churn as items enter and leave the viewport.
95+ *
96+ * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
97+ * then, so it keeps auto-refreshing even without a provider.
98+ */
99+val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
100+
101+/**
102+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
103+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
104+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
105+ */
106+@Composable
107+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
108+ var tick by remember { mutableLongStateOf(0L) }
109+ val lifecycleOwner = LocalLifecycleOwner.current
110+
111+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
112+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
113+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
114+ // then periodically so labels stay current.
115+ tick++
116+ while (true) {
117+ delay(intervalMs)
118+ tick++
119+ }
120+ }
121+ }
122+ return tick
123+}
124+
125+/**
126+ * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
127+ * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
128+ * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
129+ * need a provider to get auto-refreshing labels.
130+ */
131+@Composable
132+fun rememberRelativeTime(epochMillis: Long?): String {
133+ val sharedTick = LocalRelativeTimeTick.current
134+ return if (sharedTick != 0L) {
135+ // Reading sharedTick above registers a recomposition dependency on the shared
136+ // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
137+ relativeTime(epochMillis)
138+ } else {
139+ rememberRelativeTimeStandalone(epochMillis)
140+ }
141+}
142+
143+/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
144+@Composable
145+private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
146+ var tick by remember { mutableLongStateOf(0L) }
147+ val lifecycleOwner = LocalLifecycleOwner.current
148+
149+ androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
150+ if (epochMillis == null) return@LaunchedEffect
151+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
152+ // Tick immediately on resume so a stale label refreshes without waiting for
153+ // the first interval, then periodically.
154+ tick++
155+ while (true) {
156+ delay(RELATIVE_TIME_INTERVAL_MS)
157+ tick++
158+ }
159+ }
160+ }
161+
162+ // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
163+ // the relative-time string from the current wall clock on each tick. Keying remember
164+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
165+ val formatted by remember(epochMillis) {
166+ derivedStateOf {
167+ // Read tick to create a recomposition dependency on the timer.
168+ tick
169+ relativeTime(epochMillis)
170+ }
171+ }
172+ return formatted
173+}
174+
175+/**
176+ * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
177+ * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
178+ * and bubbles β€” the relative form is scannable, the absolute form is precise. Returns an
179+ * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
180+ */
181+fun absoluteTime(context: Context, epochMillis: Long?): String {
182+ if (epochMillis == null || epochMillis <= 0) return ""
183+ return DateUtils.formatDateTime(
184+ context,
185+ epochMillis,
186+ DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
187+ )
188+}
189+
190+/**
191+ * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
192+ * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
193+ * the default while the exact date/time is one long-press away β€” without spending screen
194+ * space on it. Renders nothing when the timestamp is absent so callers can drop it in
195+ * unconditionally.
196+ */
197+@OptIn(ExperimentalMaterial3Api::class)
198+@Composable
199+fun RelativeTimeText(
200+ epochMillis: Long?,
201+ modifier: Modifier = Modifier,
202+ style: TextStyle = MaterialTheme.typography.labelSmall,
203+ color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
204+) {
205+ val relative = rememberRelativeTime(epochMillis)
206+ if (relative.isEmpty()) return
207+ val context = LocalContext.current
208+ val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
209+ TooltipBox(
210+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
211+ tooltip = { PlainTooltip { Text(absolute) } },
212+ state = rememberTooltipState(),
213+ ) {
214+ Text(relative, style = style, color = color, modifier = modifier)
215+ }
216+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+375
@@ -0,0 +1,375 @@
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.Spacer
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.ChevronRight
22+import androidx.compose.material.icons.filled.Close
23+import androidx.compose.material.icons.filled.Description
24+import androidx.compose.material.icons.filled.Folder
25+import androidx.compose.material.icons.filled.Home
26+import androidx.compose.material.icons.filled.Search
27+import androidx.compose.material3.CircularProgressIndicator
28+import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.HorizontalDivider
30+import androidx.compose.material3.Icon
31+import androidx.compose.material3.IconButton
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.OutlinedTextField
34+import androidx.compose.material3.Scaffold
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.Text
38+import androidx.compose.material3.TopAppBar
39+import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.LaunchedEffect
41+import androidx.compose.runtime.getValue
42+import androidx.compose.runtime.remember
43+import androidx.compose.ui.Alignment
44+import androidx.compose.ui.Modifier
45+import androidx.compose.ui.graphics.Color
46+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
47+import androidx.compose.ui.platform.testTag
48+import androidx.compose.ui.semantics.Role
49+import androidx.compose.ui.semantics.contentDescription
50+import androidx.compose.ui.semantics.role
51+import androidx.compose.ui.semantics.semantics
52+import androidx.compose.ui.text.font.FontFamily
53+import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.text.style.TextOverflow
55+import androidx.compose.ui.unit.dp
56+import androidx.compose.ui.res.stringResource
57+import androidx.lifecycle.compose.collectAsStateWithLifecycle
58+import androidx.lifecycle.viewmodel.compose.viewModel
59+import soy.iko.opencode.data.model.FileStatusEntry
60+import soy.iko.opencode.di.AppContainer
61+import soy.iko.opencode.R
62+import soy.iko.opencode.ui.vmFactory
63+
64+@OptIn(ExperimentalMaterial3Api::class)
65+@Composable
66+fun FileBrowserScreen(
67+ container: AppContainer,
68+ onOpenFile: (String) -> Unit,
69+ onBack: () -> Unit,
70+) {
71+ val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
72+ val state by vm.state.collectAsStateWithLifecycle()
73+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
74+ val snackbar = remember { SnackbarHostState() }
75+
76+ LaunchedEffect(Unit) {
77+ vm.transientErrors.collect { msg ->
78+ snackbar.showSnackbar(msg)
79+ }
80+ }
81+
82+ Scaffold(
83+ topBar = {
84+ TopAppBar(
85+ title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
86+ navigationIcon = {
87+ IconButton(onClick = onBack) {
88+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
89+ }
90+ },
91+ )
92+ },
93+ snackbarHost = { SnackbarHost(snackbar) },
94+ ) { padding ->
95+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
96+ // Tappable breadcrumb trail so deep paths are navigable.
97+ Breadcrumbs(
98+ path = state.path,
99+ onNavigate = vm::open,
100+ modifier = Modifier.fillMaxWidth(),
101+ )
102+ val keyboardController = LocalSoftwareKeyboardController.current
103+ OutlinedTextField(
104+ value = state.query,
105+ onValueChange = vm::setQuery,
106+ modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
107+ label = { Text(stringResource(R.string.search_files)) },
108+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
109+ trailingIcon = if (state.query.isNotEmpty()) {
110+ {
111+ IconButton(onClick = { vm.setQuery("") }) {
112+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
113+ }
114+ }
115+ } else null,
116+ singleLine = true,
117+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
118+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
119+ )
120+
121+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
122+ isRefreshing = refreshing,
123+ onRefresh = { vm.refresh() },
124+ modifier = Modifier.fillMaxSize(),
125+ ) {
126+ Box(modifier = Modifier.fillMaxSize()) {
127+ when {
128+ state.loading || state.searching -> {
129+ val loadingLabel = stringResource(R.string.loading)
130+ CircularProgressIndicator(
131+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
132+ )
133+ }
134+ state.error != null -> Column(
135+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
136+ horizontalAlignment = Alignment.CenterHorizontally,
137+ ) {
138+ Text(
139+ state.error ?: "",
140+ color = MaterialTheme.colorScheme.error,
141+ )
142+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
143+ // Retry the operation that actually failed: re-run the search
144+ // when one was active (open() would reset the query and drop the
145+ // user back into the directory listing), otherwise reload the dir.
146+ androidx.compose.material3.TextButton(
147+ onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
148+ ) {
149+ Text(stringResource(R.string.retry))
150+ }
151+ }
152+ state.isSearching -> SearchResults(state.results, onOpenFile)
153+ else -> DirectoryListing(
154+ state = state,
155+ onOpenDir = vm::open,
156+ onUp = vm::up,
157+ onOpenFile = onOpenFile,
158+ )
159+ }
160+ }
161+ }
162+ }
163+ }
164+}
165+
166+@Composable
167+private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
168+ val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
169+ Row(
170+ modifier = modifier
171+ .horizontalScroll(rememberScrollState())
172+ .padding(horizontal = 16.dp, vertical = 8.dp),
173+ verticalAlignment = Alignment.CenterVertically,
174+ ) {
175+ IconButton(onClick = { onNavigate("") }) {
176+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
177+ }
178+ var acc = ""
179+ segments.forEachIndexed { index, segment ->
180+ acc = if (acc.isEmpty()) segment else "$acc/$segment"
181+ Icon(
182+ Icons.Filled.ChevronRight,
183+ contentDescription = null,
184+ modifier = Modifier.size(16.dp),
185+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
186+ )
187+ val target = acc
188+ Text(
189+ segment,
190+ style = MaterialTheme.typography.bodyMedium,
191+ color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
192+ modifier = Modifier
193+ .clickable(role = Role.Button) { onNavigate(target) }
194+ .defaultMinSize(minHeight = 48.dp)
195+ .padding(horizontal = 6.dp, vertical = 14.dp),
196+ )
197+ }
198+ }
199+}
200+
201+@Composable
202+private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
203+ if (results.isEmpty()) {
204+ EmptyFileState(
205+ icon = Icons.Filled.Search,
206+ message = stringResource(R.string.no_matches),
207+ modifier = Modifier.fillMaxWidth().padding(24.dp),
208+ )
209+ return
210+ }
211+ LazyColumn(modifier = Modifier.fillMaxSize()) {
212+ items(results, key = { it }) { path ->
213+ // Split the path into directory (muted) + filename (emphasized) so results
214+ // are scannable instead of a wall of identical-looking full paths.
215+ val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
216+ val name = path.substringAfterLast('/')
217+ FileRow(
218+ icon = false,
219+ label = name,
220+ sublabel = dir.takeIf { it.isNotEmpty() },
221+ onClick = { onOpenFile(path) },
222+ modifier = Modifier.animateItem(),
223+ )
224+ HorizontalDivider()
225+ }
226+ }
227+}
228+
229+@Composable
230+private fun DirectoryListing(
231+ state: FileBrowserState,
232+ onOpenDir: (String) -> Unit,
233+ onUp: () -> Unit,
234+ onOpenFile: (String) -> Unit,
235+) {
236+ if (state.entries.isEmpty()) {
237+ EmptyFileState(
238+ icon = Icons.Filled.Folder,
239+ message = stringResource(R.string.empty_folder),
240+ modifier = Modifier.fillMaxWidth().padding(24.dp),
241+ )
242+ return
243+ }
244+ LazyColumn(modifier = Modifier.fillMaxSize()) {
245+ if (state.path.isNotBlank()) {
246+ item(key = "__up") {
247+ FileRow(icon = true, label = "..", onClick = onUp)
248+ HorizontalDivider()
249+ }
250+ }
251+ items(state.entries, key = { it.path + "_" + it.name }) { node ->
252+ FileRow(
253+ icon = node.isDirectory,
254+ label = node.name,
255+ onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
256+ status = state.statusMap[node.path],
257+ modifier = Modifier.animateItem(),
258+ )
259+ HorizontalDivider()
260+ }
261+ }
262+}
263+
264+/** Shared empty-state for the file browser: icon + message, matching the icon+text
265+ * pattern used by the session and server lists (the prior versions were bare Text
266+ * lines that read as status messages rather than intentional empty states). */
267+@Composable
268+private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
269+ Column(
270+ modifier = modifier,
271+ horizontalAlignment = Alignment.CenterHorizontally,
272+ ) {
273+ Icon(
274+ icon,
275+ contentDescription = null,
276+ modifier = Modifier.size(48.dp),
277+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
278+ )
279+ Spacer(Modifier.size(12.dp))
280+ Text(
281+ message,
282+ style = MaterialTheme.typography.bodyMedium,
283+ color = MaterialTheme.colorScheme.onSurfaceVariant,
284+ )
285+ }
286+}
287+
288+@Composable
289+private fun FileRow(
290+ icon: Boolean,
291+ label: String,
292+ onClick: () -> Unit,
293+ status: FileStatusEntry? = null,
294+ sublabel: String? = null,
295+ modifier: Modifier = Modifier,
296+) {
297+ val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
298+ else if (icon) stringResource(R.string.folder, label)
299+ else stringResource(R.string.file_label, label)
300+ // Build a combined description so TalkBack announces both the file name and its git
301+ // status. The parent sets an explicit contentDescription (mergeDescendants), which
302+ // would otherwise suppress the StatusBadge's own description.
303+ val statusDesc = status?.let {
304+ when (it.status) {
305+ "added" -> stringResource(R.string.git_added)
306+ "modified" -> stringResource(R.string.git_modified)
307+ "deleted" -> stringResource(R.string.git_deleted)
308+ else -> ""
309+ }
310+ }.orEmpty()
311+ val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
312+ Row(
313+ modifier = modifier
314+ .fillMaxWidth()
315+ .defaultMinSize(minHeight = 48.dp)
316+ .clickable(role = Role.Button, onClick = onClick)
317+ .semantics(mergeDescendants = true) { contentDescription = fullDesc }
318+ .padding(horizontal = 16.dp, vertical = 14.dp),
319+ verticalAlignment = Alignment.CenterVertically,
320+ ) {
321+ Icon(
322+ if (icon) Icons.Filled.Folder else Icons.Filled.Description,
323+ contentDescription = null,
324+ modifier = Modifier.size(20.dp),
325+ tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
326+ )
327+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
328+ Text(
329+ label,
330+ style = MaterialTheme.typography.bodyLarge,
331+ maxLines = 1,
332+ overflow = TextOverflow.Ellipsis,
333+ )
334+ if (sublabel != null) {
335+ Text(
336+ sublabel,
337+ style = MaterialTheme.typography.labelSmall,
338+ color = MaterialTheme.colorScheme.onSurfaceVariant,
339+ maxLines = 1,
340+ overflow = TextOverflow.Ellipsis,
341+ )
342+ }
343+ }
344+ if (status != null) StatusBadge(status)
345+ }
346+}
347+
348+@Composable
349+private fun StatusBadge(status: FileStatusEntry) {
350+ val (letter, color) = when (status.status) {
351+ "added" -> "A" to MaterialTheme.colorScheme.primary
352+ "modified" -> "M" to MaterialTheme.colorScheme.tertiary
353+ "deleted" -> "D" to MaterialTheme.colorScheme.error
354+ else -> "Β·" to MaterialTheme.colorScheme.onSurfaceVariant
355+ }
356+ Row(
357+ verticalAlignment = Alignment.CenterVertically,
358+ ) {
359+ Text(
360+ letter,
361+ style = MaterialTheme.typography.labelMedium,
362+ fontFamily = FontFamily.Monospace,
363+ color = color,
364+ )
365+ if (status.added > 0 || status.removed > 0) {
366+ Text(
367+ "+${status.added} βˆ’${status.removed}",
368+ style = MaterialTheme.typography.labelSmall,
369+ fontFamily = FontFamily.Monospace,
370+ color = MaterialTheme.colorScheme.onSurfaceVariant,
371+ modifier = Modifier.padding(start = 4.dp),
372+ )
373+ }
374+ }
375+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+191
@@ -0,0 +1,191 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.FileNode
7+import soy.iko.opencode.data.model.FileStatusEntry
8+import soy.iko.opencode.data.network.NetworkConfig
9+import soy.iko.opencode.di.AppContainer
10+import soy.iko.opencode.R
11+import soy.iko.opencode.util.runCatchingCancellable
12+import kotlinx.coroutines.ExperimentalCoroutinesApi
13+import kotlinx.coroutines.Job
14+import kotlinx.coroutines.delay
15+import kotlinx.coroutines.channels.BufferOverflow
16+import kotlinx.coroutines.flow.MutableSharedFlow
17+import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.asSharedFlow
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.collectLatest
23+import kotlinx.coroutines.flow.update
24+import kotlinx.coroutines.launch
25+
26+@Immutable
27+data class FileBrowserState(
28+ val path: String = "",
29+ val entries: List<FileNode> = emptyList(),
30+ val statusMap: Map<String, FileStatusEntry> = emptyMap(),
31+ val query: String = "",
32+ val results: List<String> = emptyList(),
33+ val searching: Boolean = false,
34+ val loading: Boolean = true,
35+ val error: String? = null,
36+) {
37+ val isSearching: Boolean get() = query.isNotBlank()
38+}
39+
40+@OptIn(ExperimentalCoroutinesApi::class)
41+class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
42+
43+ private val api get() = container.activeConnection.value?.api
44+
45+ private val _state = MutableStateFlow(FileBrowserState())
46+ val state: StateFlow<FileBrowserState> = _state.asStateFlow()
47+
48+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
49+ private val _refreshing = MutableStateFlow(false)
50+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
51+
52+ /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
53+ * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
54+ private val _transientErrors = MutableSharedFlow<String>(
55+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
56+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
57+ )
58+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
59+
60+ private var searchJob: Job? = null
61+ private var openJob: Job? = null
62+
63+ init {
64+ open("")
65+ // Observe the active connection so VCS status loads (or reloads) when a connection
66+ // becomes available β€” including when the view opens during a reconnect window where
67+ // activeConnection.value was momentarily null. Without this, loadStatus() would be
68+ // called once from init with a null api and never retried.
69+ viewModelScope.launch {
70+ container.activeConnection.collectLatest { conn ->
71+ if (conn == null) return@collectLatest
72+ loadStatus()
73+ // Re-open the current directory when a connection becomes available
74+ // so the initial open("") (which may have run before auto-reconnect
75+ // finished and found no connection) doesn't leave a stale error. But if
76+ // the user is mid-search, re-run the search instead β€” open() resets
77+ // query/results and would silently discard their in-progress search.
78+ if (_state.value.isSearching) setQuery(_state.value.query)
79+ else open(_state.value.path)
80+ }
81+ }
82+ }
83+
84+ /** Tracks the in-flight VCS status load so a server switch can cancel it. */
85+ private var statusJob: Job? = null
86+
87+ /** Fetch the repo-wide VCS status once so file rows can show git badges. */
88+ private fun loadStatus() {
89+ val client = api ?: return
90+ statusJob?.cancel()
91+ statusJob = viewModelScope.launch {
92+ runCatchingCancellable { client.fileStatus() }
93+ .onSuccess { entries ->
94+ _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
95+ }
96+ .onFailure {
97+ // VCS status is best-effort: don't block the file browser, but surface
98+ // a transient error so the user knows why badges are missing.
99+ _transientErrors.tryEmit(container.friendlyError(it))
100+ }
101+ }
102+ }
103+
104+ /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
105+ fun refresh() {
106+ _refreshing.value = true
107+ val searching = _state.value.isSearching
108+ if (searching) {
109+ // Re-run the active search rather than open(), which would reset query/results
110+ // and drop the user back into the directory listing β€” discarding their search.
111+ setQuery(_state.value.query)
112+ } else {
113+ open(_state.value.path)
114+ }
115+ viewModelScope.launch {
116+ // Clear the spinner once the in-flight load settles by joining whichever job
117+ // the refresh kicked off (search or directory open).
118+ (if (searching) searchJob else openJob)?.join()
119+ _refreshing.value = false
120+ }
121+ }
122+
123+ fun open(path: String) {
124+ val client = api ?: run {
125+ _state.update { it.copy(
126+ path = path,
127+ loading = false,
128+ error = container.string(R.string.not_connected),
129+ ) }
130+ return
131+ }
132+ _state.update { it.copy(
133+ path = path,
134+ query = "",
135+ results = emptyList(),
136+ searching = false,
137+ loading = true,
138+ error = null,
139+ ) }
140+ openJob?.cancel()
141+ searchJob?.cancel()
142+ openJob = viewModelScope.launch {
143+ runCatchingCancellable { client.listDirectory(path) }
144+ .onSuccess { entries ->
145+ _state.update { it.copy(
146+ entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
147+ loading = false,
148+ ) }
149+ }
150+ .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
151+ }
152+ }
153+
154+ /** Navigate up one path segment. */
155+ fun up() {
156+ val current = _state.value.path
157+ if (current.isBlank()) return
158+ val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
159+ open(parent)
160+ }
161+
162+ fun setQuery(query: String) {
163+ _state.update { it.copy(query = query, error = null) }
164+ searchJob?.cancel()
165+ openJob?.cancel()
166+ if (query.isBlank()) {
167+ _state.update { it.copy(results = emptyList(), searching = false) }
168+ // Cancelling openJob above may have interrupted an in-flight directory load,
169+ // leaving loading=true stuck. Re-open the current directory so the listing
170+ // re-populates instead of showing a perpetual spinner after clearing search.
171+ open(_state.value.path)
172+ return
173+ }
174+ // The search view replaces the directory listing; clear loading so the spinner
175+ // from a cancelled open() doesn't persist into the search results view.
176+ // Set searching=true immediately (not after the debounce) so the spinner shows
177+ // during the debounce delay β€” otherwise the UI briefly renders SearchResults
178+ // with an empty list, flashing "no matches" before the search even starts.
179+ _state.update { it.copy(loading = false, searching = true) }
180+ val client = api ?: run {
181+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
182+ return
183+ }
184+ searchJob = viewModelScope.launch {
185+ delay(NetworkConfig.fileSearchDebounceMs) // debounce
186+ runCatchingCancellable { client.findFiles(query) }
187+ .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
188+ .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
189+ }
190+ }
191+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.FileContent
7+import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
9+import kotlinx.coroutines.ExperimentalCoroutinesApi
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
15+import kotlinx.coroutines.launch
16+import soy.iko.opencode.util.runCatchingCancellable
17+
18+@Immutable
19+data class FileViewState(
20+ val loading: Boolean = true,
21+ val content: FileContent? = null,
22+ val error: String? = null,
23+)
24+
25+@OptIn(ExperimentalCoroutinesApi::class)
26+class FileViewModel(
27+ private val container: AppContainer,
28+ private val path: String,
29+) : ViewModel() {
30+
31+ private val _state = MutableStateFlow(FileViewState())
32+ val state: StateFlow<FileViewState> = _state.asStateFlow()
33+
34+ /** Incremented by [reload] to trigger a fresh fetch without waiting for a connection change. */
35+ private val _reload = MutableStateFlow(0)
36+
37+ fun reload() { _reload.value++ }
38+
39+ init {
40+ // Observe the active connection so the file loads (or reloads) when a connection
41+ // becomes available β€” including when the view opens during a reconnect window
42+ // where activeConnection.value was momentarily null. collectLatest cancels the
43+ // in-flight load if the connection is replaced mid-read. The reload trigger is
44+ // merged in so a manual retry (e.g. after a transient error) re-fetches.
45+ viewModelScope.launch {
46+ merge(container.activeConnection, _reload).collectLatest {
47+ val conn = container.activeConnection.value
48+ if (conn == null) {
49+ _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
50+ return@collectLatest
51+ }
52+ _state.value = FileViewState(loading = true)
53+ runCatchingCancellable { conn.api.readFile(path) }
54+ .onSuccess { _state.value = FileViewState(loading = false, content = it) }
55+ .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
56+ }
57+ }
58+ }
59+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+559
@@ -0,0 +1,559 @@
1+package soy.iko.opencode.ui.file
2+
3+import android.content.Intent
4+import soy.iko.opencode.ui.components.showToast
5+import androidx.compose.animation.AnimatedContent
6+import androidx.compose.animation.core.tween
7+import androidx.compose.animation.fadeIn
8+import androidx.compose.animation.fadeOut
9+import androidx.compose.animation.togetherWith
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.horizontalScroll
12+import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.BoxScope
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Arrangement
16+import androidx.compose.foundation.layout.Row
17+import androidx.compose.foundation.layout.Spacer
18+import androidx.compose.foundation.layout.fillMaxSize
19+import androidx.compose.foundation.layout.fillMaxWidth
20+import androidx.compose.foundation.layout.padding
21+import androidx.compose.foundation.layout.size
22+import androidx.compose.foundation.layout.width
23+import androidx.compose.foundation.lazy.LazyColumn
24+import androidx.compose.foundation.lazy.LazyListState
25+import androidx.compose.foundation.lazy.itemsIndexed
26+import androidx.compose.foundation.lazy.rememberLazyListState
27+import androidx.compose.foundation.rememberScrollState
28+import androidx.compose.foundation.text.KeyboardActions
29+import androidx.compose.foundation.text.KeyboardOptions
30+import androidx.compose.foundation.verticalScroll
31+import androidx.compose.material.icons.Icons
32+import androidx.compose.material.icons.automirrored.filled.ArrowBack
33+import androidx.compose.material.icons.automirrored.filled.WrapText
34+import androidx.compose.material.icons.filled.Close
35+import androidx.compose.material.icons.filled.ContentCopy
36+import androidx.compose.material.icons.filled.KeyboardArrowDown
37+import androidx.compose.material.icons.filled.KeyboardArrowUp
38+import androidx.compose.material.icons.filled.Refresh
39+import androidx.compose.material.icons.filled.Search
40+import androidx.compose.material.icons.filled.Share
41+import androidx.compose.material3.CircularProgressIndicator
42+import androidx.compose.material3.ExperimentalMaterial3Api
43+import androidx.compose.material3.FilterChip
44+import androidx.compose.material3.Icon
45+import androidx.compose.material3.IconButton
46+import androidx.compose.material3.MaterialTheme
47+import androidx.compose.material3.OutlinedTextField
48+import androidx.compose.material3.Scaffold
49+import androidx.compose.material3.Text
50+import androidx.compose.material3.TextButton
51+import androidx.compose.material3.TopAppBar
52+import androidx.compose.runtime.Composable
53+import androidx.compose.runtime.LaunchedEffect
54+import androidx.compose.runtime.getValue
55+import androidx.compose.runtime.mutableIntStateOf
56+import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.produceState
58+import androidx.compose.runtime.remember
59+import androidx.compose.runtime.saveable.rememberSaveable
60+import androidx.compose.runtime.setValue
61+import androidx.compose.ui.Alignment
62+import androidx.compose.ui.Modifier
63+import androidx.compose.ui.layout.onSizeChanged
64+import androidx.compose.ui.platform.LocalContext
65+import androidx.compose.ui.platform.LocalDensity
66+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
67+import androidx.compose.ui.semantics.contentDescription
68+import androidx.compose.ui.semantics.semantics
69+import androidx.compose.ui.text.font.FontFamily
70+import androidx.compose.ui.text.input.ImeAction
71+import androidx.compose.ui.text.style.TextOverflow
72+import androidx.compose.ui.unit.dp
73+import androidx.compose.ui.res.stringResource
74+import androidx.lifecycle.compose.collectAsStateWithLifecycle
75+import androidx.lifecycle.viewmodel.compose.viewModel
76+import soy.iko.opencode.di.AppContainer
77+import soy.iko.opencode.R
78+import soy.iko.opencode.ui.components.DiffView
79+import soy.iko.opencode.ui.components.copyToClipboard
80+import soy.iko.opencode.ui.components.highlightLine
81+import soy.iko.opencode.ui.components.syntaxFor
82+import soy.iko.opencode.ui.components.rememberHighlightPalette
83+import soy.iko.opencode.ui.vmFactory
84+import soy.iko.opencode.util.runCatchingCancellable
85+import kotlinx.coroutines.Dispatchers
86+import kotlinx.coroutines.delay
87+import kotlinx.coroutines.withContext
88+
89+@OptIn(ExperimentalMaterial3Api::class)
90+@Composable
91+fun FileViewScreen(
92+ container: AppContainer,
93+ path: String,
94+ onBack: () -> Unit,
95+) {
96+ val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
97+ val state by vm.state.collectAsStateWithLifecycle()
98+ val context = LocalContext.current
99+ val filename = remember(path) {
100+ val trimmed = path.trimEnd('/')
101+ trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
102+ }
103+ val shareLabel = stringResource(R.string.share)
104+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
105+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
106+ val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
107+ var showDiff by rememberSaveable(path) { mutableStateOf(true) }
108+ val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
109+ // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
110+ // user's query/mode.
111+ var findActive by rememberSaveable(path) { mutableStateOf(false) }
112+ var findQuery by rememberSaveable(path) { mutableStateOf("") }
113+ var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
114+ var wrap by rememberSaveable(path) { mutableStateOf(false) }
115+ val listState = rememberLazyListState()
116+ val rawText = state.content?.content.orEmpty()
117+ val lines = remember(rawText) { rawText.split("\n") }
118+ // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
119+ // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
120+ var debouncedFind by remember { mutableStateOf("") }
121+ LaunchedEffect(findQuery) {
122+ val q = findQuery.trim()
123+ if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
124+ }
125+ val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
126+ val q = debouncedFind
127+ value = if (q.isEmpty()) emptyList()
128+ else withContext(Dispatchers.Default) {
129+ lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
130+ }
131+ }
132+ // Keep the current match index in range when the match set shrinks (e.g. the file
133+ // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
134+ LaunchedEffect(matchIndices.size) {
135+ matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
136+ }
137+ // Scroll to the current match whenever it (or the match set) changes.
138+ LaunchedEffect(matchPos, matchIndices) {
139+ matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
140+ }
141+
142+ Scaffold(
143+ topBar = {
144+ FileViewTopBar(
145+ filename = filename,
146+ loading = state.loading,
147+ content = rawText,
148+ wrap = wrap,
149+ inDiffMode = showToggle && showDiff,
150+ onBack = onBack,
151+ onReload = { vm.reload() },
152+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
153+ onToggleWrap = { wrap = !wrap },
154+ onCopy = { copyToClipboard(context, filename, rawText) },
155+ onShare = {
156+ val send = Intent(Intent.ACTION_SEND).apply {
157+ type = "text/plain"
158+ putExtra(Intent.EXTRA_SUBJECT, filename)
159+ putExtra(Intent.EXTRA_TEXT, rawText)
160+ }
161+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
162+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
163+ },
164+ )
165+ },
166+ ) { padding ->
167+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
168+ // AnimatedContent cross-fades between loading / error / content states so the
169+ // viewer doesn't snap abruptly when a reload finishes or fails.
170+ AnimatedContent(
171+ targetState = state,
172+ transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
173+ label = "fileViewState",
174+ ) { s ->
175+ // Derive match indices from THIS layer's content (s), not the outer/newest
176+ // state. During the crossfade the exiting layer still renders the old
177+ // content; passing the outer matchIndices (computed from the new content)
178+ // would highlight and scroll the wrong lines until the animation settles.
179+ val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
180+ val q = debouncedFind
181+ if (q.isEmpty()) emptyList()
182+ else {
183+ // Reuse the already-split `lines` when this layer shows the current
184+ // content (the common case); only re-split for the exiting layer's
185+ // stale content during the crossfade.
186+ val sContent = s.content?.content
187+ val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
188+ sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
189+ }
190+ }
191+ FileViewStateContent(
192+ state = s,
193+ filename = filename,
194+ showToggle = showToggle,
195+ showDiff = showDiff,
196+ onSetShowDiff = {
197+ showDiff = it
198+ // Close any open find bar when entering diff mode: find searches
199+ // the raw content and scrolls the line LazyColumn, neither of
200+ // which is visible in diff mode, so leaving it open would be a
201+ // confusing no-op.
202+ if (it) { findActive = false; findQuery = "" }
203+ },
204+ findActive = findActive,
205+ findQuery = findQuery,
206+ onQueryChange = { findQuery = it; matchPos = 0 },
207+ matchIndices = sMatchIndices,
208+ matchPos = matchPos,
209+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
210+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
211+ onCloseFind = { findActive = false; findQuery = "" },
212+ wrap = wrap,
213+ listState = listState,
214+ onRetry = { vm.reload() },
215+ )
216+ }
217+ }
218+ }
219+}
220+
221+@OptIn(ExperimentalMaterial3Api::class)
222+@Composable
223+private fun FileViewTopBar(
224+ filename: String,
225+ loading: Boolean,
226+ content: String,
227+ wrap: Boolean,
228+ inDiffMode: Boolean,
229+ onBack: () -> Unit,
230+ onReload: () -> Unit,
231+ onToggleFind: () -> Unit,
232+ onToggleWrap: () -> Unit,
233+ onCopy: () -> Unit,
234+ onShare: () -> Unit,
235+) {
236+ TopAppBar(
237+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
238+ navigationIcon = {
239+ IconButton(onClick = onBack) {
240+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
241+ }
242+ },
243+ actions = {
244+ // In-place refresh: a file's content can change on the server (the agent may
245+ // be editing it), so reload without requiring a back-out.
246+ IconButton(onClick = onReload, enabled = !loading) {
247+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
248+ }
249+ if (content.isNotEmpty()) {
250+ // Find-in-file: toggles a search bar over the raw content. Disabled only when
251+ // the DiffView is actually on screen ([inDiffMode]) β€” the find bar searches the
252+ // raw text and scrolls the line LazyColumn, neither of which is visible under a
253+ // diff, so find would silently no-op there. A plain file with no diff (the common
254+ // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
255+ IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
256+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
257+ }
258+ // Wrap long lines instead of horizontal-scrolling, useful for prose.
259+ IconButton(onClick = onToggleWrap) {
260+ Icon(
261+ Icons.AutoMirrored.Filled.WrapText,
262+ contentDescription = stringResource(R.string.wrap_lines),
263+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
264+ )
265+ }
266+ IconButton(onClick = onCopy) {
267+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
268+ }
269+ IconButton(onClick = onShare) {
270+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
271+ }
272+ }
273+ },
274+ )
275+}
276+
277+@Composable
278+private fun BoxScope.FileViewStateContent(
279+ state: FileViewState,
280+ filename: String,
281+ showToggle: Boolean,
282+ showDiff: Boolean,
283+ onSetShowDiff: (Boolean) -> Unit,
284+ findActive: Boolean,
285+ findQuery: String,
286+ onQueryChange: (String) -> Unit,
287+ matchIndices: List<Int>,
288+ matchPos: Int,
289+ onPrev: () -> Unit,
290+ onNext: () -> Unit,
291+ onCloseFind: () -> Unit,
292+ wrap: Boolean,
293+ listState: LazyListState,
294+ onRetry: () -> Unit,
295+) {
296+ when {
297+ state.loading -> {
298+ val loadingLabel = stringResource(R.string.loading)
299+ CircularProgressIndicator(
300+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
301+ )
302+ }
303+ state.error != null -> Column(
304+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
305+ horizontalAlignment = Alignment.CenterHorizontally,
306+ ) {
307+ Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
308+ Spacer(Modifier.size(12.dp))
309+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
310+ }
311+ state.content?.isBinary == true -> Text(
312+ stringResource(R.string.binary_file),
313+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
314+ color = MaterialTheme.colorScheme.onSurfaceVariant,
315+ )
316+ else -> FileViewContentBody(
317+ content = state.content,
318+ filename = filename,
319+ showToggle = showToggle,
320+ showDiff = showDiff,
321+ onSetShowDiff = onSetShowDiff,
322+ findActive = findActive,
323+ findQuery = findQuery,
324+ onQueryChange = onQueryChange,
325+ matchIndices = matchIndices,
326+ matchPos = matchPos,
327+ onPrev = onPrev,
328+ onNext = onNext,
329+ onCloseFind = onCloseFind,
330+ wrap = wrap,
331+ listState = listState,
332+ )
333+ }
334+}
335+
336+@Composable
337+private fun BoxScope.FileViewContentBody(
338+ content: soy.iko.opencode.data.model.FileContent?,
339+ filename: String,
340+ showToggle: Boolean,
341+ showDiff: Boolean,
342+ onSetShowDiff: (Boolean) -> Unit,
343+ findActive: Boolean,
344+ findQuery: String,
345+ onQueryChange: (String) -> Unit,
346+ matchIndices: List<Int>,
347+ matchPos: Int,
348+ onPrev: () -> Unit,
349+ onNext: () -> Unit,
350+ onCloseFind: () -> Unit,
351+ wrap: Boolean,
352+ listState: LazyListState,
353+) {
354+ // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
355+ // reachable while scrolling. Each contributes a top inset so the content below
356+ // isn't hidden behind the overlay. The inset is measured from the actual overlay
357+ // height (not a hardcoded 52dp/row) so accessibility font scaling β€” which can
358+ // make the FilterChip labels and FindBar text field taller than 52dp β€” doesn't
359+ // cause the first lines of content to hide behind the overlay.
360+ // The raw text is on screen whenever we're NOT rendering the diff β€” i.e. either the
361+ // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
362+ // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
363+ // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
364+ // would keep the find bar β€” and therefore find-in-file β€” permanently hidden.
365+ val rawOnScreen = !(showToggle && showDiff)
366+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
367+ var overlayHeightPx by remember { mutableIntStateOf(0) }
368+ // Only apply the measured inset while an overlay is actually composed. When overlayRows
369+ // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
370+ // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
371+ // a phantom top gap on the content below β€” e.g. after closing find on a plain file.
372+ val topInset = if (overlayRows > 0) {
373+ with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
374+ } else 0.dp
375+ if (overlayRows > 0) {
376+ Column(
377+ modifier = Modifier
378+ .align(Alignment.TopCenter)
379+ .fillMaxWidth()
380+ .onSizeChanged { overlayHeightPx = it.height }
381+ .padding(top = 4.dp),
382+ verticalArrangement = Arrangement.spacedBy(4.dp),
383+ ) {
384+ if (showToggle) {
385+ Row(
386+ modifier = Modifier.fillMaxWidth(),
387+ horizontalArrangement = Arrangement.Center,
388+ ) {
389+ FilterChip(
390+ selected = showDiff,
391+ onClick = { onSetShowDiff(true) },
392+ label = { Text(stringResource(R.string.show_diff)) },
393+ )
394+ Spacer(Modifier.size(8.dp))
395+ FilterChip(
396+ selected = !showDiff,
397+ onClick = { onSetShowDiff(false) },
398+ label = { Text(stringResource(R.string.show_raw)) },
399+ )
400+ }
401+ }
402+ if (findActive && rawOnScreen) {
403+ FindBar(
404+ query = findQuery,
405+ onQueryChange = onQueryChange,
406+ matchCount = matchIndices.size,
407+ matchPos = matchPos,
408+ onPrev = onPrev,
409+ onNext = onNext,
410+ onClose = onCloseFind,
411+ )
412+ }
413+ }
414+ }
415+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
416+ DiffView(
417+ diff = content.diff,
418+ modifier = Modifier
419+ .fillMaxSize()
420+ .padding(top = topInset)
421+ .verticalScroll(rememberScrollState())
422+ .padding(8.dp),
423+ )
424+ } else {
425+ val text = content?.content.orEmpty()
426+ if (text.isEmpty()) {
427+ Text(
428+ stringResource(R.string.empty_file),
429+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
430+ color = MaterialTheme.colorScheme.onSurfaceVariant,
431+ )
432+ } else {
433+ FileTextContent(
434+ text = text,
435+ filename = filename,
436+ topInset = topInset,
437+ wrap = wrap,
438+ findQuery = findQuery,
439+ matchIndices = matchIndices,
440+ listState = listState,
441+ )
442+ }
443+ }
444+}
445+
446+@Composable
447+private fun FindBar(
448+ query: String,
449+ onQueryChange: (String) -> Unit,
450+ matchCount: Int,
451+ matchPos: Int,
452+ onPrev: () -> Unit,
453+ onNext: () -> Unit,
454+ onClose: () -> Unit,
455+) {
456+ val keyboardController = LocalSoftwareKeyboardController.current
457+ Row(
458+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
459+ verticalAlignment = Alignment.CenterVertically,
460+ ) {
461+ OutlinedTextField(
462+ value = query,
463+ onValueChange = onQueryChange,
464+ modifier = Modifier.weight(1f),
465+ placeholder = { Text(stringResource(R.string.find_hint)) },
466+ singleLine = true,
467+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
468+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
469+ trailingIcon = {
470+ val countText = if (query.isBlank()) ""
471+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
472+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
473+ if (countText.isNotEmpty()) {
474+ // Use error color for "no matches" so the zero-result state is
475+ // distinguishable from a positive match count at a glance.
476+ val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
477+ else MaterialTheme.colorScheme.onSurfaceVariant
478+ Text(
479+ countText,
480+ style = MaterialTheme.typography.labelSmall,
481+ color = color,
482+ )
483+ }
484+ },
485+ )
486+ IconButton(onClick = onPrev, enabled = matchCount > 0) {
487+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
488+ }
489+ IconButton(onClick = onNext, enabled = matchCount > 0) {
490+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
491+ }
492+ IconButton(onClick = onClose) {
493+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
494+ }
495+ }
496+}
497+
498+@Composable
499+private fun FileTextContent(
500+ text: String,
501+ filename: String,
502+ topInset: androidx.compose.ui.unit.Dp,
503+ wrap: Boolean,
504+ findQuery: String,
505+ matchIndices: List<Int>,
506+ listState: LazyListState,
507+) {
508+ val lines = remember(text) { text.split("\n") }
509+ // Gutter width scales with the digit count and the font scale so accessibility
510+ // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
511+ // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
512+ // proportionally for larger font scales.
513+ val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
514+ val gutterWidth = remember(lines.size, fontScale) {
515+ ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
516+ }
517+ val hScrollState = rememberScrollState()
518+ val palette = rememberHighlightPalette()
519+ // Resolve the file's language once instead of re-parsing the extension for every line.
520+ val syntax = remember(filename) { syntaxFor(filename) }
521+ val q = findQuery.trim()
522+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
523+ LazyColumn(
524+ state = listState,
525+ modifier = Modifier
526+ .fillMaxSize()
527+ .padding(top = topInset)
528+ .padding(8.dp),
529+ ) {
530+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
531+ val isMatch = q.isNotEmpty() && index in matchSet
532+ Row(
533+ modifier = Modifier
534+ .fillMaxWidth()
535+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
536+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
537+ ) {
538+ Text(
539+ "${index + 1}",
540+ modifier = Modifier.width(gutterWidth),
541+ style = MaterialTheme.typography.bodySmall,
542+ fontFamily = FontFamily.Monospace,
543+ color = MaterialTheme.colorScheme.onSurfaceVariant,
544+ )
545+ // Heuristic syntax highlighting per line. Falls back to plain text for
546+ // unknown extensions, so non-code files render unchanged. Memoized so the
547+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
548+ // file, or palette actually changes β€” not on every recomposition (e.g. every
549+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
550+ val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
551+ Text(
552+ highlighted,
553+ modifier = Modifier.padding(start = 8.dp),
554+ style = MaterialTheme.typography.bodySmall,
555+ )
556+ }
557+ }
558+ }
559+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktdeletedβˆ’590
@@ -1,590 +0,0 @@
1βˆ’package soy.iko.opencode.ui.screens
2βˆ’
3βˆ’import android.content.ClipData
4βˆ’import android.content.ClipboardManager
5βˆ’import android.content.Context
6βˆ’import android.widget.Toast
7βˆ’import androidx.compose.animation.AnimatedVisibility
8βˆ’import androidx.compose.animation.core.RepeatMode
9βˆ’import androidx.compose.animation.core.animateFloat
10βˆ’import androidx.compose.animation.core.infiniteRepeatable
11βˆ’import androidx.compose.animation.core.rememberInfiniteTransition
12βˆ’import androidx.compose.animation.core.tween
13βˆ’import androidx.compose.animation.fadeIn
14βˆ’import androidx.compose.animation.fadeOut
15βˆ’import androidx.compose.animation.scaleIn
16βˆ’import androidx.compose.animation.scaleOut
17βˆ’import androidx.compose.foundation.ExperimentalFoundationApi
18βˆ’import androidx.compose.foundation.background
19βˆ’import androidx.compose.foundation.clickable
20βˆ’import androidx.compose.foundation.combinedClickable
21βˆ’import androidx.compose.foundation.layout.Arrangement
22βˆ’import androidx.compose.foundation.layout.Box
23βˆ’import androidx.compose.foundation.layout.BoxScope
24βˆ’import androidx.compose.foundation.layout.Column
25βˆ’import androidx.compose.foundation.layout.Row
26βˆ’import androidx.compose.foundation.layout.Spacer
27βˆ’import androidx.compose.foundation.layout.fillMaxSize
28βˆ’import androidx.compose.foundation.layout.fillMaxWidth
29βˆ’import androidx.compose.foundation.layout.heightIn
30βˆ’import androidx.compose.foundation.layout.PaddingValues
31βˆ’import androidx.compose.foundation.layout.WindowInsets
32βˆ’import androidx.compose.foundation.layout.ime
33βˆ’import androidx.compose.foundation.layout.imePadding
34βˆ’import androidx.compose.foundation.layout.padding
35βˆ’import androidx.compose.foundation.layout.size
36βˆ’import androidx.compose.foundation.lazy.LazyColumn
37βˆ’import androidx.compose.foundation.lazy.itemsIndexed
38βˆ’import androidx.compose.foundation.lazy.rememberLazyListState
39βˆ’import androidx.compose.foundation.shape.CircleShape
40βˆ’import androidx.compose.foundation.shape.RoundedCornerShape
41βˆ’import androidx.compose.foundation.text.KeyboardActions
42βˆ’import androidx.compose.foundation.text.KeyboardOptions
43βˆ’import androidx.compose.material.icons.Icons
44βˆ’import androidx.compose.material.icons.automirrored.filled.ArrowBack
45βˆ’import androidx.compose.material.icons.automirrored.filled.Send
46βˆ’import androidx.compose.material.icons.filled.ChatBubbleOutline
47βˆ’import androidx.compose.material.icons.filled.Close
48βˆ’import androidx.compose.material.icons.filled.KeyboardArrowDown
49βˆ’import androidx.compose.material.icons.filled.KeyboardArrowUp
50βˆ’import androidx.compose.material.icons.filled.Stop
51βˆ’import androidx.compose.material3.CircularProgressIndicator
52βˆ’import androidx.compose.material3.ExperimentalMaterial3Api
53βˆ’import androidx.compose.material3.FilledIconButton
54βˆ’import androidx.compose.material3.FilledTonalButton
55βˆ’import androidx.compose.material3.FloatingActionButton
56βˆ’import androidx.compose.material3.Icon
57βˆ’import androidx.compose.material3.IconButton
58βˆ’import androidx.compose.material3.LinearProgressIndicator
59βˆ’import androidx.compose.material3.MaterialTheme
60βˆ’import androidx.compose.material3.OutlinedTextField
61βˆ’import androidx.compose.material3.Scaffold
62βˆ’import androidx.compose.material3.Surface
63βˆ’import androidx.compose.material3.Text
64βˆ’import androidx.compose.material3.TextButton
65βˆ’import androidx.compose.material3.TopAppBar
66βˆ’import androidx.compose.runtime.Composable
67βˆ’import androidx.compose.runtime.LaunchedEffect
68βˆ’import androidx.compose.runtime.derivedStateOf
69βˆ’import androidx.compose.runtime.getValue
70βˆ’import androidx.compose.runtime.mutableStateOf
71βˆ’import androidx.compose.runtime.remember
72βˆ’import androidx.compose.runtime.rememberCoroutineScope
73βˆ’import androidx.compose.runtime.setValue
74βˆ’import androidx.compose.ui.Alignment
75βˆ’import androidx.compose.ui.Modifier
76βˆ’import androidx.compose.ui.draw.clip
77βˆ’import androidx.compose.ui.hapticfeedback.HapticFeedbackType
78βˆ’import androidx.compose.ui.platform.LocalClipboardManager
79βˆ’import androidx.compose.ui.platform.LocalContext
80βˆ’import androidx.compose.ui.platform.LocalDensity
81βˆ’import androidx.compose.ui.platform.LocalHapticFeedback
82βˆ’import androidx.compose.ui.text.font.FontStyle
83βˆ’import androidx.compose.ui.text.input.ImeAction
84βˆ’import androidx.compose.ui.text.style.TextAlign
85βˆ’import androidx.compose.ui.text.style.TextOverflow
86βˆ’import androidx.compose.ui.unit.dp
87βˆ’import kotlinx.coroutines.launch
88βˆ’import soy.iko.opencode.core.Event
89βˆ’import soy.iko.opencode.core.UiState
90βˆ’import soy.iko.opencode.ui.components.MessageBubble
91βˆ’
92βˆ’@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
93βˆ’@Composable
94βˆ’fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
95βˆ’ val listState = rememberLazyListState()
96βˆ’ val coroutineScope = rememberCoroutineScope()
97βˆ’ val context = LocalContext.current
98βˆ’
99βˆ’ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
100βˆ’ // sit over the top of the IME. Reading the ime inset bottom during composition
101βˆ’ // observes snapshot state, so this flips as the keyboard opens/closes.
102βˆ’ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
103βˆ’
104βˆ’ // Show a jump-to-bottom button once the user scrolls up away from the latest
105βˆ’ // message. derivedStateOf keeps this from recomposing on every pixel of scroll β€”
106βˆ’ // it only flips when the near-bottom threshold is actually crossed.
107βˆ’ val showScrollButton by remember {
108βˆ’ derivedStateOf {
109βˆ’ val layoutInfo = listState.layoutInfo
110βˆ’ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111βˆ’ val totalItems = layoutInfo.totalItemsCount
112βˆ’ totalItems > 0 && lastVisibleIndex < totalItems - 3
113βˆ’ }
114βˆ’ }
115βˆ’
116βˆ’ // Offer a jump-to-top shortcut once the conversation is long and the user has
117βˆ’ // scrolled a few messages down β€” saves flick-scrolling back through history.
118βˆ’ val showScrollTop by remember {
119βˆ’ derivedStateOf { state.messages.size > 10 && listState.firstVisibleItemIndex > 3 }
120βˆ’ }
121βˆ’
122βˆ’ // Track how many messages the user has "seen" (i.e. was at the bottom for). While
123βˆ’ // they're scrolled up, any messages beyond that count are unseen β€” flag the FAB.
124βˆ’ var lastSeenCount by remember { mutableStateOf(0) }
125βˆ’ LaunchedEffect(showScrollButton, state.messages.size) {
126βˆ’ if (!showScrollButton) {
127βˆ’ lastSeenCount = state.messages.size
128βˆ’ }
129βˆ’ }
130βˆ’ val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131βˆ’
132βˆ’ // Keep the newest content in view as it streams in, but only when the user is
133βˆ’ // already near the bottom. If they've scrolled up to read history, leave it alone.
134βˆ’ val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
135βˆ’ LaunchedEffect(state.messages.size, lastLen, state.generating) {
136βˆ’ if (state.messages.isNotEmpty()) {
137βˆ’ val layoutInfo = listState.layoutInfo
138βˆ’ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
139βˆ’ val totalItems = layoutInfo.totalItemsCount
140βˆ’ val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
141βˆ’ if (nearBottom) {
142βˆ’ listState.animateScrollToItem(state.messages.size - 1)
143βˆ’ }
144βˆ’ }
145βˆ’ }
146βˆ’
147βˆ’ Scaffold(
148βˆ’ topBar = {
149βˆ’ TopAppBar(
150βˆ’ title = {
151βˆ’ val title = state.currentSessionTitle.ifEmpty { "Chat" }
152βˆ’ Text(
153βˆ’ text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
154βˆ’ maxLines = 1,
155βˆ’ overflow = TextOverflow.Ellipsis,
156βˆ’ modifier = Modifier.combinedClickable(
157βˆ’ onClick = { dispatch(Event.NavigateToSessions) },
158βˆ’ onLongClick = {
159βˆ’ state.currentSessionId?.let { id ->
160βˆ’ copyToClipboard(context, id)
161βˆ’ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
162βˆ’ }
163βˆ’ },
164βˆ’ ),
165βˆ’ )
166βˆ’ },
167βˆ’ navigationIcon = {
168βˆ’ IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
169βˆ’ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
170βˆ’ }
171βˆ’ },
172βˆ’ actions = {
173βˆ’ if (state.generating) {
174βˆ’ CircularProgressIndicator(
175βˆ’ modifier = Modifier.padding(end = 16.dp).size(22.dp),
176βˆ’ strokeWidth = 2.dp,
177βˆ’ )
178βˆ’ }
179βˆ’ },
180βˆ’ )
181βˆ’ },
182βˆ’ bottomBar = {
183βˆ’ ChatInputBar(
184βˆ’ draft = state.draft,
185βˆ’ generating = state.generating,
186βˆ’ loading = state.loading && state.messages.isEmpty(),
187βˆ’ onDraftChange = { dispatch(Event.DraftChanged(it)) },
188βˆ’ onSend = { dispatch(Event.SendMessage(state.draft)) },
189βˆ’ onStop = { dispatch(Event.CancelGeneration) },
190βˆ’ )
191βˆ’ },
192βˆ’ ) { padding ->
193βˆ’ Column(Modifier.fillMaxSize().padding(padding)) {
194βˆ’ if (!state.sseConnected && !state.generating) {
195βˆ’ ReconnectingBanner()
196βˆ’ }
197βˆ’ if (state.loading && state.messages.isEmpty()) {
198βˆ’ LinearProgressIndicator(Modifier.fillMaxWidth())
199βˆ’ }
200βˆ’ if (state.error != null && state.messages.isNotEmpty()) {
201βˆ’ ErrorBanner(
202βˆ’ message = state.error,
203βˆ’ onDismiss = { dispatch(Event.DismissError) },
204βˆ’ )
205βˆ’ }
206βˆ’ Box(Modifier.fillMaxWidth().weight(1f)) {
207βˆ’ if (state.messages.isEmpty() && !state.loading) {
208βˆ’ Column(
209βˆ’ modifier = Modifier.align(Alignment.Center).padding(32.dp),
210βˆ’ horizontalAlignment = Alignment.CenterHorizontally,
211βˆ’ ) {
212βˆ’ Icon(
213βˆ’ Icons.Filled.ChatBubbleOutline,
214βˆ’ contentDescription = null,
215βˆ’ modifier = Modifier.size(48.dp),
216βˆ’ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
217βˆ’ )
218βˆ’ Spacer(Modifier.size(16.dp))
219βˆ’ Text(
220βˆ’ text = "Send a message to start the conversation.",
221βˆ’ style = MaterialTheme.typography.bodyMedium,
222βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
223βˆ’ textAlign = TextAlign.Center,
224βˆ’ )
225βˆ’ }
226βˆ’ }
227βˆ’ LazyColumn(
228βˆ’ state = listState,
229βˆ’ modifier = Modifier.fillMaxSize(),
230βˆ’ contentPadding = PaddingValues(vertical = 8.dp),
231βˆ’ ) {
232βˆ’ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
233βˆ’ // Insert a date separator whenever a message falls on a different
234βˆ’ // calendar day than the one before it (and above the first message).
235βˆ’ val previous = state.messages.getOrNull(index - 1)
236βˆ’ if (shouldShowDateSeparator(previous, message)) {
237βˆ’ DateSeparator(message.time)
238βˆ’ }
239βˆ’ val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
240βˆ’ MessageBubble(
241βˆ’ message,
242βˆ’ onRetry = { dispatch(Event.SendMessage(message.text)) },
243βˆ’ compactSpacing = sameSenderAsPrevious,
244βˆ’ )
245βˆ’ }
246βˆ’ // While the assistant is spinning up but hasn't emitted a streaming
247βˆ’ // message yet (only the user's message is present), show a typing
248βˆ’ // indicator so the wait doesn't look like a stall.
249βˆ’ if (state.generating && state.messages.none { it.streaming }) {
250βˆ’ item(key = "typing-indicator") {
251βˆ’ TypingIndicator()
252βˆ’ }
253βˆ’ }
254βˆ’ }
255βˆ’ ScrollToBottomButton(
256βˆ’ visible = showScrollButton && !isImeVisible,
257βˆ’ showBadge = hasUnseenMessages,
258βˆ’ onClick = {
259βˆ’ coroutineScope.launch {
260βˆ’ if (state.messages.isNotEmpty()) {
261βˆ’ // When the typing indicator is showing it occupies the item
262βˆ’ // just past the last message, so scroll one further to reach it.
263βˆ’ val target = if (state.generating && state.messages.none { it.streaming })
264βˆ’ state.messages.size
265βˆ’ else
266βˆ’ state.messages.size - 1
267βˆ’ listState.animateScrollToItem(target)
268βˆ’ }
269βˆ’ }
270βˆ’ },
271βˆ’ )
272βˆ’ ScrollToTopButton(
273βˆ’ visible = showScrollTop,
274βˆ’ onClick = {
275βˆ’ coroutineScope.launch {
276βˆ’ listState.animateScrollToItem(0)
277βˆ’ }
278βˆ’ },
279βˆ’ )
280βˆ’ }
281βˆ’ }
282βˆ’ }
283βˆ’}
284βˆ’
285βˆ’// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
286βˆ’// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
287βˆ’// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
288βˆ’@Composable
289βˆ’private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
290βˆ’ AnimatedVisibility(
291βˆ’ visible = visible,
292βˆ’ modifier = Modifier.align(Alignment.BottomEnd),
293βˆ’ enter = scaleIn() + fadeIn(),
294βˆ’ exit = scaleOut() + fadeOut(),
295βˆ’ ) {
296βˆ’ Box(Modifier.padding(16.dp)) {
297βˆ’ FloatingActionButton(
298βˆ’ onClick = onClick,
299βˆ’ modifier = Modifier.size(44.dp),
300βˆ’ ) {
301βˆ’ Icon(
302βˆ’ Icons.Filled.KeyboardArrowDown,
303βˆ’ contentDescription = "Scroll to latest message",
304βˆ’ )
305βˆ’ }
306βˆ’ if (showBadge) {
307βˆ’ Box(
308βˆ’ modifier = Modifier
309βˆ’ .align(Alignment.TopEnd)
310βˆ’ .size(8.dp)
311βˆ’ .clip(CircleShape)
312βˆ’ .background(MaterialTheme.colorScheme.error),
313βˆ’ )
314βˆ’ }
315βˆ’ }
316βˆ’ }
317βˆ’}
318βˆ’
319βˆ’// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320βˆ’// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
321βˆ’@Composable
322βˆ’private fun BoxScope.ScrollToTopButton(visible: Boolean, onClick: () -> Unit) {
323βˆ’ AnimatedVisibility(
324βˆ’ visible = visible,
325βˆ’ modifier = Modifier.align(Alignment.TopStart),
326βˆ’ enter = scaleIn() + fadeIn(),
327βˆ’ exit = scaleOut() + fadeOut(),
328βˆ’ ) {
329βˆ’ Box(Modifier.padding(16.dp)) {
330βˆ’ FloatingActionButton(
331βˆ’ onClick = onClick,
332βˆ’ modifier = Modifier.size(44.dp),
333βˆ’ ) {
334βˆ’ Icon(
335βˆ’ Icons.Filled.KeyboardArrowUp,
336βˆ’ contentDescription = "Scroll to top",
337βˆ’ )
338βˆ’ }
339βˆ’ }
340βˆ’ }
341βˆ’}
342βˆ’
343βˆ’private fun copyToClipboard(context: Context, text: String) {
344βˆ’ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
345βˆ’ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
346βˆ’}
347βˆ’
348βˆ’private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
349βˆ’
350βˆ’/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
351βˆ’private fun localDayOf(epochMillis: Long): java.time.LocalDate =
352βˆ’ java.time.Instant.ofEpochMilli(epochMillis)
353βˆ’ .atZone(java.time.ZoneId.systemDefault())
354βˆ’ .toLocalDate()
355βˆ’
356βˆ’/**
357βˆ’ * A separator is shown above a message when it starts a new calendar day β€” i.e. the
358βˆ’ * first timestamped message, or one whose day differs from the previous message's.
359βˆ’ * Messages without a valid time (time <= 0) never get one.
360βˆ’ */
361βˆ’private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
362βˆ’ if (current.time <= 0) return false
363βˆ’ if (previous == null || previous.time <= 0) return true
364βˆ’ return localDayOf(previous.time) != localDayOf(current.time)
365βˆ’}
366βˆ’
367βˆ’/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
368βˆ’private fun dateSeparatorLabel(day: java.time.LocalDate): String {
369βˆ’ val today = java.time.LocalDate.now()
370βˆ’ return when (day) {
371βˆ’ today -> "Today"
372βˆ’ today.minusDays(1) -> "Yesterday"
373βˆ’ else -> day.format(dateSeparatorFormat)
374βˆ’ }
375βˆ’}
376βˆ’
377βˆ’@Composable
378βˆ’private fun DateSeparator(epochMillis: Long) {
379βˆ’ Text(
380βˆ’ text = dateSeparatorLabel(localDayOf(epochMillis)),
381βˆ’ style = MaterialTheme.typography.labelSmall,
382βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
383βˆ’ textAlign = TextAlign.Center,
384βˆ’ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
385βˆ’ )
386βˆ’}
387βˆ’
388βˆ’// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
389βˆ’// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
390βˆ’// surfaceVariant look so it reads as an incoming message.
391βˆ’@Composable
392βˆ’private fun TypingIndicator() {
393βˆ’ val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
394βˆ’ val transition = rememberInfiniteTransition(label = "typing")
395βˆ’ Row(
396βˆ’ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
397βˆ’ horizontalArrangement = Arrangement.Start,
398βˆ’ ) {
399βˆ’ Row(
400βˆ’ modifier = Modifier
401βˆ’ .clip(shape)
402βˆ’ .background(MaterialTheme.colorScheme.surfaceVariant)
403βˆ’ .padding(horizontal = 14.dp, vertical = 12.dp),
404βˆ’ verticalAlignment = Alignment.CenterVertically,
405βˆ’ horizontalArrangement = Arrangement.spacedBy(6.dp),
406βˆ’ ) {
407βˆ’ Text(
408βˆ’ text = "Thinking",
409βˆ’ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
410βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
411βˆ’ )
412βˆ’ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
413βˆ’ // Stagger the three dots so they fade in and out in sequence.
414βˆ’ listOf(0, 150, 300).forEach { staggerMs ->
415βˆ’ val alpha by transition.animateFloat(
416βˆ’ initialValue = 0.2f,
417βˆ’ targetValue = 1f,
418βˆ’ animationSpec = infiniteRepeatable(
419βˆ’ animation = tween(durationMillis = 600, delayMillis = staggerMs),
420βˆ’ repeatMode = RepeatMode.Reverse,
421βˆ’ ),
422βˆ’ label = "dot",
423βˆ’ )
424βˆ’ Box(
425βˆ’ modifier = Modifier
426βˆ’ .size(6.dp)
427βˆ’ .clip(CircleShape)
428βˆ’ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
429βˆ’ )
430βˆ’ }
431βˆ’ }
432βˆ’ }
433βˆ’ }
434βˆ’}
435βˆ’
436βˆ’@Composable
437βˆ’private fun ReconnectingBanner() {
438βˆ’ Surface(
439βˆ’ modifier = Modifier.fillMaxWidth(),
440βˆ’ color = MaterialTheme.colorScheme.tertiaryContainer,
441βˆ’ ) {
442βˆ’ Row(
443βˆ’ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
444βˆ’ verticalAlignment = Alignment.CenterVertically,
445βˆ’ horizontalArrangement = Arrangement.spacedBy(8.dp),
446βˆ’ ) {
447βˆ’ CircularProgressIndicator(
448βˆ’ modifier = Modifier.size(14.dp),
449βˆ’ strokeWidth = 2.dp,
450βˆ’ color = MaterialTheme.colorScheme.onTertiaryContainer,
451βˆ’ )
452βˆ’ Text(
453βˆ’ text = "Reconnecting…",
454βˆ’ style = MaterialTheme.typography.bodySmall,
455βˆ’ color = MaterialTheme.colorScheme.onTertiaryContainer,
456βˆ’ )
457βˆ’ }
458βˆ’ }
459βˆ’}
460βˆ’
461βˆ’@Composable
462βˆ’private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
463βˆ’ Surface(
464βˆ’ modifier = Modifier.fillMaxWidth(),
465βˆ’ color = MaterialTheme.colorScheme.errorContainer,
466βˆ’ ) {
467βˆ’ Row(
468βˆ’ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
469βˆ’ verticalAlignment = Alignment.CenterVertically,
470βˆ’ horizontalArrangement = Arrangement.spacedBy(8.dp),
471βˆ’ ) {
472βˆ’ Text(
473βˆ’ text = message,
474βˆ’ style = MaterialTheme.typography.bodySmall,
475βˆ’ color = MaterialTheme.colorScheme.onErrorContainer,
476βˆ’ modifier = Modifier.weight(1f),
477βˆ’ )
478βˆ’ IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
479βˆ’ Icon(
480βˆ’ Icons.Filled.Close,
481βˆ’ contentDescription = "Dismiss error",
482βˆ’ tint = MaterialTheme.colorScheme.onErrorContainer,
483βˆ’ modifier = Modifier.size(18.dp),
484βˆ’ )
485βˆ’ }
486βˆ’ }
487βˆ’ }
488βˆ’}
489βˆ’
490βˆ’@Composable
491βˆ’private fun ChatInputBar(
492βˆ’ draft: String,
493βˆ’ generating: Boolean,
494βˆ’ loading: Boolean,
495βˆ’ onDraftChange: (String) -> Unit,
496βˆ’ onSend: () -> Unit,
497βˆ’ onStop: () -> Unit,
498βˆ’) {
499βˆ’ val haptic = LocalHapticFeedback.current
500βˆ’ val clipboardManager = LocalClipboardManager.current
501βˆ’ Surface(tonalElevation = 3.dp) {
502βˆ’ Column(
503βˆ’ modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
504βˆ’ ) {
505βˆ’ Row(
506βˆ’ modifier = Modifier.fillMaxWidth(),
507βˆ’ verticalAlignment = Alignment.Bottom,
508βˆ’ horizontalArrangement = Arrangement.spacedBy(8.dp),
509βˆ’ ) {
510βˆ’ OutlinedTextField(
511βˆ’ value = draft,
512βˆ’ onValueChange = onDraftChange,
513βˆ’ modifier = Modifier.weight(1f).heightIn(min = 48.dp),
514βˆ’ enabled = !loading,
515βˆ’ placeholder = { Text("Message opencode…") },
516βˆ’ maxLines = 5,
517βˆ’ // Offer a quick way to wipe the draft once there's something to clear
518βˆ’ // and we're not mid-generation (where the field is a poor place to fuss).
519βˆ’ trailingIcon = if (draft.isNotEmpty() && !generating) {
520βˆ’ {
521βˆ’ IconButton(
522βˆ’ onClick = { onDraftChange("") },
523βˆ’ modifier = Modifier.size(20.dp),
524βˆ’ ) {
525βˆ’ Icon(
526βˆ’ Icons.Filled.Close,
527βˆ’ contentDescription = "Clear draft",
528βˆ’ modifier = Modifier.size(16.dp),
529βˆ’ )
530βˆ’ }
531βˆ’ }
532βˆ’ } else {
533βˆ’ null
534βˆ’ },
535βˆ’ // Surface a running count once the draft gets long, so the user has a
536βˆ’ // sense of scale before sending a wall of text.
537βˆ’ supportingText = if (draft.length > 500) {
538βˆ’ {
539βˆ’ Text(
540βˆ’ text = "${draft.length} characters",
541βˆ’ style = MaterialTheme.typography.labelSmall,
542βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
543βˆ’ textAlign = TextAlign.End,
544βˆ’ modifier = Modifier.fillMaxWidth(),
545βˆ’ )
546βˆ’ }
547βˆ’ } else {
548βˆ’ null
549βˆ’ },
550βˆ’ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551βˆ’ keyboardActions = KeyboardActions(onSend = {
552βˆ’ if (!generating && !loading && draft.isNotBlank()) onSend()
553βˆ’ }),
554βˆ’ )
555βˆ’ if (generating) {
556βˆ’ FilledTonalButton(onClick = onStop) {
557βˆ’ Icon(
558βˆ’ Icons.Filled.Stop,
559βˆ’ contentDescription = null,
560βˆ’ modifier = Modifier.size(18.dp),
561βˆ’ )
562βˆ’ Spacer(Modifier.size(6.dp))
563βˆ’ Text("Stop")
564βˆ’ }
565βˆ’ } else {
566βˆ’ FilledIconButton(
567βˆ’ onClick = {
568βˆ’ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
569βˆ’ onSend()
570βˆ’ },
571βˆ’ enabled = draft.isNotBlank() && !loading,
572βˆ’ ) {
573βˆ’ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
574βˆ’ }
575βˆ’ }
576βˆ’ }
577βˆ’ // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
578βˆ’ if (draft.isEmpty() && !generating && !loading) {
579βˆ’ TextButton(
580βˆ’ onClick = {
581βˆ’ clipboardManager.getText()?.text?.let { onDraftChange(it) }
582βˆ’ },
583βˆ’ contentPadding = PaddingValues(0.dp),
584βˆ’ ) {
585βˆ’ Text("Paste")
586βˆ’ }
587βˆ’ }
588βˆ’ }
589βˆ’ }
590βˆ’}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktdeletedβˆ’222
@@ -1,222 +0,0 @@
1βˆ’package soy.iko.opencode.ui.screens
2βˆ’
3βˆ’import androidx.compose.foundation.layout.Arrangement
4βˆ’import androidx.compose.foundation.layout.Column
5βˆ’import androidx.compose.foundation.layout.Spacer
6βˆ’import androidx.compose.foundation.layout.fillMaxSize
7βˆ’import androidx.compose.foundation.layout.fillMaxWidth
8βˆ’import androidx.compose.foundation.layout.height
9βˆ’import androidx.compose.foundation.layout.imePadding
10βˆ’import androidx.compose.foundation.layout.padding
11βˆ’import androidx.compose.foundation.layout.size
12βˆ’import androidx.compose.foundation.rememberScrollState
13βˆ’import androidx.compose.foundation.text.KeyboardActions
14βˆ’import androidx.compose.foundation.text.KeyboardOptions
15βˆ’import androidx.compose.foundation.verticalScroll
16βˆ’import androidx.compose.material.icons.Icons
17βˆ’import androidx.compose.material.icons.filled.Lock
18βˆ’import androidx.compose.material.icons.filled.Person
19βˆ’import androidx.compose.material.icons.filled.Terminal
20βˆ’import androidx.compose.material.icons.filled.Visibility
21βˆ’import androidx.compose.material.icons.filled.VisibilityOff
22βˆ’import androidx.compose.material3.Button
23βˆ’import androidx.compose.material3.CircularProgressIndicator
24βˆ’import androidx.compose.material3.Icon
25βˆ’import androidx.compose.material3.IconButton
26βˆ’import androidx.compose.material3.MaterialTheme
27βˆ’import androidx.compose.material3.OutlinedTextField
28βˆ’import androidx.compose.material3.Text
29βˆ’import androidx.compose.runtime.Composable
30βˆ’import androidx.compose.runtime.LaunchedEffect
31βˆ’import androidx.compose.runtime.getValue
32βˆ’import androidx.compose.runtime.mutableStateOf
33βˆ’import androidx.compose.runtime.remember
34βˆ’import androidx.compose.runtime.setValue
35βˆ’import androidx.compose.ui.Alignment
36βˆ’import androidx.compose.ui.Modifier
37βˆ’import androidx.compose.ui.focus.FocusRequester
38βˆ’import androidx.compose.ui.focus.focusRequester
39βˆ’import androidx.compose.ui.text.input.ImeAction
40βˆ’import androidx.compose.ui.text.input.KeyboardType
41βˆ’import androidx.compose.ui.text.input.PasswordVisualTransformation
42βˆ’import androidx.compose.ui.text.input.VisualTransformation
43βˆ’import androidx.compose.ui.text.style.TextAlign
44βˆ’import androidx.compose.ui.unit.dp
45βˆ’import soy.iko.opencode.core.Event
46βˆ’import soy.iko.opencode.core.UiState
47βˆ’
48βˆ’@Composable
49βˆ’fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50βˆ’ val focusRequester = remember { FocusRequester() }
51βˆ’ var passwordVisible by remember { mutableStateOf(false) }
52βˆ’
53βˆ’ // Focus the server URL field when the screen opens, but only when it's empty
54βˆ’ // so we don't steal focus (and pop the keyboard) on a return visit.
55βˆ’ LaunchedEffect(Unit) {
56βˆ’ if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
57βˆ’ }
58βˆ’
59βˆ’ Column(
60βˆ’ modifier = Modifier
61βˆ’ .fillMaxSize()
62βˆ’ .verticalScroll(rememberScrollState())
63βˆ’ .imePadding()
64βˆ’ .padding(horizontal = 28.dp),
65βˆ’ verticalArrangement = Arrangement.Center,
66βˆ’ horizontalAlignment = Alignment.CenterHorizontally,
67βˆ’ ) {
68βˆ’ Icon(
69βˆ’ Icons.Filled.Terminal,
70βˆ’ contentDescription = null,
71βˆ’ tint = MaterialTheme.colorScheme.primary,
72βˆ’ modifier = Modifier.size(72.dp),
73βˆ’ )
74βˆ’ Spacer(Modifier.height(16.dp))
75βˆ’ Text(
76βˆ’ text = "opencode",
77βˆ’ style = MaterialTheme.typography.displaySmall,
78βˆ’ color = MaterialTheme.colorScheme.primary,
79βˆ’ )
80βˆ’ Text(
81βˆ’ text = "Connect to your opencode server",
82βˆ’ style = MaterialTheme.typography.bodyMedium,
83βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
84βˆ’ textAlign = TextAlign.Center,
85βˆ’ )
86βˆ’
87βˆ’ Spacer(Modifier.height(32.dp))
88βˆ’
89βˆ’ OutlinedTextField(
90βˆ’ value = state.serverUrl,
91βˆ’ onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
92βˆ’ label = { Text("Server URL") },
93βˆ’ placeholder = { Text("http://192.168.1.10:4096") },
94βˆ’ singleLine = true,
95βˆ’ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
96βˆ’ keyboardOptions = KeyboardOptions(
97βˆ’ keyboardType = KeyboardType.Uri,
98βˆ’ imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
99βˆ’ ),
100βˆ’ keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
101βˆ’ enabled = !state.loading,
102βˆ’ )
103βˆ’
104βˆ’ // Point first-time users at the expected address format. Hidden once they
105βˆ’ // start typing so it doesn't compete with the field's own contents.
106βˆ’ if (state.serverUrl.isEmpty()) {
107βˆ’ Spacer(Modifier.height(6.dp))
108βˆ’ Text(
109βˆ’ text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110βˆ’ style = MaterialTheme.typography.bodySmall,
111βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
112βˆ’ textAlign = TextAlign.Center,
113βˆ’ )
114βˆ’ }
115βˆ’
116βˆ’ // Nudge the user if the URL looks like it's missing a scheme β€” a bare host:port
117βˆ’ // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118βˆ’ if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119βˆ’ Spacer(Modifier.height(6.dp))
120βˆ’ Text(
121βˆ’ text = "Tip: Add http:// if connecting to a local server",
122βˆ’ style = MaterialTheme.typography.bodySmall,
123βˆ’ color = MaterialTheme.colorScheme.tertiary,
124βˆ’ textAlign = TextAlign.Center,
125βˆ’ )
126βˆ’ }
127βˆ’
128βˆ’ if (state.authRequired) {
129βˆ’ Spacer(Modifier.height(12.dp))
130βˆ’ OutlinedTextField(
131βˆ’ value = state.username,
132βˆ’ onValueChange = { dispatch(Event.UsernameChanged(it)) },
133βˆ’ label = { Text("Username") },
134βˆ’ singleLine = true,
135βˆ’ leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136βˆ’ modifier = Modifier.fillMaxWidth(),
137βˆ’ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138βˆ’ enabled = !state.loading,
139βˆ’ )
140βˆ’ Spacer(Modifier.height(12.dp))
141βˆ’ OutlinedTextField(
142βˆ’ value = state.password,
143βˆ’ onValueChange = { dispatch(Event.PasswordChanged(it)) },
144βˆ’ label = { Text("Password") },
145βˆ’ singleLine = true,
146βˆ’ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
147βˆ’ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
148βˆ’ trailingIcon = {
149βˆ’ IconButton(onClick = { passwordVisible = !passwordVisible }) {
150βˆ’ Icon(
151βˆ’ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
152βˆ’ contentDescription = if (passwordVisible) "Hide password" else "Show password",
153βˆ’ )
154βˆ’ }
155βˆ’ },
156βˆ’ modifier = Modifier.fillMaxWidth(),
157βˆ’ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158βˆ’ keyboardActions = KeyboardActions(onGo = {
159βˆ’ // Mirror the connect button's enabled condition so the keyboard's
160βˆ’ // Go action can't fire a request the form isn't ready for.
161βˆ’ if (!state.loading && state.serverUrl.isNotBlank() &&
162βˆ’ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163βˆ’ ) {
164βˆ’ dispatch(Event.Connect)
165βˆ’ }
166βˆ’ }),
167βˆ’ enabled = !state.loading,
168βˆ’ )
169βˆ’ Spacer(Modifier.height(4.dp))
170βˆ’ Text(
171βˆ’ text = "This server requires authentication.",
172βˆ’ style = MaterialTheme.typography.bodySmall,
173βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
174βˆ’ )
175βˆ’ }
176βˆ’
177βˆ’ Spacer(Modifier.height(24.dp))
178βˆ’
179βˆ’ Button(
180βˆ’ onClick = { dispatch(Event.Connect) },
181βˆ’ // When auth is required, keep the button disabled until at least one
182βˆ’ // credential is entered β€” otherwise it just triggers a validation error.
183βˆ’ enabled = !state.loading && state.serverUrl.isNotBlank() &&
184βˆ’ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
185βˆ’ modifier = Modifier.fillMaxWidth(),
186βˆ’ ) {
187βˆ’ if (state.loading) {
188βˆ’ CircularProgressIndicator(
189βˆ’ modifier = Modifier.height(20.dp),
190βˆ’ strokeWidth = 2.dp,
191βˆ’ color = MaterialTheme.colorScheme.onPrimary,
192βˆ’ )
193βˆ’ } else {
194βˆ’ Text(if (state.authRequired) "Sign in" else "Connect")
195βˆ’ }
196βˆ’ }
197βˆ’
198βˆ’ // While connecting (including auto-connect on launch), echo the target URL
199βˆ’ // so the user knows what the spinner is waiting on.
200βˆ’ if (state.loading) {
201βˆ’ Spacer(Modifier.height(8.dp))
202βˆ’ Text(
203βˆ’ text = "Connecting to ${state.serverUrl}...",
204βˆ’ style = MaterialTheme.typography.bodySmall,
205βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
206βˆ’ textAlign = TextAlign.Center,
207βˆ’ )
208βˆ’ }
209βˆ’
210βˆ’ // Surface the error inline as well, so it stays visible while the user
211βˆ’ // corrects the form (the snackbar is transient).
212βˆ’ if (state.error != null && !state.loading) {
213βˆ’ Spacer(Modifier.height(8.dp))
214βˆ’ Text(
215βˆ’ text = state.error,
216βˆ’ style = MaterialTheme.typography.bodySmall,
217βˆ’ color = MaterialTheme.colorScheme.error,
218βˆ’ textAlign = TextAlign.Center,
219βˆ’ )
220βˆ’ }
221βˆ’ }
222βˆ’}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktdeletedβˆ’402
@@ -1,402 +0,0 @@
1βˆ’package soy.iko.opencode.ui.screens
2βˆ’
3βˆ’import android.content.ClipData
4βˆ’import android.content.ClipboardManager
5βˆ’import android.content.Context
6βˆ’import android.widget.Toast
7βˆ’import androidx.compose.foundation.ExperimentalFoundationApi
8βˆ’import androidx.compose.foundation.background
9βˆ’import androidx.compose.foundation.clickable
10βˆ’import androidx.compose.foundation.combinedClickable
11βˆ’import androidx.compose.foundation.layout.Arrangement
12βˆ’import androidx.compose.foundation.layout.Box
13βˆ’import androidx.compose.foundation.layout.Column
14βˆ’import androidx.compose.foundation.layout.PaddingValues
15βˆ’import androidx.compose.foundation.layout.Row
16βˆ’import androidx.compose.foundation.layout.Spacer
17βˆ’import androidx.compose.foundation.layout.fillMaxSize
18βˆ’import androidx.compose.foundation.layout.fillMaxWidth
19βˆ’import androidx.compose.foundation.layout.height
20βˆ’import androidx.compose.foundation.layout.padding
21βˆ’import androidx.compose.foundation.layout.size
22βˆ’import androidx.compose.foundation.lazy.LazyColumn
23βˆ’import androidx.compose.foundation.lazy.items
24βˆ’import androidx.compose.foundation.lazy.rememberLazyListState
25βˆ’import androidx.compose.foundation.shape.CircleShape
26βˆ’import androidx.compose.material.icons.Icons
27βˆ’import androidx.compose.material.icons.automirrored.filled.ArrowBack
28βˆ’import androidx.compose.material.icons.filled.Add
29βˆ’import androidx.compose.material.icons.filled.ChatBubbleOutline
30βˆ’import androidx.compose.material.icons.filled.ChevronRight
31βˆ’import androidx.compose.material.icons.filled.DeleteOutline
32βˆ’import androidx.compose.material.icons.filled.Refresh
33βˆ’import androidx.compose.material3.AlertDialog
34βˆ’import androidx.compose.material3.CircularProgressIndicator
35βˆ’import androidx.compose.material3.ExperimentalMaterial3Api
36βˆ’import androidx.compose.material3.ExtendedFloatingActionButton
37βˆ’import androidx.compose.material3.HorizontalDivider
38βˆ’import androidx.compose.material3.Icon
39βˆ’import androidx.compose.material3.IconButton
40βˆ’import androidx.compose.material3.LinearProgressIndicator
41βˆ’import androidx.compose.material3.MaterialTheme
42βˆ’import androidx.compose.material3.OutlinedTextField
43βˆ’import androidx.compose.material3.Scaffold
44βˆ’import androidx.compose.material3.Surface
45βˆ’import androidx.compose.material3.SwipeToDismissBox
46βˆ’import androidx.compose.material3.SwipeToDismissBoxValue
47βˆ’import androidx.compose.material3.Text
48βˆ’import androidx.compose.material3.TextButton
49βˆ’import androidx.compose.material3.TopAppBar
50βˆ’import androidx.compose.material3.rememberSwipeToDismissBoxState
51βˆ’import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52βˆ’import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
53βˆ’import androidx.compose.runtime.Composable
54βˆ’import androidx.compose.runtime.LaunchedEffect
55βˆ’import androidx.compose.runtime.getValue
56βˆ’import androidx.compose.runtime.mutableStateOf
57βˆ’import androidx.compose.runtime.remember
58βˆ’import androidx.compose.runtime.setValue
59βˆ’import androidx.compose.ui.Alignment
60βˆ’import androidx.compose.ui.Modifier
61βˆ’import androidx.compose.ui.draw.clip
62βˆ’import androidx.compose.ui.graphics.Color
63βˆ’import androidx.compose.ui.hapticfeedback.HapticFeedbackType
64βˆ’import androidx.compose.ui.platform.LocalContext
65βˆ’import androidx.compose.ui.platform.LocalHapticFeedback
66βˆ’import androidx.compose.ui.text.style.TextAlign
67βˆ’import androidx.compose.ui.text.style.TextOverflow
68βˆ’import androidx.compose.ui.unit.dp
69βˆ’import soy.iko.opencode.core.Event
70βˆ’import soy.iko.opencode.core.SessionView
71βˆ’import soy.iko.opencode.core.UiState
72βˆ’
73βˆ’@OptIn(ExperimentalMaterial3Api::class)
74βˆ’@Composable
75βˆ’fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
76βˆ’ val haptic = LocalHapticFeedback.current
77βˆ’ var pendingDeleteId by remember { mutableStateOf<String?>(null) }
78βˆ’ var isPullRefreshing by remember { mutableStateOf(false) }
79βˆ’ var searchQuery by remember { mutableStateOf("") }
80βˆ’ val sessionsListState = rememberLazyListState()
81βˆ’
82βˆ’ // Bring a newly created session (which lands at the top of the list) into view.
83βˆ’ // Only scroll when the list actually grows, not on every reload of the same sessions.
84βˆ’ var prevSize by remember { mutableStateOf(state.sessions.size) }
85βˆ’ LaunchedEffect(state.sessions.size) {
86βˆ’ if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
87βˆ’ prevSize = state.sessions.size
88βˆ’ }
89βˆ’
90βˆ’ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
91βˆ’ // every loading operation. Clear it once the underlying load completes.
92βˆ’ LaunchedEffect(state.loading) {
93βˆ’ if (!state.loading) isPullRefreshing = false
94βˆ’ }
95βˆ’
96βˆ’ pendingDeleteId?.let { deleteId ->
97βˆ’ val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
98βˆ’ AlertDialog(
99βˆ’ onDismissRequest = { pendingDeleteId = null },
100βˆ’ title = { Text("Delete session?") },
101βˆ’ text = { Text(pendingSession?.title ?: deleteId) },
102βˆ’ confirmButton = {
103βˆ’ TextButton(onClick = {
104βˆ’ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
105βˆ’ dispatch(Event.DeleteSession(deleteId))
106βˆ’ pendingDeleteId = null
107βˆ’ }) {
108βˆ’ Text("Delete")
109βˆ’ }
110βˆ’ },
111βˆ’ dismissButton = {
112βˆ’ TextButton(onClick = { pendingDeleteId = null }) {
113βˆ’ Text("Cancel")
114βˆ’ }
115βˆ’ },
116βˆ’ )
117βˆ’ }
118βˆ’
119βˆ’ Scaffold(
120βˆ’ topBar = {
121βˆ’ TopAppBar(
122βˆ’ title = {
123βˆ’ Row(verticalAlignment = Alignment.CenterVertically) {
124βˆ’ // A quick at-a-glance health dot for the SSE stream: green when
125βˆ’ // connected, error-tinted when the stream is down (updates stall).
126βˆ’ Box(
127βˆ’ modifier = Modifier
128βˆ’ .size(8.dp)
129βˆ’ .clip(CircleShape)
130βˆ’ .background(
131βˆ’ if (state.sseConnected) Color(0xFF4CAF50)
132βˆ’ else MaterialTheme.colorScheme.error,
133βˆ’ ),
134βˆ’ )
135βˆ’ Spacer(Modifier.size(8.dp))
136βˆ’ Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions")
137βˆ’ }
138βˆ’ },
139βˆ’ navigationIcon = {
140βˆ’ IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141βˆ’ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
142βˆ’ }
143βˆ’ },
144βˆ’ actions = {
145βˆ’ IconButton(onClick = { dispatch(Event.LoadSessions) }) {
146βˆ’ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
147βˆ’ }
148βˆ’ },
149βˆ’ )
150βˆ’ },
151βˆ’ floatingActionButton = {
152βˆ’ // Give the search results the full width while searching β€” the FAB would
153βˆ’ // otherwise cover the bottom rows and isn't relevant mid-search anyway.
154βˆ’ if (searchQuery.isEmpty()) {
155βˆ’ ExtendedFloatingActionButton(
156βˆ’ onClick = { dispatch(Event.CreateSession) },
157βˆ’ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
158βˆ’ text = { Text("New session") },
159βˆ’ )
160βˆ’ }
161βˆ’ },
162βˆ’ ) { padding ->
163βˆ’ Column(Modifier.fillMaxSize().padding(padding)) {
164βˆ’ if (!state.sseConnected) {
165βˆ’ ReconnectingBanner()
166βˆ’ }
167βˆ’ if (state.loading && !isPullRefreshing) {
168βˆ’ LinearProgressIndicator(Modifier.fillMaxWidth())
169βˆ’ }
170βˆ’ if (state.sessions.isNotEmpty()) {
171βˆ’ OutlinedTextField(
172βˆ’ value = searchQuery,
173βˆ’ onValueChange = { searchQuery = it },
174βˆ’ placeholder = { Text("Search sessions") },
175βˆ’ singleLine = true,
176βˆ’ modifier = Modifier
177βˆ’ .fillMaxWidth()
178βˆ’ .padding(horizontal = 16.dp, vertical = 8.dp),
179βˆ’ )
180βˆ’ }
181βˆ’ val filteredSessions = if (searchQuery.isEmpty()) {
182βˆ’ state.sessions
183βˆ’ } else {
184βˆ’ state.sessions.filter {
185βˆ’ it.title.contains(searchQuery, ignoreCase = true) ||
186βˆ’ it.id.contains(searchQuery, ignoreCase = true)
187βˆ’ }
188βˆ’ }
189βˆ’ when {
190βˆ’ state.sessions.isEmpty() && !state.loading -> EmptySessions()
191βˆ’ filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
192βˆ’ else -> PullToRefreshBox(
193βˆ’ isRefreshing = isPullRefreshing,
194βˆ’ onRefresh = {
195βˆ’ isPullRefreshing = true
196βˆ’ dispatch(Event.LoadSessions)
197βˆ’ },
198βˆ’ modifier = Modifier.fillMaxSize(),
199βˆ’ state = rememberPullToRefreshState(),
200βˆ’ ) {
201βˆ’ LazyColumn(
202βˆ’ state = sessionsListState,
203βˆ’ modifier = Modifier.fillMaxSize(),
204βˆ’ contentPadding = PaddingValues(bottom = 88.dp),
205βˆ’ ) {
206βˆ’ items(filteredSessions, key = { it.id }) { session ->
207βˆ’ // animateItem() slides the remaining rows into place when one is
208βˆ’ // deleted (and fades new rows in) instead of snapping instantly.
209βˆ’ Column(modifier = Modifier.animateItem()) {
210βˆ’ val dismissState = rememberSwipeToDismissBoxState(
211βˆ’ // Only a swipe from the end (right-to-left) triggers deletion;
212βˆ’ // return false so the row snaps back and the confirm dialog decides.
213βˆ’ confirmValueChange = { value ->
214βˆ’ if (value == SwipeToDismissBoxValue.EndToStart) {
215βˆ’ pendingDeleteId = session.id
216βˆ’ }
217βˆ’ false
218βˆ’ },
219βˆ’ )
220βˆ’ SwipeToDismissBox(
221βˆ’ state = dismissState,
222βˆ’ enableDismissFromStartToEnd = false,
223βˆ’ backgroundContent = { SwipeDeleteBackground() },
224βˆ’ ) {
225βˆ’ SessionRow(
226βˆ’ session = session,
227βˆ’ onOpen = { dispatch(Event.SelectSession(session.id)) },
228βˆ’ onDelete = { pendingDeleteId = session.id },
229βˆ’ )
230βˆ’ }
231βˆ’ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
232βˆ’ }
233βˆ’ }
234βˆ’ }
235βˆ’ }
236βˆ’ }
237βˆ’ }
238βˆ’ }
239βˆ’}
240βˆ’
241βˆ’@OptIn(ExperimentalFoundationApi::class)
242βˆ’@Composable
243βˆ’private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
244βˆ’ val context = LocalContext.current
245βˆ’ Row(
246βˆ’ modifier = Modifier
247βˆ’ .fillMaxWidth()
248βˆ’ .background(MaterialTheme.colorScheme.surface)
249βˆ’ .combinedClickable(
250βˆ’ onClick = onOpen,
251βˆ’ onLongClick = {
252βˆ’ copyToClipboard(context, session.id)
253βˆ’ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
254βˆ’ },
255βˆ’ )
256βˆ’ .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
257βˆ’ verticalAlignment = Alignment.CenterVertically,
258βˆ’ ) {
259βˆ’ Icon(
260βˆ’ Icons.Filled.ChatBubbleOutline,
261βˆ’ contentDescription = null,
262βˆ’ tint = MaterialTheme.colorScheme.primary,
263βˆ’ modifier = Modifier.padding(end = 16.dp),
264βˆ’ )
265βˆ’ Column(Modifier.weight(1f)) {
266βˆ’ Text(
267βˆ’ text = session.title,
268βˆ’ style = MaterialTheme.typography.bodyLarge,
269βˆ’ maxLines = 1,
270βˆ’ overflow = TextOverflow.Ellipsis,
271βˆ’ )
272βˆ’ Text(
273βˆ’ text = shortSessionId(session.id),
274βˆ’ style = MaterialTheme.typography.bodySmall,
275βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
276βˆ’ maxLines = 1,
277βˆ’ overflow = TextOverflow.Ellipsis,
278βˆ’ )
279βˆ’ }
280βˆ’ Icon(
281βˆ’ Icons.Filled.ChevronRight,
282βˆ’ contentDescription = null,
283βˆ’ tint = MaterialTheme.colorScheme.onSurfaceVariant,
284βˆ’ modifier = Modifier.padding(horizontal = 4.dp),
285βˆ’ )
286βˆ’ IconButton(onClick = onDelete) {
287βˆ’ Icon(
288βˆ’ Icons.Filled.DeleteOutline,
289βˆ’ contentDescription = "Delete session",
290βˆ’ tint = MaterialTheme.colorScheme.error,
291βˆ’ )
292βˆ’ }
293βˆ’ }
294βˆ’}
295βˆ’
296βˆ’/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
297βˆ’@Composable
298βˆ’private fun SwipeDeleteBackground() {
299βˆ’ Box(
300βˆ’ modifier = Modifier
301βˆ’ .fillMaxSize()
302βˆ’ .background(MaterialTheme.colorScheme.error)
303βˆ’ .padding(horizontal = 24.dp),
304βˆ’ contentAlignment = Alignment.CenterEnd,
305βˆ’ ) {
306βˆ’ Row(verticalAlignment = Alignment.CenterVertically) {
307βˆ’ Icon(
308βˆ’ Icons.Filled.DeleteOutline,
309βˆ’ contentDescription = null,
310βˆ’ tint = MaterialTheme.colorScheme.onError,
311βˆ’ )
312βˆ’ Text(
313βˆ’ text = "Delete",
314βˆ’ style = MaterialTheme.typography.labelLarge,
315βˆ’ color = MaterialTheme.colorScheme.onError,
316βˆ’ modifier = Modifier.padding(start = 4.dp),
317βˆ’ )
318βˆ’ }
319βˆ’ }
320βˆ’}
321βˆ’
322βˆ’/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
323βˆ’@Composable
324βˆ’private fun ReconnectingBanner() {
325βˆ’ Surface(
326βˆ’ modifier = Modifier.fillMaxWidth(),
327βˆ’ color = MaterialTheme.colorScheme.tertiaryContainer,
328βˆ’ ) {
329βˆ’ Row(
330βˆ’ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
331βˆ’ verticalAlignment = Alignment.CenterVertically,
332βˆ’ horizontalArrangement = Arrangement.spacedBy(8.dp),
333βˆ’ ) {
334βˆ’ CircularProgressIndicator(
335βˆ’ modifier = Modifier.size(14.dp),
336βˆ’ strokeWidth = 2.dp,
337βˆ’ color = MaterialTheme.colorScheme.onTertiaryContainer,
338βˆ’ )
339βˆ’ Text(
340βˆ’ text = "Reconnecting…",
341βˆ’ style = MaterialTheme.typography.bodySmall,
342βˆ’ color = MaterialTheme.colorScheme.onTertiaryContainer,
343βˆ’ )
344βˆ’ }
345βˆ’ }
346βˆ’}
347βˆ’
348βˆ’private fun copyToClipboard(context: Context, text: String) {
349βˆ’ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
350βˆ’ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
351βˆ’}
352βˆ’
353βˆ’/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
354βˆ’private fun shortSessionId(id: String): String =
355βˆ’ if (id.length > 12) id.take(8) + "…" else id
356βˆ’
357βˆ’@Composable
358βˆ’private fun NoSearchResults(query: String) {
359βˆ’ Box(
360βˆ’ modifier = Modifier.fillMaxSize().padding(32.dp),
361βˆ’ contentAlignment = Alignment.Center,
362βˆ’ ) {
363βˆ’ Text(
364βˆ’ text = "No results for β€œ$query”",
365βˆ’ style = MaterialTheme.typography.bodyMedium,
366βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
367βˆ’ )
368βˆ’ }
369βˆ’}
370βˆ’
371βˆ’@Composable
372βˆ’private fun EmptySessions() {
373βˆ’ Column(
374βˆ’ modifier = Modifier.fillMaxSize().padding(32.dp),
375βˆ’ verticalArrangement = Arrangement.Center,
376βˆ’ horizontalAlignment = Alignment.CenterHorizontally,
377βˆ’ ) {
378βˆ’ Icon(
379βˆ’ Icons.Filled.ChatBubbleOutline,
380βˆ’ contentDescription = null,
381βˆ’ modifier = Modifier.size(64.dp),
382βˆ’ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
383βˆ’ )
384βˆ’ Spacer(Modifier.height(16.dp))
385βˆ’ Text(
386βˆ’ text = "No sessions yet",
387βˆ’ style = MaterialTheme.typography.titleMedium,
388βˆ’ )
389βˆ’ Text(
390βˆ’ text = "Tap β€œNew session” to start chatting.",
391βˆ’ style = MaterialTheme.typography.bodyMedium,
392βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
393βˆ’ )
394βˆ’ Spacer(Modifier.height(8.dp))
395βˆ’ Text(
396βˆ’ text = "Sessions you create on this server will appear here.",
397βˆ’ style = MaterialTheme.typography.bodySmall,
398βˆ’ color = MaterialTheme.colorScheme.onSurfaceVariant,
399βˆ’ textAlign = TextAlign.Center,
400βˆ’ )
401βˆ’ }
402βˆ’}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+356
@@ -0,0 +1,356 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
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.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.text.KeyboardActions
17+import androidx.compose.foundation.text.KeyboardOptions
18+import androidx.compose.foundation.verticalScroll
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.Visibility
22+import androidx.compose.material.icons.filled.VisibilityOff
23+import androidx.compose.material3.AlertDialog
24+import androidx.compose.material3.Button
25+import androidx.compose.material3.CircularProgressIndicator
26+import androidx.compose.material3.ExperimentalMaterial3Api
27+import androidx.compose.material3.Icon
28+import androidx.compose.material3.IconButton
29+import androidx.compose.material3.MaterialTheme
30+import androidx.compose.material3.OutlinedButton
31+import androidx.compose.material3.OutlinedTextField
32+import androidx.compose.material3.Scaffold
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.getValue
38+import androidx.compose.runtime.mutableStateOf
39+import androidx.compose.runtime.remember
40+import androidx.compose.runtime.saveable.rememberSaveable
41+import androidx.compose.runtime.setValue
42+import androidx.compose.ui.Alignment
43+import androidx.compose.ui.Modifier
44+import androidx.compose.ui.platform.testTag
45+import androidx.compose.ui.semantics.contentDescription
46+import androidx.compose.ui.semantics.semantics
47+import androidx.compose.ui.text.input.ImeAction
48+import androidx.compose.ui.text.input.KeyboardType
49+import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
51+import androidx.compose.ui.res.stringResource
52+import androidx.compose.ui.unit.dp
53+import androidx.lifecycle.compose.collectAsStateWithLifecycle
54+import androidx.lifecycle.viewmodel.compose.viewModel
55+import soy.iko.opencode.di.AppContainer
56+import soy.iko.opencode.R
57+import soy.iko.opencode.ui.vmFactory
58+
59+@OptIn(ExperimentalMaterial3Api::class)
60+@Composable
61+fun ServerEditScreen(
62+ container: AppContainer,
63+ profileId: String?,
64+ onDone: () -> Unit,
65+ sourceId: String? = null,
66+) {
67+ val vm: ServerEditViewModel =
68+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
69+ val state by vm.state.collectAsStateWithLifecycle()
70+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
71+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
72+
73+ fun safeExit() {
74+ when {
75+ // A save is in flight: ignore the back gesture (matching the BackHandler guard
76+ // below). Popping here would cancel the save's viewModelScope coroutine and
77+ // silently abandon the write; the VM calls onDone itself once the save lands.
78+ state.saving -> Unit
79+ state.isDirty -> showDiscardConfirm = true
80+ else -> onDone()
81+ }
82+ }
83+
84+ // Intercept the system back gesture when the form has unsaved changes so the user
85+ // gets a chance to keep editing instead of losing their work silently.
86+ BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
87+ showDiscardConfirm = true
88+ }
89+
90+ Scaffold(
91+ topBar = {
92+ TopAppBar(
93+ title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
94+ navigationIcon = {
95+ IconButton(onClick = ::safeExit) {
96+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
97+ }
98+ },
99+ )
100+ },
101+ ) { padding ->
102+ if (!state.loaded) {
103+ val loadingLabel = stringResource(R.string.loading)
104+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
105+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
106+ }
107+ } else {
108+ ServerEditForm(
109+ state = state,
110+ passwordVisible = passwordVisible,
111+ onTogglePassword = { passwordVisible = !passwordVisible },
112+ padding = padding,
113+ vm = vm,
114+ onDone = onDone,
115+ )
116+ }
117+ }
118+
119+ if (showDiscardConfirm) {
120+ AlertDialog(
121+ onDismissRequest = { showDiscardConfirm = false },
122+ title = { Text(stringResource(R.string.discard_title)) },
123+ text = { Text(stringResource(R.string.discard_text)) },
124+ confirmButton = {
125+ TextButton(onClick = {
126+ showDiscardConfirm = false
127+ onDone()
128+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
129+ },
130+ dismissButton = {
131+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
132+ },
133+ )
134+ }
135+}
136+
137+@Composable
138+private fun ServerEditForm(
139+ state: ServerEditState,
140+ passwordVisible: Boolean,
141+ onTogglePassword: () -> Unit,
142+ padding: androidx.compose.foundation.layout.PaddingValues,
143+ vm: ServerEditViewModel,
144+ onDone: () -> Unit,
145+) {
146+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
147+ // canSave parses the base URL with java.net.URI (which throws β€” and fills a stack trace β€”
148+ // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
149+ // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
150+ val canSave = remember(state.baseUrl) { state.canSave }
151+ Column(
152+ modifier = Modifier
153+ .padding(padding)
154+ .imePadding()
155+ .widthIn(max = 600.dp)
156+ .padding(16.dp)
157+ .verticalScroll(rememberScrollState()),
158+ verticalArrangement = Arrangement.spacedBy(12.dp),
159+ ) {
160+ OutlinedTextField(
161+ value = state.label,
162+ onValueChange = { v -> vm.update { it.copy(label = v) } },
163+ label = { Text(stringResource(R.string.label_optional)) },
164+ singleLine = true,
165+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
166+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
167+ )
168+ OutlinedTextField(
169+ value = state.baseUrl,
170+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
171+ label = { Text(stringResource(R.string.base_url)) },
172+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
173+ singleLine = true,
174+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
175+ supportingText = {
176+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
177+ // Offer a one-tap "http://" fix for bare host:port input instead of a
178+ // generic error, so the user doesn't have to know the URL needs a scheme.
179+ val suggestion = suggestUrlScheme(state.baseUrl)
180+ if (suggestion != null) {
181+ TextButton(
182+ onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
183+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
184+ ) {
185+ Text(stringResource(R.string.suggest_scheme))
186+ }
187+ } else {
188+ Text(stringResource(R.string.invalid_url))
189+ }
190+ }
191+ },
192+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
193+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
194+ )
195+ OutlinedButton(
196+ onClick = { vm.probe() },
197+ enabled = canSave && !state.probing && !state.saving,
198+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
199+ ) {
200+ if (state.probing) {
201+ CircularProgressIndicator(
202+ modifier = Modifier.size(20.dp),
203+ strokeWidth = 2.dp,
204+ )
205+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
206+ } else {
207+ Text(stringResource(R.string.check_connectivity))
208+ }
209+ }
210+ // Positive feedback when the probe found the server reachable without auth, so
211+ // the user knows the connection works before saving (closes the loop that the
212+ // prior "silently does nothing" Reachable path left open).
213+ if (state.probeReachable == true && !state.authFieldsVisible) {
214+ Text(
215+ stringResource(R.string.server_reachable),
216+ style = MaterialTheme.typography.bodySmall,
217+ color = MaterialTheme.colorScheme.primary,
218+ modifier = Modifier.fillMaxWidth(),
219+ )
220+ }
221+ if (!state.authFieldsVisible) {
222+ Text(
223+ stringResource(R.string.auth_help),
224+ style = MaterialTheme.typography.bodySmall,
225+ color = MaterialTheme.colorScheme.onSurfaceVariant,
226+ modifier = Modifier.fillMaxWidth(),
227+ )
228+ }
229+ AnimatedVisibility(visible = state.authFieldsVisible) {
230+ AuthFields(
231+ state = state,
232+ passwordVisible = passwordVisible,
233+ onTogglePassword = onTogglePassword,
234+ onUpdate = vm::update,
235+ onTestCredentials = vm::testCredentials,
236+ onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
237+ )
238+ }
239+ Row(
240+ modifier = Modifier.fillMaxWidth(),
241+ horizontalArrangement = Arrangement.spacedBy(12.dp),
242+ ) {
243+ OutlinedButton(
244+ onClick = {
245+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
246+ vm.save(onDone)
247+ },
248+ enabled = canSave && !state.saving,
249+ modifier = Modifier.weight(1f),
250+ ) {
251+ if (state.saving) {
252+ CircularProgressIndicator(
253+ modifier = Modifier.size(20.dp),
254+ color = MaterialTheme.colorScheme.primary,
255+ strokeWidth = 2.dp,
256+ )
257+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
258+ } else {
259+ Text(stringResource(R.string.save))
260+ }
261+ }
262+ Button(
263+ onClick = {
264+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
265+ vm.saveAndConnect(onDone)
266+ },
267+ enabled = canSave && !state.saving,
268+ modifier = Modifier.weight(1f),
269+ ) {
270+ Text(stringResource(R.string.save_and_connect))
271+ }
272+ }
273+ state.error?.let { message ->
274+ Text(
275+ message,
276+ color = MaterialTheme.colorScheme.error,
277+ style = MaterialTheme.typography.bodySmall,
278+ modifier = Modifier.fillMaxWidth(),
279+ )
280+ }
281+ }
282+}
283+
284+@Composable
285+private fun AuthFields(
286+ state: ServerEditState,
287+ passwordVisible: Boolean,
288+ onTogglePassword: () -> Unit,
289+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
290+ onTestCredentials: () -> Unit,
291+ onImeDone: () -> Unit,
292+) {
293+ val showPasswordLabel = stringResource(R.string.show_password)
294+ val hidePasswordLabel = stringResource(R.string.hide_password)
295+ // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
296+ // every keystroke's recomposition (see ServerEditForm).
297+ val canSave = remember(state.baseUrl) { state.canSave }
298+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
299+ Text(
300+ stringResource(R.string.auth_required),
301+ style = MaterialTheme.typography.bodySmall,
302+ color = MaterialTheme.colorScheme.error,
303+ )
304+ OutlinedTextField(
305+ value = state.username,
306+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
307+ label = { Text(stringResource(R.string.username_optional)) },
308+ singleLine = true,
309+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
310+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
311+ )
312+ OutlinedTextField(
313+ value = state.password,
314+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
315+ label = { Text(stringResource(R.string.password_optional)) },
316+ singleLine = true,
317+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
318+ trailingIcon = {
319+ IconButton(onClick = onTogglePassword) {
320+ Icon(
321+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
322+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
323+ )
324+ }
325+ },
326+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
327+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
328+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
329+ )
330+ OutlinedButton(
331+ onClick = onTestCredentials,
332+ // Require at least one credential field: testCredentials() no-ops when both are
333+ // blank, so without this the button is tappable but silently does nothing.
334+ enabled = canSave && !state.testingCredentials && !state.saving &&
335+ (state.username.isNotBlank() || state.password.isNotBlank()),
336+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
337+ ) {
338+ if (state.testingCredentials) {
339+ CircularProgressIndicator(
340+ modifier = Modifier.size(20.dp),
341+ strokeWidth = 2.dp,
342+ )
343+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
344+ } else {
345+ Text(stringResource(R.string.test_credentials))
346+ }
347+ }
348+ state.credentialsResult?.let { ok ->
349+ Text(
350+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
351+ style = MaterialTheme.typography.bodySmall,
352+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
353+ )
354+ }
355+ }
356+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+286
@@ -0,0 +1,286 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.network.NetworkConfig
8+import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.di.ProbeResult
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.first
14+import kotlinx.coroutines.flow.update
15+import kotlinx.coroutines.launch
16+import kotlinx.coroutines.withTimeoutOrNull
17+import soy.iko.opencode.R
18+import soy.iko.opencode.util.runCatchingCancellable
19+import java.util.UUID
20+
21+@Immutable
22+data class ServerEditState(
23+ val id: String? = null,
24+ val label: String = "",
25+ val baseUrl: String = "",
26+ val username: String = "",
27+ val password: String = "",
28+ val loaded: Boolean = false,
29+ val saving: Boolean = false,
30+ val error: String? = null,
31+ /** Whether the auth (username/password) fields are shown. */
32+ val authFieldsVisible: Boolean = false,
33+ /** Whether a connectivity/auth probe is in progress. */
34+ val probing: Boolean = false,
35+ /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
36+ val initial: InitialProfile? = null,
37+ /** Whether a credential test is in progress. */
38+ val testingCredentials: Boolean = false,
39+ /** Non-null when the last credential test succeeded (true) or failed (false). */
40+ val credentialsResult: Boolean? = null,
41+ /** True when the last connectivity probe found the server reachable without auth.
42+ * Null until a probe runs, and cleared when the base URL is edited. */
43+ val probeReachable: Boolean? = null,
44+) {
45+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
46+ val isNew: Boolean get() = id == null
47+ /** True if any field differs from its loaded value. */
48+ val isDirty: Boolean
49+ get() {
50+ val init = initial ?: return false
51+ return label.trim() != init.label ||
52+ baseUrl.trim() != init.baseUrl ||
53+ username.trim() != init.username ||
54+ password.trim() != init.password
55+ }
56+}
57+
58+/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
59+data class InitialProfile(
60+ val label: String,
61+ val baseUrl: String,
62+ val username: String,
63+ val password: String,
64+)
65+
66+fun isValidUrl(url: String): Boolean = try {
67+ val u = java.net.URI(url.trim())
68+ u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
69+} catch (_: Exception) {
70+ false
71+}
72+
73+/**
74+ * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
75+ * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
76+ * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
77+ */
78+fun suggestUrlScheme(input: String): String? {
79+ val trimmed = input.trim()
80+ if (trimmed.isEmpty() || "://" in trimmed) return null
81+ val candidate = "http://$trimmed"
82+ return if (isValidUrl(candidate)) candidate else null
83+}
84+
85+class ServerEditViewModel(
86+ private val container: AppContainer,
87+ private val profileId: String?,
88+ private val sourceId: String? = null,
89+) : ViewModel() {
90+
91+ private val _state = MutableStateFlow(ServerEditState(id = profileId))
92+ val state: StateFlow<ServerEditState> = _state.asStateFlow()
93+
94+ init {
95+ viewModelScope.launch {
96+ if (profileId != null) {
97+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
98+ container.profileStore.profiles
99+ .first { list -> list.any { p -> p.id == profileId } }
100+ .firstOrNull { it.id == profileId }
101+ }
102+ if (existing != null) {
103+ val init = InitialProfile(
104+ label = existing.label,
105+ baseUrl = existing.baseUrl,
106+ username = existing.username.orEmpty(),
107+ password = existing.password.orEmpty(),
108+ )
109+ _state.value = ServerEditState(
110+ id = existing.id,
111+ label = existing.label,
112+ baseUrl = existing.baseUrl,
113+ username = existing.username.orEmpty(),
114+ password = existing.password.orEmpty(),
115+ loaded = true,
116+ authFieldsVisible = existing.hasAuth,
117+ initial = init,
118+ )
119+ return@launch
120+ }
121+ _state.value = ServerEditState(
122+ id = profileId,
123+ loaded = true,
124+ error = container.string(R.string.error_load_timeout),
125+ )
126+ return@launch
127+ }
128+ // Duplicate: seed the form from an existing profile but as a NEW profile
129+ // (id stays null so save() generates a fresh one). Appends " (copy)" to the
130+ // label so the user can tell the duplicate from the original at a glance.
131+ if (sourceId != null) {
132+ val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133+ container.profileStore.profiles
134+ .first { list -> list.any { p -> p.id == sourceId } }
135+ .firstOrNull { it.id == sourceId }
136+ }
137+ if (source != null) {
138+ val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
139+ _state.value = ServerEditState(
140+ id = null,
141+ label = dupLabel,
142+ baseUrl = source.baseUrl,
143+ username = source.username.orEmpty(),
144+ password = source.password.orEmpty(),
145+ loaded = true,
146+ authFieldsVisible = source.hasAuth,
147+ // Snapshot the seeded (copy) values so an untouched duplicate isn't
148+ // immediately dirty β€” an empty initial would make backing out of a
149+ // freshly opened duplicate always trigger the discard-changes dialog.
150+ initial = InitialProfile(
151+ label = dupLabel.trim(),
152+ baseUrl = source.baseUrl.trim(),
153+ username = source.username.orEmpty().trim(),
154+ password = source.password.orEmpty().trim(),
155+ ),
156+ )
157+ return@launch
158+ }
159+ // Source not found β€” fall through to a blank new-profile form.
160+ }
161+ // New-profile form: seed an initial snapshot of empty values so isDirty
162+ // becomes true the moment the user types anything.
163+ _state.update {
164+ it.copy(
165+ loaded = true,
166+ initial = InitialProfile("", "", "", ""),
167+ )
168+ }
169+ }
170+ }
171+
172+ fun update(transform: (ServerEditState) -> ServerEditState) {
173+ // Clear both the inline credential note and the bottom-of-form error when any field
174+ // changes: once the user starts editing (e.g. correcting a rejected password), a stale
175+ // "Credentials rejected" / test-failure message no longer describes the current input.
176+ _state.update { transform(it).copy(credentialsResult = null, error = null) }
177+ }
178+
179+ fun probe() {
180+ val s = _state.value
181+ if (!s.canSave || s.probing) return
182+ _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
183+ viewModelScope.launch {
184+ val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
185+ result.onSuccess { pr ->
186+ _state.update {
187+ when (pr) {
188+ is ProbeResult.Reachable -> it.copy(
189+ probing = false,
190+ authFieldsVisible = false,
191+ error = null,
192+ probeReachable = true,
193+ )
194+ is ProbeResult.NeedsAuth -> it.copy(
195+ probing = false,
196+ authFieldsVisible = true,
197+ error = null,
198+ probeReachable = false,
199+ )
200+ is ProbeResult.Unreachable -> it.copy(
201+ probing = false,
202+ authFieldsVisible = false,
203+ error = pr.error,
204+ probeReachable = false,
205+ )
206+ }
207+ }
208+ }.onFailure { e ->
209+ _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
210+ }
211+ }
212+ }
213+
214+ /**
215+ * Probe the server *with* the entered credentials to validate them before saving.
216+ * Only meaningful once auth fields are visible (the server requires auth); calling
217+ * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
218+ * so the UI can show success/failure without dismissing the form.
219+ */
220+ fun testCredentials() {
221+ val s = _state.value
222+ if (!s.canSave || s.testingCredentials) return
223+ if (s.username.isBlank() && s.password.isBlank()) return
224+ _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
225+ viewModelScope.launch {
226+ val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
227+ result.onSuccess { ok ->
228+ _state.update {
229+ it.copy(
230+ testingCredentials = false,
231+ credentialsResult = ok,
232+ error = if (ok) null else container.string(R.string.credentials_rejected),
233+ )
234+ }
235+ }.onFailure { e ->
236+ _state.update {
237+ it.copy(
238+ testingCredentials = false,
239+ credentialsResult = false,
240+ error = container.friendlyError(e),
241+ )
242+ }
243+ }
244+ }
245+ }
246+
247+ fun save(onDone: () -> Unit) {
248+ saveInternal(connectAfter = false, onDone = onDone)
249+ }
250+
251+ /** Save and then connect to the saved profile in one step. */
252+ fun saveAndConnect(onDone: () -> Unit) {
253+ saveInternal(connectAfter = true, onDone = onDone)
254+ }
255+
256+ private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
257+ val s = _state.value
258+ if (!s.canSave || s.saving) return
259+ _state.update { it.copy(saving = true) }
260+ viewModelScope.launch {
261+ val result = runCatchingCancellable {
262+ val existingLastUsed = if (s.id != null) {
263+ withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
264+ container.profileStore.profiles.first()
265+ .firstOrNull { it.id == s.id }?.lastUsed
266+ } ?: 0L
267+ } else 0L
268+ val saved = ServerProfile(
269+ id = s.id ?: UUID.randomUUID().toString(),
270+ label = s.label.trim(),
271+ baseUrl = s.baseUrl.trim(),
272+ username = s.username.trim().takeIf { it.isNotBlank() },
273+ password = s.password.trim().takeIf { it.isNotEmpty() },
274+ lastUsed = existingLastUsed,
275+ )
276+ container.profileStore.save(saved)
277+ if (connectAfter) {
278+ container.connect(saved)
279+ container.activeConnection.value?.api?.ping()
280+ }
281+ }
282+ result.onSuccess { onDone() }
283+ .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
284+ }
285+ }
286+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+455
@@ -0,0 +1,455 @@
1+package soy.iko.opencode.ui.server
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.Row
9+import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Delete
20+import androidx.compose.material.icons.filled.Dns
21+import androidx.compose.material.icons.filled.MoreVert
22+import androidx.compose.material3.AlertDialog
23+import androidx.compose.material3.Card
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
27+import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.FloatingActionButton
29+import androidx.compose.material3.Icon
30+import androidx.compose.material3.IconButton
31+import androidx.compose.material3.LinearProgressIndicator
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarDuration
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.SnackbarResult
38+import androidx.compose.material3.SwipeToDismissBox
39+import androidx.compose.material3.SwipeToDismissBoxValue
40+import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
42+import androidx.compose.material3.TopAppBar
43+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
44+import androidx.compose.material3.rememberSwipeToDismissBoxState
45+import androidx.compose.runtime.Composable
46+import androidx.compose.runtime.LaunchedEffect
47+import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableStateOf
49+import androidx.compose.runtime.remember
50+import androidx.compose.runtime.saveable.rememberSaveable
51+import androidx.compose.runtime.setValue
52+import androidx.compose.ui.Alignment
53+import androidx.compose.ui.Modifier
54+import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalHapticFeedback
56+import androidx.compose.ui.platform.testTag
57+import androidx.compose.ui.semantics.Role
58+import androidx.compose.ui.semantics.contentDescription
59+import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.text.style.TextOverflow
61+import androidx.compose.ui.unit.dp
62+import androidx.compose.ui.res.stringResource
63+import androidx.lifecycle.compose.collectAsStateWithLifecycle
64+import androidx.lifecycle.viewmodel.compose.viewModel
65+import kotlinx.coroutines.coroutineScope
66+import kotlinx.coroutines.flow.collectLatest
67+import kotlinx.coroutines.delay
68+import kotlinx.coroutines.launch
69+import soy.iko.opencode.data.model.ServerProfile
70+import soy.iko.opencode.data.network.NetworkConfig
71+import soy.iko.opencode.di.AppContainer
72+import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.rememberRelativeTime
74+import soy.iko.opencode.ui.vmFactory
75+
76+@OptIn(ExperimentalMaterial3Api::class)
77+@Composable
78+fun ServerListScreen(
79+ container: AppContainer,
80+ onConnected: () -> Unit,
81+ onAddProfile: () -> Unit,
82+ onEditProfile: (String) -> Unit,
83+ onDuplicateProfile: (String) -> Unit,
84+) {
85+ val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
86+ val profiles by vm.profiles.collectAsStateWithLifecycle()
87+ val connectingId by vm.connectingId.collectAsStateWithLifecycle()
88+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
89+ val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
90+ val haptics = LocalHapticFeedback.current
91+ val snackbar = remember { SnackbarHostState() }
92+ val retryLabel = stringResource(R.string.retry)
93+ val undoLabel = stringResource(R.string.undo)
94+ val serverRemovedLabel = stringResource(R.string.server_removed)
95+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
96+ val connectedId = activeConnection?.profile?.id
97+
98+ LaunchedEffect(Unit) {
99+ vm.errorEvents.collect { event ->
100+ val result = if (event.profile != null) {
101+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
102+ } else {
103+ snackbar.showSnackbar(event.message)
104+ }
105+ if (result == SnackbarResult.ActionPerformed && event.profile != null) {
106+ vm.connect(event.profile, onConnected)
107+ }
108+ }
109+ }
110+
111+ // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
112+ // action is taken before the delay expires, the profile is kept and the delete
113+ // never fires β€” mirrors the session list's undo UX for consistency. Indefinite + a
114+ // matching timed dismiss keeps the Undo button visible for exactly the
115+ // undoServerDeleteDelayMs window (5s) and no longer: a fixed SnackbarDuration.Long
116+ // (~10s) outlasted the window, leaving the button on screen but dead for its
117+ // second half. Mirrors DiagnosticsScreen's undo pattern.
118+ LaunchedEffect(Unit) {
119+ // collectLatest (not collect): a serialized collect queues each snackbar behind
120+ // the previous one's full window, so under rapid deletes a later profile's VM
121+ // delete timer (started at emit time) fires before its snackbar is ever shown β€”
122+ // a dead Undo button. collectLatest instead cancels the current snackbar and
123+ // shows the newest immediately, keeping its Undo window aligned with its timer.
124+ vm.undoEvents.collectLatest { profileId ->
125+ coroutineScope {
126+ val dismisser = launch {
127+ delay(NetworkConfig.undoServerDeleteDelayMs)
128+ snackbar.currentSnackbarData?.dismiss()
129+ }
130+ val result = snackbar.showSnackbar(
131+ message = serverRemovedLabel,
132+ actionLabel = undoLabel,
133+ duration = SnackbarDuration.Indefinite,
134+ )
135+ dismisser.cancel()
136+ if (result == SnackbarResult.ActionPerformed) {
137+ vm.undoDelete(profileId)
138+ }
139+ }
140+ }
141+ }
142+
143+ // On cold start the container auto-reconnects to the last server; once it lands,
144+ // skip this screen and go straight to the session list. Consumed once so a later
145+ // manual visit to this screen doesn't bounce the user back out.
146+ LaunchedEffect(Unit) {
147+ container.autoConnectDone.collect { succeeded ->
148+ if (succeeded && container.consumeAutoConnect()) onConnected()
149+ }
150+ }
151+
152+ Scaffold(
153+ topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
154+ snackbarHost = { SnackbarHost(snackbar) },
155+ floatingActionButton = {
156+ FloatingActionButton(onClick = onAddProfile) {
157+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
158+ }
159+ },
160+ ) { padding ->
161+ Column(modifier = Modifier.fillMaxSize()) {
162+ if (reconnecting && profiles.isNotEmpty()) {
163+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
164+ }
165+ if (profiles.isEmpty()) {
166+ EmptyServers(
167+ onAdd = onAddProfile,
168+ modifier = Modifier.fillMaxSize().padding(padding),
169+ )
170+ } else {
171+ PullToRefreshBox(
172+ isRefreshing = connectingId != null,
173+ onRefresh = { vm.refresh(onConnected) },
174+ modifier = Modifier.fillMaxSize(),
175+ ) {
176+ LazyColumn(
177+ modifier = Modifier.fillMaxSize().padding(padding),
178+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
179+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
180+ ),
181+ verticalArrangement = Arrangement.spacedBy(12.dp),
182+ ) {
183+ items(profiles, key = { it.id }) { profile ->
184+ val isActive = profile.id == connectedId
185+ if (isActive) {
186+ // The active server can't be swipe-deleted (deleting it also
187+ // disconnects, which deserves an explicit tap, not an
188+ // accidental swipe). Render a plain card without the swipe
189+ // affordance for the active row.
190+ Card(
191+ modifier = Modifier
192+ .fillMaxWidth()
193+ .animateItem()
194+ .testTag("server_card")
195+ .clickable(enabled = connectingId == null, role = Role.Button) {
196+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
197+ vm.connect(profile, onConnected)
198+ },
199+ ) {
200+ Row(
201+ modifier = Modifier.fillMaxWidth().padding(16.dp),
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Column(modifier = Modifier.weight(1f)) {
205+ Text(
206+ profile.displayLabel,
207+ style = MaterialTheme.typography.titleMedium,
208+ maxLines = 1,
209+ overflow = TextOverflow.Ellipsis,
210+ )
211+ Text(
212+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
213+ style = MaterialTheme.typography.bodySmall,
214+ color = MaterialTheme.colorScheme.onSurfaceVariant,
215+ maxLines = 1,
216+ overflow = TextOverflow.Ellipsis,
217+ )
218+ Text(
219+ stringResource(R.string.connected),
220+ style = MaterialTheme.typography.labelSmall,
221+ color = MaterialTheme.colorScheme.primary,
222+ )
223+ LastUsedText(profile.lastUsed)
224+ }
225+ Icon(
226+ Icons.Filled.CheckCircle,
227+ contentDescription = stringResource(R.string.connected),
228+ tint = MaterialTheme.colorScheme.primary,
229+ modifier = Modifier.size(22.dp),
230+ )
231+ // Overflow menu consolidates edit/duplicate/delete so the
232+ // active row shows a single chevron instead of three icons.
233+ // Keep edit available on the active server so credentials can
234+ // be fixed without disconnecting first. Delete is also offered:
235+ // the confirmation dialog warns that removing the active server
236+ // also disconnects (remove_server_active_text), and the
237+ // ViewModel disconnects immediately on confirm.
238+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
239+ val editLabel = stringResource(R.string.edit)
240+ val duplicateLabel = stringResource(R.string.duplicate_server)
241+ val removeLabel = stringResource(R.string.remove)
242+ val moreLabel = stringResource(R.string.more)
243+ Box {
244+ IconButton(onClick = { showRowMenu = true }) {
245+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
246+ }
247+ DropdownMenu(
248+ expanded = showRowMenu,
249+ onDismissRequest = { showRowMenu = false },
250+ ) {
251+ DropdownMenuItem(
252+ text = { Text(editLabel) },
253+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
254+ )
255+ DropdownMenuItem(
256+ text = { Text(duplicateLabel) },
257+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
258+ )
259+ DropdownMenuItem(
260+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
261+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
262+ )
263+ }
264+ }
265+ }
266+ }
267+ } else {
268+ // Swipe end-to-start reveals a delete affordance and opens the
269+ // same confirmation dialog as the trash icon (matching the
270+ // session list). confirmValueChange always returns false (snap
271+ // back) so the dialog guards the actual deletion.
272+ val swipeState = rememberSwipeToDismissBoxState(
273+ confirmValueChange = {
274+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
275+ pendingDeleteId = profile.id
276+ false
277+ },
278+ )
279+ SwipeToDismissBox(
280+ state = swipeState,
281+ enableDismissFromStartToEnd = false,
282+ modifier = Modifier.animateItem(),
283+ backgroundContent = {
284+ Box(
285+ modifier = Modifier
286+ .fillMaxSize()
287+ .clip(MaterialTheme.shapes.medium)
288+ .background(MaterialTheme.colorScheme.errorContainer)
289+ .padding(horizontal = 20.dp),
290+ contentAlignment = Alignment.CenterEnd,
291+ ) {
292+ Icon(
293+ Icons.Filled.Delete,
294+ contentDescription = stringResource(R.string.delete),
295+ tint = MaterialTheme.colorScheme.onErrorContainer,
296+ )
297+ }
298+ },
299+ ) {
300+ Card(
301+ modifier = Modifier
302+ .fillMaxWidth()
303+ .testTag("server_card")
304+ .clickable(enabled = connectingId == null, role = Role.Button) {
305+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
306+ vm.connect(profile, onConnected)
307+ },
308+ ) {
309+ Row(
310+ modifier = Modifier.fillMaxWidth().padding(16.dp),
311+ verticalAlignment = Alignment.CenterVertically,
312+ ) {
313+ Column(modifier = Modifier.weight(1f)) {
314+ Text(
315+ profile.displayLabel,
316+ style = MaterialTheme.typography.titleMedium,
317+ maxLines = 1,
318+ overflow = TextOverflow.Ellipsis,
319+ )
320+ Text(
321+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
322+ style = MaterialTheme.typography.bodySmall,
323+ color = MaterialTheme.colorScheme.onSurfaceVariant,
324+ maxLines = 1,
325+ overflow = TextOverflow.Ellipsis,
326+ )
327+ LastUsedText(profile.lastUsed)
328+ }
329+ if (connectingId == profile.id) {
330+ val connectingLabel = stringResource(R.string.connecting)
331+ CircularProgressIndicator(
332+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
333+ )
334+ } else {
335+ // Overflow menu replaces the three inline edit/duplicate/
336+ // delete icons so a long server list looks less busy.
337+ // Swipe-to-delete remains the gesture path; the overflow
338+ // is the discovery path (matching the session cards).
339+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
340+ val editLabel = stringResource(R.string.edit)
341+ val duplicateLabel = stringResource(R.string.duplicate_server)
342+ val removeLabel = stringResource(R.string.remove)
343+ val moreLabel = stringResource(R.string.more)
344+ Box {
345+ IconButton(onClick = { showRowMenu = true }) {
346+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
347+ }
348+ DropdownMenu(
349+ expanded = showRowMenu,
350+ onDismissRequest = { showRowMenu = false },
351+ ) {
352+ DropdownMenuItem(
353+ text = { Text(editLabel) },
354+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
355+ )
356+ DropdownMenuItem(
357+ text = { Text(duplicateLabel) },
358+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
359+ )
360+ DropdownMenuItem(
361+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
362+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
363+ )
364+ }
365+ }
366+ }
367+ }
368+ }
369+ }
370+ }
371+ }
372+ }
373+ }
374+ }
375+ }
376+ }
377+
378+ profiles.find { it.id == pendingDeleteId }?.let { profile ->
379+ val isActiveProfile = profile.id == connectedId
380+ AlertDialog(
381+ onDismissRequest = { pendingDeleteId = null },
382+ title = { Text(stringResource(R.string.remove_server_title)) },
383+ text = {
384+ Text(
385+ if (isActiveProfile) stringResource(R.string.remove_server_active_text, profile.displayLabel)
386+ else stringResource(R.string.remove_server_text, profile.displayLabel),
387+ )
388+ },
389+ confirmButton = {
390+ TextButton(onClick = {
391+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
392+ pendingDeleteId = null
393+ // delete() defers the actual removal so the undo snackbar can cancel
394+ // it; the row disappears on the next profiles emission.
395+ vm.delete(profile)
396+ }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
397+ },
398+ dismissButton = {
399+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
400+ },
401+ )
402+ }
403+}
404+
405+/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
406+ * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
407+@Composable
408+private fun LastUsedText(lastUsed: Long) {
409+ if (lastUsed <= 0) {
410+ Text(
411+ stringResource(R.string.last_used_never),
412+ style = MaterialTheme.typography.labelSmall,
413+ color = MaterialTheme.colorScheme.onSurfaceVariant,
414+ )
415+ } else {
416+ val relative = rememberRelativeTime(lastUsed)
417+ if (relative.isNotEmpty()) {
418+ Text(
419+ stringResource(R.string.last_used, relative),
420+ style = MaterialTheme.typography.labelSmall,
421+ color = MaterialTheme.colorScheme.onSurfaceVariant,
422+ )
423+ }
424+ }
425+}
426+
427+@Composable
428+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
429+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
430+ Column(
431+ modifier = Modifier.padding(32.dp),
432+ horizontalAlignment = Alignment.CenterHorizontally,
433+ ) {
434+ Icon(
435+ Icons.Filled.Dns,
436+ contentDescription = null,
437+ modifier = Modifier.size(56.dp),
438+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
439+ )
440+ Spacer(Modifier.size(16.dp))
441+ Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
442+ Spacer(Modifier.size(8.dp))
443+ Text(
444+ stringResource(R.string.no_servers_hint),
445+ style = MaterialTheme.typography.bodyMedium,
446+ color = MaterialTheme.colorScheme.onSurfaceVariant,
447+ )
448+ Spacer(Modifier.size(20.dp))
449+ TextButton(onClick = onAdd) {
450+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
451+ Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
452+ }
453+ }
454+ }
455+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+133
@@ -0,0 +1,133 @@
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.data.network.NetworkConfig
7+import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
9+import soy.iko.opencode.util.runCatchingCancellable
10+import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.flow.MutableSharedFlow
12+import kotlinx.coroutines.flow.MutableStateFlow
13+import kotlinx.coroutines.flow.SharedFlow
14+import kotlinx.coroutines.flow.SharingStarted
15+import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asSharedFlow
17+import kotlinx.coroutines.flow.asStateFlow
18+import kotlinx.coroutines.flow.combine
19+import kotlinx.coroutines.flow.stateIn
20+import kotlinx.coroutines.flow.update
21+import kotlinx.coroutines.launch
22+
23+/** A transient error surfaced as a snackbar, optionally paired with the profile that
24+ * failed to connect so the snackbar can offer a Retry action. */
25+data class ConnectError(val message: String, val profile: ServerProfile?)
26+
27+class ServerListViewModel(private val container: AppContainer) : ViewModel() {
28+
29+ /** Ids optimistically hidden while their deferred delete's undo window is open, so a
30+ * "deleted" row disappears immediately instead of lingering (tappable) for the whole
31+ * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
32+ private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
33+
34+ val profiles: StateFlow<List<ServerProfile>> =
35+ combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
36+ profiles.filterNot { it.id in hidden }
37+ }.stateIn(
38+ scope = viewModelScope,
39+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
40+ initialValue = emptyList(),
41+ )
42+
43+ private val _connecting = MutableStateFlow<String?>(null)
44+ val connectingId: StateFlow<String?> = _connecting.asStateFlow()
45+
46+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
47+ * emission is delivered independently. */
48+ private val _errorEvents = MutableSharedFlow<ConnectError>(
49+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
50+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
51+ )
52+ val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
53+
54+ /** One-shot events carrying the id of a server profile marked for deferred deletion,
55+ * so the UI can show an Undo snackbar. Mirrors the session list's undo pattern. */
56+ private val _undoEvents = MutableSharedFlow<String>(
57+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
58+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
59+ )
60+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
61+
62+ fun connect(profile: ServerProfile, onConnected: () -> Unit) {
63+ if (_connecting.value != null) return
64+ _connecting.value = profile.id
65+ viewModelScope.launch {
66+ try {
67+ val result = runCatchingCancellable {
68+ val conn = container.connect(profile)
69+ conn.api.ping()
70+ }
71+ result.onSuccess { onConnected() }
72+ .onFailure {
73+ container.disconnect()
74+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
75+ }
76+ } finally {
77+ _connecting.value = null
78+ }
79+ }
80+ }
81+
82+ /** Retry connecting to the most recently used profile β€” the pull-to-refresh action on
83+ * the server list. Gives the user a familiar gesture to retry a failed auto-connect
84+ * instead of having to tap a server card. */
85+ fun refresh(onConnected: () -> Unit) {
86+ if (_connecting.value != null || container.reconnecting.value) return
87+ val target = profiles.value.firstOrNull() ?: return
88+ connect(target, onConnected)
89+ }
90+
91+ /**
92+ * Mark [profile] for deferred deletion and emit an Undo event. The actual
93+ * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
94+ * [undoDelete] is called before it fires, the profile is restored. If the profile
95+ * is the active one, disconnect immediately (the user has already confirmed via the
96+ * dialog) so the UI reflects the disconnection right away β€” the profile row is
97+ * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
98+ */
99+ fun delete(profile: ServerProfile) {
100+ // Optimistically hide the row so the UI feels instant; undo re-shows it.
101+ _hiddenIds.update { it + profile.id }
102+ if (container.activeConnection.value?.profile?.id == profile.id) {
103+ // disconnect() is suspend; run it on the VM scope so the optimistic removal
104+ // and undo emission aren't blocked on the connection close completing.
105+ viewModelScope.launch { container.disconnect() }
106+ }
107+ _undoEvents.tryEmit(profile.id)
108+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
109+ // if the user navigates away from the server list within the undo window, a
110+ // viewModelScope job would be cancelled and the profile would never be deleted
111+ // (reappearing on return). The container owns the timer and the undo cancellation.
112+ container.scheduleProfileDelete(
113+ id = profile.id,
114+ delayMs = NetworkConfig.undoServerDeleteDelayMs,
115+ onDeleted = {
116+ // Drop the optimistic hide only after the store has emitted the
117+ // removal, so the row doesn't flash back before the list updates.
118+ _hiddenIds.update { it - profile.id }
119+ },
120+ onError = {
121+ // Delete failed: re-show the row so it isn't hidden forever.
122+ _hiddenIds.update { it - profile.id }
123+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
124+ },
125+ )
126+ }
127+
128+ /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
129+ fun undoDelete(profileId: String) {
130+ container.cancelProfileDelete(profileId)
131+ _hiddenIds.update { it - profileId }
132+ }
133+}
app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktnew file+237
@@ -0,0 +1,237 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.horizontalScroll
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.rememberScrollState
11+import androidx.compose.foundation.selection.selectable
12+import androidx.compose.foundation.text.KeyboardOptions
13+import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material3.AlertDialog
15+import androidx.compose.material3.CircularProgressIndicator
16+import androidx.compose.material3.MaterialTheme
17+import androidx.compose.material3.OutlinedTextField
18+import androidx.compose.material3.RadioButton
19+import androidx.compose.material3.Text
20+import androidx.compose.material3.TextButton
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.Saver
26+import androidx.compose.runtime.saveable.rememberSaveable
27+import androidx.compose.runtime.setValue
28+import androidx.compose.ui.Alignment
29+import androidx.compose.ui.Modifier
30+import androidx.compose.ui.semantics.Role
31+import androidx.compose.ui.text.font.FontFamily
32+import androidx.compose.ui.text.input.ImeAction
33+import androidx.compose.ui.text.style.TextOverflow
34+import androidx.compose.ui.unit.dp
35+import androidx.compose.ui.res.stringResource
36+import soy.iko.opencode.R
37+
38+/** The user's directory choice in the new-session picker. */
39+private sealed interface DirChoice {
40+ /** Create with no directory override β€” the server uses its launch cwd. */
41+ object ServerDefault : DirChoice
42+
43+ /** A known worktree / recent-session directory. */
44+ data class Known(val path: String) : DirChoice
45+
46+ /** A path the user types by hand. */
47+ object Custom : DirChoice
48+}
49+
50+// Persist the radio selection across configuration changes / process death as a short
51+// string tag ("d" = server default, "c" = custom, "k:<path>" = a known directory).
52+private val dirChoiceSaver = Saver<DirChoice, String>(
53+ save = {
54+ when (it) {
55+ is DirChoice.ServerDefault -> "d"
56+ is DirChoice.Custom -> "c"
57+ is DirChoice.Known -> "k:${it.path}"
58+ }
59+ },
60+ restore = {
61+ when {
62+ it == "c" -> DirChoice.Custom
63+ it.startsWith("k:") -> DirChoice.Known(it.removePrefix("k:"))
64+ else -> DirChoice.ServerDefault
65+ }
66+ },
67+)
68+
69+/**
70+ * Dialog shown when creating a new session: lets the user pick the working directory the
71+ * agent will run in. Offers the server's default (cwd), the directories the server knows
72+ * about ([options].projects) plus any directories of existing [sessionDirectories], and a
73+ * free-text path. Sends the resolved path (or null for the server default) to [onCreate].
74+ *
75+ * [lastChosenDirectory] preselects the directory used for the previous new session.
76+ */
77+@Composable
78+fun NewSessionDialog(
79+ options: DirectoryOptionsState,
80+ sessionDirectories: List<String>,
81+ lastChosenDirectory: String?,
82+ creating: Boolean,
83+ onCreate: (String?) -> Unit,
84+ onDismiss: () -> Unit,
85+) {
86+ // Directories to list as selectable rows: known projects + directories of existing
87+ // sessions + the last chosen one, minus the server default (which has its own row).
88+ val knownDirs = remember(options.projects, sessionDirectories, lastChosenDirectory, options.serverDefault) {
89+ (options.projects + sessionDirectories + listOfNotNull(lastChosenDirectory))
90+ .filter { it.isNotBlank() && it != options.serverDefault }
91+ .distinct()
92+ }
93+
94+ // Preselect the last chosen directory (or the most-recent session's directory); both are
95+ // included in knownDirs above, so a non-null, non-default preselect always maps to a row.
96+ var choice by rememberSaveable(stateSaver = dirChoiceSaver) {
97+ val preselect = lastChosenDirectory ?: sessionDirectories.firstOrNull { it.isNotBlank() }
98+ mutableStateOf(
99+ if (preselect == null || preselect == options.serverDefault) DirChoice.ServerDefault
100+ else DirChoice.Known(preselect),
101+ )
102+ }
103+ var customText by rememberSaveable { mutableStateOf("") }
104+
105+ val resolved: String? = when (val c = choice) {
106+ is DirChoice.ServerDefault -> null
107+ is DirChoice.Known -> c.path
108+ is DirChoice.Custom -> customText.trim().takeIf { it.isNotBlank() }
109+ }
110+ // Custom requires a non-blank path; the other choices are always valid.
111+ val canCreate = !creating && (choice !is DirChoice.Custom || customText.isNotBlank())
112+
113+ AlertDialog(
114+ onDismissRequest = { if (!creating) onDismiss() },
115+ title = { Text(stringResource(R.string.new_session)) },
116+ text = {
117+ Column(
118+ modifier = Modifier
119+ .fillMaxWidth()
120+ .heightIn(max = 380.dp)
121+ .verticalScroll(rememberScrollState()),
122+ ) {
123+ Text(
124+ stringResource(R.string.directory_label),
125+ style = MaterialTheme.typography.labelLarge,
126+ color = MaterialTheme.colorScheme.onSurfaceVariant,
127+ modifier = Modifier.padding(bottom = 4.dp),
128+ )
129+
130+ DirectoryRow(
131+ label = stringResource(R.string.directory_server_default),
132+ sublabel = options.serverDefault,
133+ selected = choice is DirChoice.ServerDefault,
134+ enabled = !creating,
135+ onSelect = { choice = DirChoice.ServerDefault },
136+ )
137+
138+ knownDirs.forEach { path ->
139+ DirectoryRow(
140+ label = path.trimEnd('/').substringAfterLast('/').ifBlank { path },
141+ sublabel = path,
142+ selected = (choice as? DirChoice.Known)?.path == path,
143+ enabled = !creating,
144+ onSelect = { choice = DirChoice.Known(path) },
145+ )
146+ }
147+
148+ DirectoryRow(
149+ label = stringResource(R.string.directory_custom),
150+ sublabel = null,
151+ selected = choice is DirChoice.Custom,
152+ enabled = !creating,
153+ onSelect = { choice = DirChoice.Custom },
154+ )
155+ if (choice is DirChoice.Custom) {
156+ OutlinedTextField(
157+ value = customText,
158+ onValueChange = { customText = it },
159+ modifier = Modifier
160+ .fillMaxWidth()
161+ .padding(start = 36.dp, top = 4.dp, bottom = 4.dp),
162+ placeholder = { Text(stringResource(R.string.directory_custom_hint)) },
163+ singleLine = true,
164+ enabled = !creating,
165+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
166+ )
167+ }
168+
169+ if (options.loading && !options.loaded) {
170+ Row(
171+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
172+ verticalAlignment = Alignment.CenterVertically,
173+ ) {
174+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
175+ Text(
176+ stringResource(R.string.directory_loading),
177+ style = MaterialTheme.typography.bodySmall,
178+ color = MaterialTheme.colorScheme.onSurfaceVariant,
179+ modifier = Modifier.padding(start = 8.dp),
180+ )
181+ }
182+ }
183+ }
184+ },
185+ confirmButton = {
186+ TextButton(onClick = { onCreate(resolved) }, enabled = canCreate) {
187+ if (creating) {
188+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
189+ } else {
190+ Text(stringResource(R.string.create))
191+ }
192+ }
193+ },
194+ dismissButton = {
195+ TextButton(onClick = onDismiss, enabled = !creating) { Text(stringResource(R.string.cancel)) }
196+ },
197+ )
198+}
199+
200+@Composable
201+private fun DirectoryRow(
202+ label: String,
203+ sublabel: String?,
204+ selected: Boolean,
205+ enabled: Boolean,
206+ onSelect: () -> Unit,
207+) {
208+ Row(
209+ modifier = Modifier
210+ .fillMaxWidth()
211+ .selectable(selected = selected, enabled = enabled, role = Role.RadioButton, onClick = onSelect)
212+ .padding(vertical = 4.dp),
213+ verticalAlignment = Alignment.CenterVertically,
214+ ) {
215+ RadioButton(selected = selected, onClick = null, enabled = enabled)
216+ Column(modifier = Modifier.padding(start = 12.dp)) {
217+ Text(
218+ label,
219+ style = MaterialTheme.typography.bodyLarge,
220+ maxLines = 1,
221+ overflow = TextOverflow.Ellipsis,
222+ )
223+ if (!sublabel.isNullOrBlank() && sublabel != label) {
224+ // Full path can be long; let it scroll horizontally rather than truncate so
225+ // the user can verify exactly which directory they're selecting.
226+ Text(
227+ sublabel,
228+ style = MaterialTheme.typography.bodySmall,
229+ color = MaterialTheme.colorScheme.onSurfaceVariant,
230+ fontFamily = FontFamily.Monospace,
231+ maxLines = 1,
232+ modifier = Modifier.horizontalScroll(rememberScrollState()),
233+ )
234+ }
235+ }
236+ }
237+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+815
@@ -0,0 +1,815 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.background
4+import androidx.activity.compose.BackHandler
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.fillMaxSize
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.items
18+import androidx.compose.foundation.text.KeyboardActions
19+import androidx.compose.foundation.text.KeyboardOptions
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.automirrored.filled.Logout
22+import androidx.compose.material.icons.automirrored.filled.Sort
23+import androidx.compose.material.icons.filled.Add
24+import androidx.compose.material.icons.filled.ArrowDropDown
25+import androidx.compose.material.icons.filled.ChatBubbleOutline
26+import androidx.compose.material.icons.filled.CheckCircle
27+import androidx.compose.material.icons.filled.Close
28+import androidx.compose.material.icons.filled.Delete
29+import androidx.compose.material.icons.filled.Folder
30+import androidx.compose.material.icons.filled.MoreVert
31+import androidx.compose.material.icons.filled.Refresh
32+import androidx.compose.material.icons.filled.Search
33+import androidx.compose.material.icons.filled.Settings
34+import androidx.compose.material3.AlertDialog
35+import androidx.compose.material3.Button
36+import androidx.compose.material3.Card
37+import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
40+import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.ExtendedFloatingActionButton
42+import androidx.compose.material3.Icon
43+import androidx.compose.material3.IconButton
44+import androidx.compose.material3.MaterialTheme
45+import androidx.compose.material3.OutlinedTextField
46+import androidx.compose.material3.Scaffold
47+import androidx.compose.material3.SnackbarHost
48+import androidx.compose.material3.SnackbarHostState
49+import androidx.compose.material3.SwipeToDismissBox
50+import androidx.compose.material3.SwipeToDismissBoxValue
51+import androidx.compose.material3.Text
52+import androidx.compose.material3.TextButton
53+import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
55+import androidx.compose.material3.rememberSwipeToDismissBoxState
56+import androidx.compose.runtime.Composable
57+import androidx.compose.runtime.CompositionLocalProvider
58+import androidx.compose.runtime.LaunchedEffect
59+import androidx.compose.runtime.getValue
60+import androidx.compose.runtime.mutableStateOf
61+import androidx.compose.runtime.remember
62+import androidx.compose.runtime.saveable.rememberSaveable
63+import androidx.compose.runtime.setValue
64+import androidx.compose.ui.Alignment
65+import androidx.compose.ui.Modifier
66+import androidx.compose.ui.draw.clip
67+import androidx.compose.ui.platform.LocalHapticFeedback
68+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
69+import androidx.compose.ui.platform.testTag
70+import androidx.compose.ui.semantics.Role
71+import androidx.compose.ui.semantics.contentDescription
72+import androidx.compose.ui.semantics.semantics
73+import androidx.compose.ui.text.input.ImeAction
74+import androidx.compose.ui.text.style.TextOverflow
75+import androidx.compose.ui.unit.dp
76+import androidx.compose.ui.res.stringResource
77+import androidx.lifecycle.compose.collectAsStateWithLifecycle
78+import androidx.lifecycle.viewmodel.compose.viewModel
79+import kotlinx.coroutines.coroutineScope
80+import kotlinx.coroutines.flow.collectLatest
81+import kotlinx.coroutines.delay
82+import kotlinx.coroutines.launch
83+import soy.iko.opencode.data.model.ServerProfile
84+import soy.iko.opencode.data.model.Session
85+import soy.iko.opencode.data.network.NetworkConfig
86+import soy.iko.opencode.di.AppContainer
87+import soy.iko.opencode.R
88+import soy.iko.opencode.ui.components.ConnectionBanner
89+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
90+import soy.iko.opencode.ui.components.RelativeTimeText
91+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
92+import soy.iko.opencode.ui.vmFactory
93+
94+@OptIn(ExperimentalMaterial3Api::class)
95+@Composable
96+fun SessionListScreen(
97+ container: AppContainer,
98+ onOpenSession: (String) -> Unit,
99+ onDisconnect: () -> Unit,
100+ onOpenFiles: () -> Unit,
101+ onOpenSettings: () -> Unit,
102+ onAddServer: () -> Unit,
103+ selectedSessionId: String? = null,
104+) {
105+ val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
106+ val state by vm.state.collectAsStateWithLifecycle()
107+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
108+ val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
109+ val profiles by vm.profiles.collectAsStateWithLifecycle()
110+ val switchingId by vm.switchingId.collectAsStateWithLifecycle()
111+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
112+ val unread by vm.unread.collectAsStateWithLifecycle()
113+ val creating by vm.creating.collectAsStateWithLifecycle()
114+ val directoryOptions by vm.directoryOptions.collectAsStateWithLifecycle()
115+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
116+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
117+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
118+ val connectedId = activeConnection?.profile?.id
119+ val haptics = LocalHapticFeedback.current
120+ val snackbar = remember { SnackbarHostState() }
121+ val undoLabel = stringResource(R.string.undo)
122+ val sessionDeletedLabel = stringResource(R.string.session_deleted)
123+ // One shared timer drives every relative-time label in the session list instead of
124+ // each card spinning up its own coroutine + lifecycle observer while scrolling.
125+ val timeTick = rememberRelativeTimeTick()
126+ var showServerMenu by rememberSaveable { mutableStateOf(false) }
127+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
128+ var showNewSessionDialog by rememberSaveable { mutableStateOf(false) }
129+ // Open the new-session directory picker, kicking off a fetch of the server's known
130+ // directories so they're ready by the time the user looks at the list.
131+ val openNewSession = {
132+ if (!creating) { vm.loadDirectoryOptions(); showNewSessionDialog = true }
133+ }
134+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
135+ var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
136+ var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
137+ val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
138+ val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
139+
140+ // Close the open dropdown on back press instead of navigating away.
141+ BackHandler(enabled = showServerMenu) { showServerMenu = false }
142+ BackHandler(enabled = showSortMenu) { showSortMenu = false }
143+
144+ LaunchedEffect(Unit) {
145+ vm.transientErrors.collect { msg ->
146+ snackbar.showSnackbar(msg)
147+ }
148+ }
149+
150+ // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
151+ // action is taken before the delay expires, the session is restored and the REST
152+ // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
153+ // visible for exactly the undoDeleteDelayMs window (5s) and no longer: a fixed
154+ // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
155+ // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
156+ LaunchedEffect(Unit) {
157+ // collectLatest (not collect): a serialized collect queues each snackbar behind
158+ // the previous one's full window, so under rapid deletes a later session's VM
159+ // delete timer (started at emit time) fires before its snackbar is ever shown β€”
160+ // a dead Undo button. collectLatest instead cancels the current snackbar and
161+ // shows the newest immediately, keeping its Undo window aligned with its timer.
162+ vm.undoEvents.collectLatest { sessionId ->
163+ coroutineScope {
164+ val dismisser = launch {
165+ delay(NetworkConfig.undoDeleteDelayMs)
166+ snackbar.currentSnackbarData?.dismiss()
167+ }
168+ val result = snackbar.showSnackbar(
169+ message = sessionDeletedLabel,
170+ actionLabel = undoLabel,
171+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
172+ )
173+ dismisser.cancel()
174+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
175+ vm.undoDelete(sessionId)
176+ }
177+ }
178+ }
179+ }
180+
181+ Scaffold(
182+ topBar = {
183+ TopAppBar(
184+ title = {
185+ ServerSwitcherMenu(
186+ serverLabel = serverLabel,
187+ profiles = profiles,
188+ connectedId = connectedId,
189+ switchingId = switchingId,
190+ expanded = showServerMenu,
191+ onExpand = { showServerMenu = true },
192+ onDismiss = { showServerMenu = false },
193+ onSelect = { profile ->
194+ showServerMenu = false
195+ vm.switchServer(profile)
196+ },
197+ onAddServer = {
198+ showServerMenu = false
199+ onAddServer()
200+ },
201+ )
202+ },
203+ actions = {
204+ Box {
205+ IconButton(onClick = { showSortMenu = true }) {
206+ Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
207+ }
208+ DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
209+ DropdownMenuItem(
210+ text = { Text(stringResource(R.string.sort_recent)) },
211+ onClick = { vm.setSortMode(SessionSortMode.RECENT); showSortMenu = false },
212+ )
213+ DropdownMenuItem(
214+ text = { Text(stringResource(R.string.sort_title)) },
215+ onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
216+ )
217+ }
218+ }
219+ IconButton(onClick = { vm.refresh() }) {
220+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
221+ }
222+ IconButton(onClick = onOpenFiles) {
223+ Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
224+ }
225+ IconButton(onClick = onOpenSettings) {
226+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
227+ }
228+ IconButton(onClick = {
229+ // Confirm before disconnecting if an agent run is active in any
230+ // session β€” disconnecting kills the SSE stream and the run.
231+ if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
232+ }) {
233+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
234+ }
235+ },
236+ )
237+ },
238+ snackbarHost = { SnackbarHost(snackbar) },
239+ floatingActionButton = {
240+ ExtendedFloatingActionButton(
241+ onClick = openNewSession,
242+ icon = {
243+ if (creating) {
244+ CircularProgressIndicator(
245+ Modifier.size(24.dp),
246+ strokeWidth = 2.dp,
247+ color = MaterialTheme.colorScheme.onPrimaryContainer,
248+ )
249+ } else {
250+ Icon(Icons.Filled.Add, contentDescription = null)
251+ }
252+ },
253+ text = { Text(stringResource(R.string.new_session)) },
254+ // Disable while a creation is in flight so a double-tap can't spawn two
255+ // sessions. The container guard in createSession is the real protection;
256+ // this is the visual signal.
257+ expanded = !creating,
258+ )
259+ },
260+ ) { padding ->
261+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
262+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
263+ ConnectionBanner(
264+ state = connectionState,
265+ modifier = Modifier.align(Alignment.TopCenter),
266+ isOnline = isOnline,
267+ onRetry = { vm.retryConnection() },
268+ )
269+ SessionListBody(
270+ state = state,
271+ unread = unread,
272+ refreshing = refreshing,
273+ haptics = haptics,
274+ selectedSessionId = selectedSessionId,
275+ onRefresh = vm::refresh,
276+ onQueryChange = vm::setQuery,
277+ onOpenSession = onOpenSession,
278+ onCreateSession = openNewSession,
279+ onRename = { pendingRenameId = it },
280+ onDelete = { pendingDeleteId = it },
281+ )
282+ }
283+ }
284+ }
285+
286+ pendingDelete?.let { session ->
287+ AlertDialog(
288+ onDismissRequest = { pendingDeleteId = null },
289+ title = { Text(stringResource(R.string.delete_session_title)) },
290+ text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
291+ confirmButton = {
292+ TextButton(onClick = {
293+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
294+ pendingDeleteId = null
295+ vm.deleteSession(session)
296+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
297+ },
298+ dismissButton = {
299+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
300+ },
301+ )
302+ }
303+
304+ pendingRename?.let { session ->
305+ var title by rememberSaveable(session.id) { mutableStateOf(session.title ?: "") }
306+ RenameSessionDialog(
307+ title = title,
308+ onTitleChange = { title = it },
309+ onConfirm = {
310+ val newName = title.trim()
311+ pendingRenameId = null
312+ if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
313+ },
314+ onDismiss = { pendingRenameId = null },
315+ )
316+ }
317+
318+ if (showDisconnectConfirm) {
319+ AlertDialog(
320+ onDismissRequest = { showDisconnectConfirm = false },
321+ title = { Text(stringResource(R.string.disconnect_active_title)) },
322+ text = { Text(stringResource(R.string.disconnect_active_text)) },
323+ confirmButton = {
324+ TextButton(onClick = {
325+ showDisconnectConfirm = false
326+ onDisconnect()
327+ }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
328+ },
329+ dismissButton = {
330+ TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
331+ },
332+ )
333+ }
334+
335+ if (showNewSessionDialog) {
336+ // Distinct session directories, most-recent first (state.sessions is sorted), so the
337+ // picker can offer directories the user has recently worked in even if the server's
338+ // project list is unavailable.
339+ val sessionDirectories = remember(state.sessions) {
340+ state.sessions.mapNotNull { it.directory?.takeIf { d -> d.isNotBlank() } }.distinct()
341+ }
342+ NewSessionDialog(
343+ options = directoryOptions,
344+ sessionDirectories = sessionDirectories,
345+ lastChosenDirectory = vm.lastChosenDirectory,
346+ creating = creating,
347+ onCreate = { dir ->
348+ vm.createSession(directory = dir) { id ->
349+ showNewSessionDialog = false
350+ onOpenSession(id)
351+ }
352+ },
353+ onDismiss = { showNewSessionDialog = false },
354+ )
355+ }
356+}
357+
358+@OptIn(ExperimentalMaterial3Api::class)
359+@Composable
360+private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
361+ state: SessionListState,
362+ unread: Map<String, Int>,
363+ refreshing: Boolean,
364+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
365+ selectedSessionId: String?,
366+ onRefresh: () -> Unit,
367+ onQueryChange: (String) -> Unit,
368+ onOpenSession: (String) -> Unit,
369+ onCreateSession: () -> Unit,
370+ onRename: (String) -> Unit,
371+ onDelete: (String) -> Unit,
372+) {
373+ when {
374+ state.loading -> {
375+ val loadingLabel = stringResource(R.string.loading)
376+ CircularProgressIndicator(
377+ Modifier
378+ .align(Alignment.Center)
379+ .semantics { contentDescription = loadingLabel },
380+ )
381+ }
382+ state.sessions.isEmpty() && state.error != null -> Column(
383+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
384+ horizontalAlignment = Alignment.CenterHorizontally,
385+ ) {
386+ Text(
387+ state.error ?: "",
388+ color = MaterialTheme.colorScheme.error,
389+ )
390+ Spacer(Modifier.size(12.dp))
391+ TextButton(onClick = onRefresh) {
392+ Text(stringResource(R.string.retry))
393+ }
394+ }
395+ state.sessions.isEmpty() -> EmptySessions(
396+ onCreate = onCreateSession,
397+ modifier = Modifier.align(Alignment.Center),
398+ )
399+ else -> Column(modifier = Modifier.fillMaxSize()) {
400+ val keyboardController = LocalSoftwareKeyboardController.current
401+ OutlinedTextField(
402+ value = state.query,
403+ onValueChange = onQueryChange,
404+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
405+ label = { Text(stringResource(R.string.search_sessions)) },
406+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
407+ trailingIcon = if (state.query.isNotEmpty()) {
408+ {
409+ IconButton(onClick = { onQueryChange("") }) {
410+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
411+ }
412+ }
413+ } else null,
414+ singleLine = true,
415+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
416+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
417+ )
418+ val sessions = remember(state.sessions, state.query, state.previews) {
419+ // Fast-path an empty query: filtered just returns sessions, so skip
420+ // the recompute (and the fresh list reference) when only previews
421+ // churned in the background. With a non-empty query the previews
422+ // map affects the match set, so recompute on any of the three.
423+ if (state.query.isBlank()) state.sessions else state.filtered
424+ }
425+ if (sessions.isEmpty()) {
426+ Box(
427+ modifier = Modifier.fillMaxSize(),
428+ contentAlignment = Alignment.Center,
429+ ) {
430+ Column(
431+ horizontalAlignment = Alignment.CenterHorizontally,
432+ modifier = Modifier.padding(32.dp),
433+ ) {
434+ Icon(
435+ Icons.Filled.Search,
436+ contentDescription = null,
437+ modifier = Modifier.size(48.dp),
438+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
439+ )
440+ Spacer(Modifier.size(12.dp))
441+ Text(
442+ stringResource(R.string.no_sessions_match, state.query),
443+ style = MaterialTheme.typography.bodyMedium,
444+ color = MaterialTheme.colorScheme.onSurfaceVariant,
445+ )
446+ }
447+ }
448+ } else {
449+ PullToRefreshBox(
450+ isRefreshing = refreshing,
451+ onRefresh = onRefresh,
452+ modifier = Modifier.fillMaxSize(),
453+ ) {
454+ LazyColumn(
455+ modifier = Modifier.fillMaxSize(),
456+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
457+ verticalArrangement = Arrangement.spacedBy(12.dp),
458+ ) {
459+ items(sessions, key = { it.id }) { session ->
460+ // Swipe end-to-start reveals a delete affordance and opens the
461+ // same confirmation dialog as the trash icon. We never commit
462+ // the dismissal (always reset to Settled) so the card snaps back
463+ // and the dialog guards against accidental data loss.
464+ val swipeState = rememberSwipeToDismissBoxState(
465+ confirmValueChange = {
466+ // Haptic at the trigger so the swipe path matches the icon-path
467+ // delete, which vibrates on confirm. Returns false (snap back) so
468+ // the dialog guards the actual deletion.
469+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
470+ onDelete(session.id)
471+ false
472+ },
473+ )
474+ // Per-item callbacks memoized on the session id so SessionCard
475+ // stays skippable: the list emits a fresh SessionListState on
476+ // every background preview/unread update, and freshly-allocated
477+ // lambdas would otherwise force every visible card to recompose
478+ // on each of those emissions even when its own data is unchanged.
479+ val onCardClick = remember(session.id) { { onOpenSession(session.id) } }
480+ val onCardRename = remember(session.id) { { onRename(session.id) } }
481+ val onCardDelete = remember(session.id) { { onDelete(session.id) } }
482+ SwipeToDismissBox(
483+ state = swipeState,
484+ enableDismissFromStartToEnd = false,
485+ modifier = Modifier.animateItem(),
486+ backgroundContent = {
487+ Box(
488+ modifier = Modifier
489+ .fillMaxSize()
490+ .clip(MaterialTheme.shapes.medium)
491+ .background(MaterialTheme.colorScheme.errorContainer)
492+ .padding(horizontal = 20.dp),
493+ contentAlignment = Alignment.CenterEnd,
494+ ) {
495+ Icon(
496+ Icons.Filled.Delete,
497+ contentDescription = stringResource(R.string.delete),
498+ tint = MaterialTheme.colorScheme.onErrorContainer,
499+ )
500+ }
501+ },
502+ ) {
503+ SessionCard(
504+ session = session,
505+ preview = state.previews[session.id],
506+ unreadCount = unread[session.id] ?: 0,
507+ isSelected = session.id == selectedSessionId,
508+ onClick = onCardClick,
509+ onRename = onCardRename,
510+ onDelete = onCardDelete,
511+ modifier = Modifier.testTag("session_card"),
512+ )
513+ }
514+ }
515+ }
516+ }
517+ }
518+ }
519+ }
520+}
521+
522+@Composable
523+private fun RenameSessionDialog(
524+ title: String,
525+ onTitleChange: (String) -> Unit,
526+ onConfirm: () -> Unit,
527+ onDismiss: () -> Unit,
528+) {
529+ AlertDialog(
530+ onDismissRequest = onDismiss,
531+ title = { Text(stringResource(R.string.rename_session)) },
532+ text = {
533+ OutlinedTextField(
534+ value = title,
535+ onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
536+ modifier = Modifier.fillMaxWidth(),
537+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
538+ label = { Text(stringResource(R.string.rename_session)) },
539+ singleLine = true,
540+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
541+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
542+ )
543+ },
544+ confirmButton = {
545+ TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
546+ },
547+ dismissButton = {
548+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
549+ },
550+ )
551+}
552+
553+@OptIn(ExperimentalMaterial3Api::class)
554+@Composable
555+private fun SessionCard(
556+ session: Session,
557+ preview: String?,
558+ unreadCount: Int,
559+ onClick: () -> Unit,
560+ onRename: () -> Unit,
561+ onDelete: () -> Unit,
562+ modifier: Modifier = Modifier,
563+ isSelected: Boolean = false,
564+) {
565+ // In two-pane mode the selected row is highlighted (border + container tint) so
566+ // the user can tell which conversation is open in the detail pane at a glance β€”
567+ // without it every row looks identical and the open session is a guess.
568+ val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
569+ else MaterialTheme.colorScheme.surface
570+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
571+ else androidx.compose.ui.graphics.Color.Transparent
572+ Card(
573+ modifier = modifier
574+ .fillMaxWidth()
575+ .clickable { onClick() },
576+ colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
577+ border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
578+ ) {
579+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
580+ Row(
581+ modifier = Modifier.fillMaxWidth(),
582+ verticalAlignment = Alignment.CenterVertically,
583+ ) {
584+ Column(modifier = Modifier.weight(1f)) {
585+ Row(verticalAlignment = Alignment.CenterVertically) {
586+ if (unreadCount > 0) {
587+ val unreadLabel = stringResource(R.string.unread_count, unreadCount)
588+ // Count badge: shows the number of unread messages so the user
589+ // can tell a single reply from a burst. Falls back to a dot for
590+ // a count of 1 (the common "one reply" case) to avoid clutter.
591+ if (unreadCount == 1) {
592+ Box(
593+ modifier = Modifier
594+ .padding(end = 8.dp)
595+ .size(10.dp)
596+ .clip(androidx.compose.foundation.shape.CircleShape)
597+ .background(MaterialTheme.colorScheme.primary)
598+ .semantics { contentDescription = unreadLabel },
599+ )
600+ } else {
601+ Box(
602+ modifier = Modifier
603+ .padding(end = 8.dp)
604+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
605+ .background(MaterialTheme.colorScheme.primary)
606+ .padding(horizontal = 6.dp, vertical = 1.dp)
607+ .semantics { contentDescription = unreadLabel },
608+ contentAlignment = Alignment.Center,
609+ ) {
610+ Text(
611+ unreadCount.toString(),
612+ style = MaterialTheme.typography.labelSmall,
613+ color = MaterialTheme.colorScheme.onPrimary,
614+ )
615+ }
616+ }
617+ }
618+ Text(
619+ session.displayTitle,
620+ style = MaterialTheme.typography.titleMedium,
621+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
622+ maxLines = 1,
623+ overflow = TextOverflow.Ellipsis,
624+ )
625+ }
626+ // Long-press the relative label to reveal the full absolute timestamp.
627+ RelativeTimeText(
628+ session.time?.updated ?: session.time?.created,
629+ modifier = Modifier.padding(top = 2.dp),
630+ )
631+ // The working directory the agent runs in for this session. Shown because
632+ // the list spans sessions across directories, so the folder name tells
633+ // otherwise-similar sessions apart at a glance.
634+ session.displayDirectory?.let { dir ->
635+ val dirDesc = stringResource(R.string.session_directory_desc, session.directory.orEmpty())
636+ Row(
637+ verticalAlignment = Alignment.CenterVertically,
638+ modifier = Modifier
639+ .padding(top = 2.dp)
640+ .semantics { contentDescription = dirDesc },
641+ ) {
642+ Icon(
643+ Icons.Filled.Folder,
644+ contentDescription = null,
645+ modifier = Modifier.size(12.dp),
646+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
647+ )
648+ Text(
649+ dir,
650+ style = MaterialTheme.typography.labelSmall,
651+ color = MaterialTheme.colorScheme.onSurfaceVariant,
652+ maxLines = 1,
653+ overflow = TextOverflow.Ellipsis,
654+ modifier = Modifier.padding(start = 4.dp),
655+ )
656+ }
657+ }
658+ }
659+ // Overflow menu replaces the inline edit + delete IconButtons so the
660+ // card row is decluttered β€” two always-visible icons per row made a
661+ // long list look busy. Swipe-to-delete and the overflow cover the same
662+ // actions; swipe remains the gesture path, overflow the discovery path.
663+ var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
664+ val renameLabel = stringResource(R.string.rename)
665+ val deleteLabel = stringResource(R.string.delete)
666+ val moreLabel = stringResource(R.string.more)
667+ Box {
668+ IconButton(onClick = { showRowMenu = true }) {
669+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
670+ }
671+ DropdownMenu(
672+ expanded = showRowMenu,
673+ onDismissRequest = { showRowMenu = false },
674+ ) {
675+ DropdownMenuItem(
676+ text = { Text(renameLabel) },
677+ onClick = { showRowMenu = false; onRename() },
678+ )
679+ DropdownMenuItem(
680+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
681+ onClick = { showRowMenu = false; onDelete() },
682+ )
683+ }
684+ }
685+ }
686+ if (!preview.isNullOrBlank()) {
687+ Spacer(Modifier.size(4.dp))
688+ Text(
689+ preview,
690+ style = MaterialTheme.typography.bodyMedium,
691+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
692+ maxLines = 2,
693+ overflow = TextOverflow.Ellipsis,
694+ )
695+ }
696+ }
697+ }
698+}
699+
700+@Composable
701+private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
702+ Column(
703+ modifier = modifier.padding(32.dp),
704+ horizontalAlignment = Alignment.CenterHorizontally,
705+ ) {
706+ Icon(
707+ Icons.Filled.ChatBubbleOutline,
708+ contentDescription = null,
709+ modifier = Modifier.size(56.dp),
710+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
711+ )
712+ Spacer(Modifier.size(16.dp))
713+ Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
714+ Spacer(Modifier.size(8.dp))
715+ Text(
716+ stringResource(R.string.no_sessions_hint),
717+ style = MaterialTheme.typography.bodyMedium,
718+ color = MaterialTheme.colorScheme.onSurfaceVariant,
719+ )
720+ Spacer(Modifier.size(20.dp))
721+ Button(onClick = onCreate) {
722+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
723+ Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
724+ }
725+ }
726+}
727+
728+/** Title + dropdown for quick-switching between saved servers. Highlights the active
729+ * server with a check mark and primary color so the user can tell which one they're
730+ * on at a glance. Includes an "Add server" item so adding a new server doesn't require
731+ * navigating out to Settings β†’ Manage servers β€” a long path for a common action. */
732+@Composable
733+private fun ServerSwitcherMenu(
734+ serverLabel: String,
735+ profiles: List<ServerProfile>,
736+ connectedId: String?,
737+ switchingId: String?,
738+ expanded: Boolean,
739+ onExpand: () -> Unit,
740+ onDismiss: () -> Unit,
741+ onSelect: (ServerProfile) -> Unit,
742+ onAddServer: () -> Unit,
743+) {
744+ Column {
745+ Box {
746+ Row(
747+ modifier = Modifier
748+ .clickable(role = Role.Button) { onExpand() },
749+ verticalAlignment = Alignment.CenterVertically,
750+ ) {
751+ Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
752+ Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
753+ }
754+ DropdownMenu(
755+ expanded = expanded,
756+ onDismissRequest = onDismiss,
757+ ) {
758+ profiles.forEach { profile ->
759+ val isActiveProfile = profile.id == connectedId
760+ DropdownMenuItem(
761+ text = {
762+ Row(verticalAlignment = Alignment.CenterVertically) {
763+ Column(modifier = Modifier.weight(1f)) {
764+ Text(
765+ profile.displayLabel,
766+ style = MaterialTheme.typography.bodyLarge,
767+ color = if (isActiveProfile) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
768+ maxLines = 1,
769+ overflow = TextOverflow.Ellipsis,
770+ )
771+ Text(
772+ profile.baseUrl,
773+ style = MaterialTheme.typography.bodySmall,
774+ color = MaterialTheme.colorScheme.onSurfaceVariant,
775+ maxLines = 1,
776+ overflow = TextOverflow.Ellipsis,
777+ )
778+ }
779+ if (isActiveProfile) {
780+ Icon(
781+ Icons.Filled.CheckCircle,
782+ contentDescription = stringResource(R.string.connected),
783+ tint = MaterialTheme.colorScheme.primary,
784+ modifier = Modifier.size(18.dp),
785+ )
786+ }
787+ if (switchingId == profile.id) {
788+ val switchingLabel = stringResource(R.string.loading)
789+ CircularProgressIndicator(
790+ Modifier
791+ .size(18.dp)
792+ .semantics { contentDescription = switchingLabel },
793+ strokeWidth = 2.dp,
794+ )
795+ }
796+ }
797+ },
798+ onClick = { onSelect(profile) },
799+ )
800+ }
801+ // Divider + Add server item so the user can add a server without leaving
802+ // the session list. The trailing icon reinforces the primary action.
803+ androidx.compose.material3.HorizontalDivider()
804+ DropdownMenuItem(
805+ text = { Text(stringResource(R.string.add_server)) },
806+ leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },
807+ onClick = {
808+ onDismiss()
809+ onAddServer()
810+ },
811+ )
812+ }
813+ }
814+ }
815+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+661
@@ -0,0 +1,661 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.model.Session
8+import soy.iko.opencode.data.model.SessionDeleted
9+import soy.iko.opencode.data.model.SessionUpdated
10+import soy.iko.opencode.data.model.TextPart
11+import soy.iko.opencode.data.model.ToolCompleted
12+import soy.iko.opencode.data.model.ToolError
13+import soy.iko.opencode.data.model.ToolPart
14+import soy.iko.opencode.data.model.ToolRunning
15+import soy.iko.opencode.data.network.EventStreamClient
16+import soy.iko.opencode.data.network.NetworkConfig
17+import soy.iko.opencode.di.AppContainer
18+import soy.iko.opencode.R
19+import soy.iko.opencode.util.runCatchingCancellable
20+import android.util.Log
21+import kotlinx.coroutines.CancellationException
22+import kotlinx.coroutines.ExperimentalCoroutinesApi
23+import kotlinx.coroutines.Job
24+import kotlinx.coroutines.channels.BufferOverflow
25+import kotlinx.coroutines.delay
26+import kotlinx.coroutines.flow.MutableSharedFlow
27+import kotlinx.coroutines.flow.MutableStateFlow
28+import kotlinx.coroutines.flow.SharedFlow
29+import kotlinx.coroutines.flow.SharingStarted
30+import kotlinx.coroutines.flow.StateFlow
31+import kotlinx.coroutines.flow.asSharedFlow
32+import kotlinx.coroutines.flow.asStateFlow
33+import kotlinx.coroutines.flow.collectLatest
34+import kotlinx.coroutines.flow.flatMapLatest
35+import kotlinx.coroutines.flow.flowOf
36+import kotlinx.coroutines.flow.stateIn
37+import kotlinx.coroutines.flow.update
38+import kotlinx.coroutines.joinAll
39+import kotlinx.coroutines.launch
40+import kotlinx.coroutines.sync.Semaphore
41+import kotlinx.coroutines.sync.withPermit
42+
43+/** Sort order for the session list. */
44+enum class SessionSortMode { RECENT, TITLE }
45+
46+/** Directory options for the new-session picker: worktree paths the server knows about
47+ * ([projects]) and the server's default working directory ([serverDefault], its cwd).
48+ * [loaded] distinguishes "not fetched yet" from "fetched, none found". */
49+@Immutable
50+data class DirectoryOptionsState(
51+ val loading: Boolean = false,
52+ val loaded: Boolean = false,
53+ val projects: List<String> = emptyList(),
54+ val serverDefault: String? = null,
55+)
56+
57+@Immutable
58+data class SessionListState(
59+ val sessions: List<Session> = emptyList(),
60+ val query: String = "",
61+ val previews: Map<String, String> = emptyMap(),
62+ val loading: Boolean = true,
63+ val error: String? = null,
64+ val sortMode: SessionSortMode = SessionSortMode.RECENT,
65+) {
66+ /** Sessions filtered by the current search query (title or preview text). */
67+ val filtered: List<Session>
68+ get() {
69+ val q = query.trim()
70+ if (q.isEmpty()) return sessions
71+ return sessions.filter { session ->
72+ session.displayTitle.contains(q, ignoreCase = true) ||
73+ (previews[session.id]?.contains(q, ignoreCase = true) == true)
74+ }
75+ }
76+}
77+
78+/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
79+ * display title asc (case-insensitive), falling back to recency for ties. */
80+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
81+ SessionSortMode.RECENT ->
82+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
83+ SessionSortMode.TITLE ->
84+ // CASE_INSENSITIVE_ORDER avoids allocating a lowercased title per comparison; this
85+ // sort re-runs on every debounced list flush during streaming.
86+ sortedWith(
87+ compareBy<Session, String>(String.CASE_INSENSITIVE_ORDER) { it.displayTitle }
88+ .thenByDescending { it.time?.updated ?: it.time?.created ?: 0L },
89+ )
90+}
91+
92+class SessionListViewModel(private val container: AppContainer) : ViewModel() {
93+
94+ /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
95+ @OptIn(ExperimentalCoroutinesApi::class)
96+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
97+ container.activeConnection
98+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
99+ .stateIn(
100+ scope = viewModelScope,
101+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
102+ initialValue = EventStreamClient.ConnectionState.Disconnected,
103+ )
104+
105+ /** Sessions with activity that arrived while not being viewed, mapped to their
106+ * unread event count so the list can show "N unread" instead of a bare dot. */
107+ val unread: StateFlow<Map<String, Int>> = container.unread
108+
109+ val profiles: StateFlow<List<ServerProfile>> =
110+ container.profileStore.profiles.stateIn(
111+ scope = viewModelScope,
112+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
113+ initialValue = emptyList(),
114+ )
115+
116+ private val _serverLabel = MutableStateFlow(
117+ container.activeConnection.value?.profile?.displayLabel ?: container.string(R.string.app_name)
118+ )
119+ val serverLabel: StateFlow<String> = _serverLabel.asStateFlow()
120+
121+ private val _switchingId = MutableStateFlow<String?>(null)
122+ val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
123+
124+ /** True while a session creation is in flight, so the FAB can disable itself and
125+ * prevent double-taps from firing two createSession calls. */
126+ private val _creating = MutableStateFlow(false)
127+ val creating: StateFlow<Boolean> = _creating.asStateFlow()
128+
129+ private val _state = MutableStateFlow(SessionListState())
130+ val state: StateFlow<SessionListState> = _state.asStateFlow()
131+
132+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
133+ private val _refreshing = MutableStateFlow(false)
134+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
135+
136+ /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
137+ /** One-shot transient errors surfaced as snackbars. A SharedFlow (not StateFlow) so
138+ * each emission is delivered independently β€” two rapid failures both get a snackbar
139+ * instead of the second silently overwriting the first. */
140+ private val _transientErrors = MutableSharedFlow<String>(
141+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
142+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
143+ )
144+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
145+
146+ /** One-shot events carrying the id of a session marked for deferred deletion, so the
147+ * UI can show an Undo snackbar. */
148+ private val _undoEvents = MutableSharedFlow<String>(
149+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
150+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
151+ )
152+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
153+
154+ /** Session awaiting the undo window to expire before its REST delete fires. Holds the
155+ * Session so Undo can restore it. */
156+ // Keyed by session id, not a single field: the undo window lets several deletes be
157+ // in flight at once, and a single field would let a second delete overwrite the
158+ // first β€” dropping the first's server-side delete while it stays removed from the UI.
159+ // Concurrent because the delete timer's completion callbacks run on the container's
160+ // process-lived scope while the SSE collector mutates this on viewModelScope.
161+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Session>()
162+
163+ private var previewJob: Job? = null
164+ private var refreshJob: Job? = null
165+
166+ /** Per-session in-flight preview loads, so rapid SSE events for the same session
167+ * coalesce into a single request instead of firing one per event. Accessed from
168+ * multiple coroutines (the SSE event handler and [loadPreview]), so the
169+ * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
170+ private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
171+ private val previewLock = Any()
172+
173+ /** Accumulates SessionUpdated events between debounce flushes so a burst of SSE
174+ * updates during active streaming coalesces into one list rebuild + recomposition
175+ * instead of one per event. */
176+ private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
177+ private var sessionUpdateJob: Job? = null
178+
179+ private val activeProfileId: String?
180+ get() = container.activeConnection.value?.profile?.id
181+
182+ init { refresh(); observeSessionEvents() }
183+
184+ /**
185+ * Keep the session list live by reacting to SSE session events. New/renamed
186+ * sessions upsert and re-sort; deleted sessions drop immediately β€” no manual
187+ * refresh needed when activity happens server-side.
188+ */
189+ private fun observeSessionEvents() {
190+ viewModelScope.launch {
191+ container.activeConnection.collectLatest { conn ->
192+ if (conn == null) return@collectLatest
193+ _serverLabel.value = conn.profile.displayLabel
194+ // Cancel any in-flight preview loads from the previous connection so
195+ // they don't complete against the old server and write stale previews
196+ // for sessions that may not exist on the new one. collectLatest cancels
197+ // the SSE collector, but the jobs in livePreviewJobs are independent
198+ // launches on viewModelScope that survive the collector cancellation.
199+ synchronized(previewLock) {
200+ livePreviewJobs.values.forEach { it.cancel() }
201+ livePreviewJobs.clear()
202+ }
203+ sessionUpdateJob?.cancel()
204+ pendingSessionUpdates.clear()
205+ // Cancel any deferred deletes still pending from the previous connection.
206+ // Their captured connection is now closed, so they can only fail; letting
207+ // them run would fire onError and re-inject a session belonging to the old
208+ // server into this (new) server's list. Cancelling drops them cleanly β€” the
209+ // session simply survives on its original server (unavoidable once we've
210+ // switched away) rather than appearing as a foreign row here.
211+ pendingDeletes.keys.toList().forEach { container.cancelSessionDelete(it) }
212+ pendingDeletes.clear()
213+ // Reload the session list when a connection becomes available so the
214+ // initial load (which may have run before auto-reconnect finished and
215+ // found no connection) doesn't leave a stale "not connected" error.
216+ refresh()
217+ try {
218+ conn.events.events.collect { event ->
219+ when (event) {
220+ is SessionUpdated -> {
221+ val session = event.properties.info
222+ // Ignore updates for a session the user just deleted (still
223+ // within its undo window): the server hasn't deleted it yet
224+ // and can legitimately emit updates, but buffering them here
225+ // would resurrect the optimistically-hidden row mid-undo.
226+ if (pendingDeletes.containsKey(session.id)) return@collect
227+ pendingSessionUpdates[session.id] = session
228+ if (sessionUpdateJob?.isActive != true) {
229+ sessionUpdateJob = viewModelScope.launch {
230+ delay(NetworkConfig.sessionUpdateDebounceMs)
231+ flushPendingSessionUpdates()
232+ }
233+ }
234+ }
235+ is SessionDeleted -> {
236+ val id = event.properties.info?.id ?: event.properties.sessionID
237+ if (id != null) {
238+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
239+ // Drop any buffered update for this id so a pending flush
240+ // can't re-add the just-deleted session to the list.
241+ pendingSessionUpdates.remove(id)
242+ // The session is gone server-side; cancel any pending local
243+ // delete so its deferred REST call doesn't later 404 and
244+ // trigger the failure-restore path that resurrects the row.
245+ pendingDeletes.remove(id)
246+ container.draftStore.remove(id)
247+ _state.update { s ->
248+ s.copy(
249+ sessions = s.sessions.filterNot { it.id == id },
250+ previews = s.previews - id,
251+ )
252+ }
253+ }
254+ }
255+ else -> {}
256+ }
257+ }
258+ } catch (e: CancellationException) {
259+ throw e
260+ } catch (e: Exception) {
261+ Log.w("SessionListVM", "SSE event collector error", e)
262+ }
263+ }
264+ }
265+ }
266+
267+ /** Apply all buffered SessionUpdated events at once: upsert into the sessions list,
268+ * re-sort by recency, and kick off preview fetches. Called after the debounce delay. */
269+ private fun flushPendingSessionUpdates() {
270+ val batch = pendingSessionUpdates.toMap()
271+ if (batch.isEmpty()) return
272+ // Remove only the snapshotted key/value pairs rather than clear()-ing the whole
273+ // map: an update for a session that arrived between the toMap() snapshot and here
274+ // would otherwise be dropped. The key/value remove overload also leaves a newer
275+ // value for the same key in place to be flushed on the next pass.
276+ batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
277+ val changedIds = mutableSetOf<String>()
278+ _state.update { s ->
279+ // Reset per-attempt: MutableStateFlow.update may re-run this lambda on CAS
280+ // contention, and we only want the ids that changed in the winning attempt.
281+ changedIds.clear()
282+ val byId = s.sessions.associateBy { it.id }.toMutableMap()
283+ batch.forEach { (id, session) ->
284+ // Final guard: never re-add a session that's pending deletion (its undo
285+ // window is still open), even if an update slipped into this batch.
286+ if (pendingDeletes.containsKey(id)) return@forEach
287+ if (byId[id] != session) { byId[id] = session; changedIds += id }
288+ }
289+ // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
290+ // new state (and trigger recomposition) when the batch held no real changes
291+ // (e.g. a duplicate SessionUpdated for an unchanged session).
292+ if (changedIds.isEmpty()) return@update s
293+ val sorted = byId.values.sortedByMode(s.sortMode)
294+ // If the new ordering matches the existing one (e.g. only the already-newest
295+ // session updated its timestamp), reuse the same list instance so downstream
296+ // skips a recomposition for an identical reference.
297+ val sessions = if (sorted == s.sessions) s.sessions else sorted
298+ s.copy(sessions = sessions)
299+ }
300+ // Only refetch previews for sessions that actually changed β€” a duplicate
301+ // SessionUpdated for an unchanged session shouldn't trigger a full-history download.
302+ changedIds.forEach { loadPreview(it) }
303+ }
304+
305+ fun setQuery(query: String) {
306+ _state.update { it.copy(query = query) }
307+ }
308+
309+ /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
310+ * re-fetch the session list so the UI reflects the current server state. */
311+ fun retryConnection() {
312+ container.activeConnection.value?.events?.triggerReconnect()
313+ refresh()
314+ }
315+
316+ fun refresh() {
317+ val conn = container.activeConnection.value
318+ if (conn == null) {
319+ if (_state.value.sessions.isEmpty()) {
320+ _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
321+ } else {
322+ _state.update { it.copy(loading = false) }
323+ _transientErrors.tryEmit(container.string(R.string.not_connected))
324+ }
325+ _refreshing.value = false
326+ return
327+ }
328+ _serverLabel.value = conn.profile.displayLabel
329+ // Only show the full-screen spinner on the initial load (empty list). For a
330+ // pull-to-refresh or SSE-reconnect refresh with an existing list, the dedicated
331+ // PullToRefreshBox indicator (_refreshing) covers it β€” flipping `loading` here
332+ // would blank the populated list behind a centered spinner.
333+ _state.update { it.copy(loading = it.sessions.isEmpty(), error = null) }
334+ _refreshing.value = true
335+ refreshJob?.cancel()
336+ refreshJob = viewModelScope.launch {
337+ val self = coroutineContext[Job]
338+ try {
339+ runCatchingCancellable { conn.repository.listSessions() }
340+ .onSuccess { list ->
341+ // Drop sessions that were optimistically deleted but whose deferred
342+ // REST delete hasn't fired yet β€” the server still returns them, and
343+ // re-adding them here would resurrect a just-hidden row (and later
344+ // collide on the LazyColumn id key when undo/restore re-appends it).
345+ val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }.sortedByMode(_state.value.sortMode)
346+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
347+ loadPreviews(sorted)
348+ }
349+ .onFailure {
350+ if (_state.value.sessions.isNotEmpty()) {
351+ _state.update { it.copy(loading = false) }
352+ _transientErrors.tryEmit(container.friendlyError(it))
353+ } else {
354+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
355+ }
356+ }
357+ } finally {
358+ // Only clear the spinner if we're still the active refresh job. When a
359+ // newer refresh() cancels this one, its finally runs asynchronously on
360+ // the Main dispatcher and would otherwise clobber _refreshing back to
361+ // false while the newer refresh is still in flight.
362+ if (refreshJob === self) _refreshing.value = false
363+ }
364+ }
365+ }
366+
367+ /**
368+ * Reload the preview for a single session (used by the live SSE handler).
369+ * Cancels any prior in-flight load for the same session so a burst of
370+ * [SessionUpdated] events coalesces into a single request. A short debounce
371+ * delay before the fetch avoids launching one full-history download per event
372+ * during active streaming β€” the fetch runs only after events quiet down.
373+ */
374+ private fun loadPreview(sessionId: String) {
375+ val conn = container.activeConnection.value ?: return
376+ val api = conn.api
377+ val toolFormat = container.string(R.string.tool_preview)
378+ synchronized(previewLock) {
379+ livePreviewJobs.remove(sessionId)?.cancel()
380+ livePreviewJobs[sessionId] = viewModelScope.launch {
381+ val job = coroutineContext[kotlinx.coroutines.Job]
382+ try {
383+ delay(NetworkConfig.previewDebounceMs)
384+ val preview = fetchSessionPreview(api, sessionId, toolFormat) ?: return@launch
385+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
386+ } catch (e: CancellationException) {
387+ throw e
388+ } catch (e: Exception) {
389+ Log.w("SessionListVM", "Preview load failed for $sessionId", e)
390+ } finally {
391+ if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
392+ }
393+ }
394+ }
395+ }
396+
397+ /** Fetch the last text part of each session for list previews (best-effort, bounded).
398+ * Sessions that already have a cached preview are skipped β€” their previews are kept
399+ * fresh by the live SSE handler ([loadPreview] on [SessionUpdated]). Each preview
400+ * fetch downloads the full conversation history, so re-fetching all of them on every
401+ * refresh is wasteful when most are already cached. */
402+ private fun loadPreviews(sessions: List<Session>) {
403+ previewJob?.cancel()
404+ val conn = container.activeConnection.value ?: return
405+ val api = conn.api
406+ val toolFormat = container.string(R.string.tool_preview)
407+ // Keep previews for sessions still in the list; drop stale ones without
408+ // clearing the whole map (which would cause a visual flash).
409+ val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
410+ _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
411+ val cached = _state.value.previews.keys
412+ val toFetch = sessions.take(NetworkConfig.maxPreviewSessions).filter { it.id !in cached }
413+ if (toFetch.isEmpty()) return
414+ previewJob = viewModelScope.launch {
415+ val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
416+ toFetch.map { session ->
417+ launch {
418+ semaphore.withPermit {
419+ val preview = fetchSessionPreview(api, session.id, toolFormat) ?: return@withPermit
420+ _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
421+ }
422+ }
423+ }.joinAll()
424+ }
425+ }
426+
427+ /**
428+ * Create a new session, optionally in a specific worktree [directory] (the agent's
429+ * working directory for that session). A null/blank directory lets the server use its
430+ * launch cwd β€” the pre-directory behavior. The chosen directory is remembered
431+ * ([lastChosenDirectory]) so the next new-session picker can preselect it.
432+ */
433+ fun createSession(directory: String? = null, onCreated: (String) -> Unit) {
434+ val conn = container.activeConnection.value ?: return
435+ if (!_creating.compareAndSet(false, true)) return
436+ val dir = directory?.takeIf { it.isNotBlank() }
437+ viewModelScope.launch {
438+ try {
439+ runCatchingCancellable { conn.repository.createSession(directory = dir) }
440+ .onSuccess { lastChosenDirectory = dir; onCreated(it.id); refresh() }
441+ .onFailure {
442+ _state.update { it.copy(error = null) }
443+ _transientErrors.tryEmit(container.friendlyError(it))
444+ }
445+ } finally {
446+ _creating.value = false
447+ }
448+ }
449+ }
450+
451+ /** The directory the user last created a session in (this VM lifetime), so the picker
452+ * can preselect it. Best-effort: not persisted across process death. */
453+ var lastChosenDirectory: String? = null
454+ private set
455+
456+ private val _directoryOptions = MutableStateFlow(DirectoryOptionsState())
457+ val directoryOptions: StateFlow<DirectoryOptionsState> = _directoryOptions.asStateFlow()
458+
459+ private var directoryOptionsJob: Job? = null
460+
461+ /**
462+ * Load the directory options for the new-session picker: the server's known projects
463+ * (`GET /project`) plus its default working directory (`GET /path`). Best-effort β€” a
464+ * failure leaves an empty option set, and the picker still offers the directories of
465+ * existing sessions and manual path entry. Safe to call on each dialog open; an
466+ * in-flight load is not restarted.
467+ */
468+ fun loadDirectoryOptions() {
469+ val conn = container.activeConnection.value ?: return
470+ if (_directoryOptions.value.loading) return
471+ _directoryOptions.update { it.copy(loading = true) }
472+ directoryOptionsJob = viewModelScope.launch {
473+ val projects = runCatchingCancellable { conn.api.listProjects() }
474+ .getOrDefault(emptyList())
475+ .mapNotNull { it.worktree.takeIf { w -> w.isNotBlank() } }
476+ .distinct()
477+ val serverDefault = runCatchingCancellable { conn.api.currentPath() }
478+ .getOrNull()?.directory?.takeIf { it.isNotBlank() }
479+ _directoryOptions.value = DirectoryOptionsState(
480+ loading = false,
481+ loaded = true,
482+ projects = projects,
483+ serverDefault = serverDefault,
484+ )
485+ }
486+ }
487+
488+ /**
489+ * Mark [session] as pending deletion and hide it from the list immediately, then
490+ * surface an Undo via [undoEvents]. The actual REST delete is deferred by
491+ * [NetworkConfig.undoDeleteDelayMs]; if [undoDelete] is called before it fires,
492+ * the session is restored. Otherwise the delete proceeds.
493+ */
494+ fun deleteSession(session: Session) {
495+ // Optimistically remove from the visible list so the UI feels instant.
496+ _state.update { s ->
497+ s.copy(sessions = s.sessions.filterNot { it.id == session.id })
498+ }
499+ // Hold the pending delete so the snackbar can undo it.
500+ pendingDeletes[session.id] = session
501+ // Drop any already-buffered update for this session so a pending flush during the
502+ // undo window can't re-add the row we just optimistically hid.
503+ pendingSessionUpdates.remove(session.id)
504+ _undoEvents.tryEmit(session.id)
505+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
506+ // if this VM is cleared during the undo window (e.g. a disconnect pops the session
507+ // list), a viewModelScope job would be cancelled and the server-side delete would
508+ // never fire β€” the row was already optimistically hidden, so the session would
509+ // silently resurrect on the next refresh. The container also clears the stored draft.
510+ container.scheduleSessionDelete(
511+ id = session.id,
512+ delayMs = NetworkConfig.undoDeleteDelayMs,
513+ onDeleted = {
514+ pendingDeletes.remove(session.id)
515+ refresh()
516+ },
517+ onError = {
518+ // Restore the session since the delete failed (or there was no connection to
519+ // delete on). Guard against a concurrent refresh having already re-added it so
520+ // we never produce two rows with the same id.
521+ pendingDeletes.remove(session.id)
522+ _state.update { s ->
523+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
524+ }
525+ _transientErrors.tryEmit(container.friendlyError(it))
526+ },
527+ )
528+ }
529+
530+ /** Cancel a pending delete (Undo snackbar action) and restore the session. */
531+ fun undoDelete(sessionId: String) {
532+ // Cancel the deferred container-scoped delete first. If it already fired (cancel
533+ // returns false), the delete is committing/committed β€” drop the token and don't
534+ // restore, so we never re-add a session that's gone on the server.
535+ if (!container.cancelSessionDelete(sessionId)) {
536+ pendingDeletes.remove(sessionId)
537+ return
538+ }
539+ val pending = pendingDeletes.remove(sessionId) ?: return
540+ _state.update { s ->
541+ // Dedup by id in case a refresh during the undo window already re-added it.
542+ s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode))
543+ }
544+ }
545+
546+ fun setSortMode(mode: SessionSortMode) {
547+ if (mode == _state.value.sortMode) return
548+ _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
549+ }
550+
551+ fun renameSession(session: Session, newTitle: String) {
552+ val conn = container.activeConnection.value ?: return
553+ val title = newTitle.trim()
554+ if (title.isEmpty() || title == session.title) return
555+ viewModelScope.launch {
556+ runCatchingCancellable { conn.api.updateSession(session.id, title) }
557+ .onSuccess { refresh() }
558+ .onFailure {
559+ _state.update { it.copy(error = null) }
560+ _transientErrors.tryEmit(container.friendlyError(it))
561+ }
562+ }
563+ }
564+
565+ /** Quick-switch to a different saved server without leaving the session list. */
566+ fun switchServer(profile: ServerProfile) {
567+ if (profile.id == activeProfileId) return
568+ if (_switchingId.value != null) return
569+ val previousProfile = container.activeConnection.value?.profile
570+ val savedUnread = container.unread.value
571+ _switchingId.value = profile.id
572+ _state.update { it.copy(loading = true, error = null) }
573+ viewModelScope.launch {
574+ val result = runCatchingCancellable {
575+ val conn = container.connect(profile)
576+ conn.api.ping()
577+ }
578+ _switchingId.value = null
579+ result.onSuccess { refresh() }
580+ .onFailure { error ->
581+ // connect() already closed the old connection; try to restore the
582+ // previous profile so the session list survives. If there was none,
583+ // show the error state.
584+ container.disconnect()
585+ if (previousProfile != null) {
586+ val restored = runCatchingCancellable { container.connect(previousProfile) }
587+ // connect() clears unread state; restore what we saved so the
588+ // user doesn't lose unread badges on a failed server switch.
589+ // Pass the real captured count so a session badged with "5
590+ // unread" is restored as 5, not 1.
591+ savedUnread.forEach { (id, count) -> container.restoreUnread(id, count) }
592+ if (restored.isSuccess) {
593+ // Clear stale sessions from the old server; the SSE observer
594+ // will reload from the restored connection via refresh(). Preserve
595+ // the user's sort mode and search query β€” the switch failed and they
596+ // were bounced back to the same server, so resetting those (via a
597+ // fresh SessionListState) would be an unexpected loss on an error path.
598+ _state.update {
599+ it.copy(
600+ sessions = emptyList(),
601+ previews = emptyMap(),
602+ loading = false,
603+ error = null,
604+ )
605+ }
606+ // Surface the switch failure as a transient snackbar β€” refresh()
607+ // would clear a state.error, so use transientErrors which survives it.
608+ _transientErrors.tryEmit(container.friendlyError(error))
609+ refresh()
610+ } else {
611+ // Restore also failed β€” the user is now disconnected from
612+ // both servers. Surface both failures so the user understands
613+ // why they're looking at an empty list.
614+ val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
615+ _state.value = SessionListState(
616+ loading = false,
617+ error = container.string(R.string.error_switch_and_restore_failed,
618+ container.friendlyError(error), restoreMsg),
619+ )
620+ }
621+ } else {
622+ _state.value = SessionListState(loading = false, error = container.friendlyError(error))
623+ }
624+ }
625+ }
626+ }
627+}
628+
629+/**
630+ * Fetch a preview of a session's most recent activity, truncated to
631+ * [NetworkConfig.previewTextMaxLength]. Returns the last text part if present; otherwise
632+ * a short summary of the last tool call (e.g. "πŸ”§ read") so a session whose last activity
633+ * was a tool call (no assistant text yet) still shows a meaningful preview instead of
634+ * stale/empty. Returns null on failure or when there's nothing usable.
635+ *
636+ * [toolPreviewFormat] is a format string (from resources) with a %1$s placeholder for
637+ * the tool title/name, so the wrench emoji prefix is a presentation concern resolved
638+ * by the caller rather than hardcoded here.
639+ */
640+private suspend fun fetchSessionPreview(
641+ api: soy.iko.opencode.data.network.OpencodeApiClient,
642+ sessionId: String,
643+ toolPreviewFormat: String,
644+): String? = runCatchingCancellable {
645+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
646+ val text = msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
647+ if (text != null) return@let text
648+ val tool = msg.parts.filterIsInstance<ToolPart>().lastOrNull()
649+ if (tool != null) {
650+ val title = when (val st = tool.state) {
651+ is ToolRunning -> st.title
652+ is ToolCompleted -> st.title
653+ is ToolError -> st.error
654+ else -> null
655+ }
656+ return@let title?.takeIf { it.isNotBlank() }?.let { toolPreviewFormat.format(it) }
657+ ?: toolPreviewFormat.format(tool.tool)
658+ }
659+ null
660+ }
661+}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktnew file+157
@@ -0,0 +1,157 @@
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.remember
22+import androidx.compose.runtime.saveable.rememberSaveable
23+import androidx.compose.runtime.setValue
24+import androidx.compose.ui.Alignment
25+import androidx.compose.ui.Modifier
26+import androidx.compose.ui.res.stringResource
27+import androidx.compose.ui.unit.dp
28+import androidx.lifecycle.compose.collectAsStateWithLifecycle
29+import soy.iko.opencode.R
30+import soy.iko.opencode.data.network.NetworkConfig
31+import soy.iko.opencode.di.AppContainer
32+import soy.iko.opencode.ui.chat.ChatScreen
33+
34+/**
35+ * Master–detail layout for wide screens (β‰₯ 840dp, e.g. tablets / unfolded foldables):
36+ * the session list on the left, the selected conversation on the right. On compact
37+ * widths the app uses the single-pane [androidx.navigation] back stack instead.
38+ *
39+ * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
40+ * two panes are self-contained. Selecting a session updates the detail pane rather than
41+ * pushing a destination; the chat [onBack] clears the selection. The chat composition is
42+ * keyed by session id so each conversation gets its own ViewModel.
43+ */
44+@Composable
45+fun TwoPaneSessionChat(
46+ container: AppContainer,
47+ onOpenFiles: () -> Unit,
48+ onOpenFile: (String) -> Unit,
49+ onOpenSettings: () -> Unit,
50+ onDisconnect: () -> Unit,
51+ onAddServer: () -> Unit,
52+ modifier: Modifier = Modifier,
53+) {
54+ var selected by rememberSaveable { mutableStateOf<String?>(null) }
55+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
56+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
57+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
58+
59+ // A notification tap / deep link requests a session: open it in the detail pane.
60+ LaunchedEffect(pendingOpenSession) {
61+ pendingOpenSession?.let {
62+ selected = it
63+ container.consumePendingOpenSession()
64+ }
65+ }
66+
67+ // Clear the selection when the active server profile changes (a server switch via
68+ // the ServerSwitcherMenu). Without this, `selected` still points at the old
69+ // server's session id and the detail pane renders a ChatScreen for a session that
70+ // doesn't exist on the new server, showing a load error with no explanation.
71+ // Skips the initial transition (null -> a profile id) so a deep link / pending
72+ // open session that sets `selected` on the same first composition isn't clobbered.
73+ var lastProfileId by remember { mutableStateOf<String?>(null) }
74+ LaunchedEffect(activeConnection?.profile?.id) {
75+ val currentId = activeConnection?.profile?.id
76+ if (lastProfileId != null && lastProfileId != currentId) selected = null
77+ lastProfileId = currentId
78+ }
79+
80+ // Inject a pending share into the currently selected session's draft (if any).
81+ // Unlike single-pane mode where the session list is navigated to specifically for
82+ // the share, in two-pane mode the list is always visible β€” so we inject into the
83+ // session the user is already viewing. If no session is selected, the share is
84+ // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
85+ // changes to non-null).
86+ LaunchedEffect(pendingShare, selected) {
87+ val target = selected
88+ if (pendingShare != null && target != null) {
89+ container.consumePendingShare()?.let { container.draftStore.set(target, it) }
90+ }
91+ }
92+
93+ Row(modifier = modifier.fillMaxSize()) {
94+ Box(
95+ modifier = Modifier
96+ .weight(NetworkConfig.twoPaneLeftWeight)
97+ .widthIn(max = 460.dp),
98+ ) {
99+ SessionListScreen(
100+ container = container,
101+ onOpenSession = { id ->
102+ selected = id
103+ },
104+ onDisconnect = onDisconnect,
105+ onOpenFiles = onOpenFiles,
106+ onOpenSettings = onOpenSettings,
107+ onAddServer = onAddServer,
108+ selectedSessionId = selected,
109+ )
110+ }
111+
112+ Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
113+ val sessionId = selected
114+ if (sessionId == null) {
115+ EmptyDetail()
116+ } else {
117+ BackHandler { selected = null }
118+ // key() on the session id so switching conversations in two-pane mode
119+ // gives ChatScreen a fresh composition. Without it, position-anchored
120+ // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
121+ // that wires error/retry snackbars) persists across the switch: the new
122+ // session inherits the old scroll offset, skips its initial scroll-to-bottom,
123+ // and its error events go unhandled. viewModel(key=sessionId) already swaps
124+ // the VM; this aligns the composition state with it.
125+ key(sessionId) {
126+ ChatScreen(
127+ container = container,
128+ sessionId = sessionId,
129+ onBack = { selected = null },
130+ onOpenFile = onOpenFile,
131+ )
132+ }
133+ }
134+ }
135+ }
136+}
137+
138+@Composable
139+private fun EmptyDetail() {
140+ Column(
141+ modifier = Modifier.fillMaxSize().padding(32.dp),
142+ verticalArrangement = Arrangement.Center,
143+ horizontalAlignment = Alignment.CenterHorizontally,
144+ ) {
145+ Icon(
146+ Icons.AutoMirrored.Filled.Chat,
147+ contentDescription = null,
148+ tint = MaterialTheme.colorScheme.outline,
149+ )
150+ Text(
151+ stringResource(R.string.empty_detail_pane),
152+ style = MaterialTheme.typography.bodyLarge,
153+ color = MaterialTheme.colorScheme.onSurfaceVariant,
154+ modifier = Modifier.padding(top = 12.dp),
155+ )
156+ }
157+}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktnew file+403

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+411

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Color.ktdeletedβˆ’19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+110βˆ’38

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktdeletedβˆ’15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktnew file+87

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktnew file+35

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+13

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmlnew file+6

This diff is too big to show here.

app/src/main/res/values-night/colors.xmldeletedβˆ’4

This diff is too big to show here.

app/src/main/res/values-night/themes.xmldeletedβˆ’8

This diff is too big to show here.

app/src/main/res/values/colors.xml+2βˆ’1

This diff is too big to show here.

app/src/main/res/values/strings.xml+280

This diff is too big to show here.

app/src/main/res/values/themes.xml+10βˆ’9

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmlnew file+15

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmlnew file+15

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktnew file+144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktnew file+102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktnew file+88

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktnew file+91

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktdeletedβˆ’69

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktnew file+37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktnew file+42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktnew file+66

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktnew file+238

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktnew file+46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktnew file+60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktnew file+385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktnew file+73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktnew file+549

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktnew file+90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktnew file+55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktnew file+219

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktnew file+464

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktnew file+572

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktnew file+387

This diff is too big to show here.

build.gradle.kts+1βˆ’1

This diff is too big to show here.

config/detekt/detekt.ymlnew file+60

This diff is too big to show here.

flake.lock+3βˆ’3

This diff is too big to show here.

flake.nix+17βˆ’27

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javadeletedβˆ’38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javadeletedβˆ’33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javadeletedβˆ’12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javadeletedβˆ’195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javadeletedβˆ’146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javadeletedβˆ’54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javadeletedβˆ’11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javadeletedβˆ’56

This diff is too big to show here.

generated/com/novi/serde/Int128.javadeletedβˆ’11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javadeletedβˆ’11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javadeletedβˆ’58

This diff is too big to show here.

generated/com/novi/serde/Slice.javadeletedβˆ’40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javadeletedβˆ’40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javadeletedβˆ’46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javadeletedβˆ’54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javadeletedβˆ’61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javadeletedβˆ’67

This diff is too big to show here.

generated/com/novi/serde/Unit.javadeletedβˆ’20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javadeletedβˆ’11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javadeletedβˆ’150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javadeletedβˆ’131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javadeletedβˆ’27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javadeletedβˆ’150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javadeletedβˆ’77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javadeletedβˆ’86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javadeletedβˆ’71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javadeletedβˆ’185

This diff is too big to show here.

gradle.properties+2βˆ’5

This diff is too big to show here.

gradle/libs.versions.toml+50βˆ’25

This diff is too big to show here.

renovate.jsonnew file+32

This diff is too big to show here.

settings.gradle.ktsβˆ’3

This diff is too big to show here.

shared/Cargo.lockdeletedβˆ’1358

This diff is too big to show here.

shared/Cargo.tomldeletedβˆ’31

This diff is too big to show here.

shared/src/app.rsdeletedβˆ’1051

This diff is too big to show here.

shared/src/bin/codegen.rsdeletedβˆ’35

This diff is too big to show here.

shared/src/lib.rsdeletedβˆ’17

This diff is too big to show here.

shared/src/protocol.rsdeletedβˆ’248

This diff is too big to show here.