git.iko.soy/opencode-android
on master help sign in
.direnv/bin/nix-direnv-reloaddeleted−19
since splitsnapshots
unifiedsplit
@@ -1,19 +0,0 @@
1−#!/usr/bin/env bash
2−set -e
3−if [[ ! -d "/home/iko/opencode-android" ]]; then
4− echo "Cannot find source directory; Did you move it?"
5− echo "(Looking for "/home/iko/opencode-android")"
6− echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
7− exit 1
8−fi
9−
10−# rebuild the cache forcefully
11−_nix_direnv_force_reload=1 direnv exec "/home/iko/opencode-android" true
12−
13−# Update the mtime for .envrc.
14−# This will cause direnv to reload again - but without re-building.
15−touch "/home/iko/opencode-android/.envrc"
16−
17−# Also update the timestamp of whatever profile_rc we have.
18−# This makes sure that we know we are up to date.
19−touch -r "/home/iko/opencode-android/.envrc" "/home/iko/opencode-android/.direnv"/*.rc
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfadeleted−1
@@ -1 +0,0 @@
1−/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcdeleted−2250
@@ -1,2250 +0,0 @@
1−unset shellHook
2−PATH=${PATH:-}
3−nix_saved_PATH="$PATH"
4−XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
5−nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
6−ANDROID_HOME='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
7−export ANDROID_HOME
8−ANDROID_SDK_ROOT='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
9−export ANDROID_SDK_ROOT
10−AR='ar'
11−export AR
12−AS='as'
13−export AS
14−BASH='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
15−CC='gcc'
16−export CC
17−CLASSPATH=''
18−export CLASSPATH
19−CONFIG_SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
20−export CONFIG_SHELL
21−CXX='g++'
22−export CXX
23−GRADLE_OPTS='-Dorg.gradle.project.android.aapt2FromMavenOverride=/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2'
24−export GRADLE_OPTS
25−HOSTTYPE='x86_64'
26−HOST_PATH='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
27−export HOST_PATH
28−IFS='
29−'
30−IN_NIX_SHELL='impure'
31−export IN_NIX_SHELL
32−JAVA_HOME='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2'
33−export JAVA_HOME
34−LD='ld'
35−export LD
36−LINENO='80'
37−MACHTYPE='x86_64-pc-linux-gnu'
38−NIX_BINTOOLS='/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46'
39−export NIX_BINTOOLS
40−NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
41−export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
42−NIX_BUILD_CORES='4'
43−export NIX_BUILD_CORES
44−NIX_CC='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
45−export NIX_CC
46−NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
47−export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
48−NIX_CFLAGS_COMPILE=' -frandom-seed=n53brq0w7p -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include'
49−export NIX_CFLAGS_COMPILE
50−NIX_ENFORCE_NO_NATIVE='1'
51−export NIX_ENFORCE_NO_NATIVE
52−NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
53−export NIX_HARDENING_ENABLE
54−NIX_LDFLAGS='-rpath /home/iko/opencode-android/outputs/out/lib '
55−export NIX_LDFLAGS
56−NIX_NO_SELF_RPATH='1'
57−NIX_STORE='/nix/store'
58−export NIX_STORE
59−NM='nm'
60−export NM
61−OBJCOPY='objcopy'
62−export OBJCOPY
63−OBJDUMP='objdump'
64−export OBJDUMP
65−OLDPWD=''
66−export OLDPWD
67−OPTERR='1'
68−OSTYPE='linux-gnu'
69−PATH='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/bin:/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0/bin:/nix/store/sq0nrnfwhkc5ljvklnrk5ps4358g4nbj-gcc-15.2.0/bin:/nix/store/521dd0054ifhzmjfmpx9mz0hr7wh7sig-glibc-2.42-67-bin/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46/bin:/nix/store/39lyf5g2iz4jd2r6q4g5whk9vw09zglj-binutils-2.46/bin:/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
70−export PATH
71−PS4='+ '
72−RANLIB='ranlib'
73−export RANLIB
74−READELF='readelf'
75−export READELF
76−SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
77−export SHELL
78−SIZE='size'
79−export SIZE
80−SOURCE_DATE_EPOCH='315532800'
81−export SOURCE_DATE_EPOCH
82−STRINGS='strings'
83−export STRINGS
84−STRIP='strip'
85−export STRIP
86−XDG_DATA_DIRS='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/share'
87−export XDG_DATA_DIRS
88−__structuredAttrs=''
89−export __structuredAttrs
90−_substituteStream_has_warned_replace_deprecation='false'
91−buildInputs='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2 /nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4 /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk'
92−export buildInputs
93−buildPhase='{ echo "------------------------------------------------------------";
94− echo " WARNING: the existence of this path is not guaranteed.";
95− echo " It is an internal implementation detail for pkgs.mkShell.";
96− echo "------------------------------------------------------------";
97− echo;
98− # Record all build inputs as runtime dependencies
99− export;
100−} >> "$out"
101−'
102−export buildPhase
103−builder='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
104−export builder
105−checkPhase='gradleCheckPhase'
106−cmakeFlags=''
107−export cmakeFlags
108−configureFlags=''
109−export configureFlags
110−defaultBuildInputs=''
111−defaultNativeBuildInputs='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2 /nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
112−depsBuildBuild=''
113−export depsBuildBuild
114−depsBuildBuildPropagated=''
115−export depsBuildBuildPropagated
116−depsBuildTarget=''
117−export depsBuildTarget
118−depsBuildTargetPropagated=''
119−export depsBuildTargetPropagated
120−depsHostHost=''
121−export depsHostHost
122−depsHostHostPropagated=''
123−export depsHostHostPropagated
124−depsTargetTarget=''
125−export depsTargetTarget
126−depsTargetTargetPropagated=''
127−export depsTargetTargetPropagated
128−doCheck=''
129−export doCheck
130−doInstallCheck=''
131−export doInstallCheck
132−dontAddDisableDepTrack='1'
133−export dontAddDisableDepTrack
134−declare -a envBuildBuildHooks=()
135−declare -a envBuildHostHooks=()
136−declare -a envBuildTargetHooks=()
137−declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
138−declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
139−declare -a envTargetTargetHooks=('addPkgToClassPath' )
140−declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
141−initialPath='/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11 /nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0 /nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12 /nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9 /nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12 /nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0 /nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35 /nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14 /nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin /nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1 /nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9 /nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8 /nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin /nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47'
142−mesonFlags=''
143−export mesonFlags
144−name='nix-shell-env'
145−export name
146−nativeBuildInputs=''
147−export nativeBuildInputs
148−out='/home/iko/opencode-android/outputs/out'
149−export out
150−outputBin='out'
151−outputDev='out'
152−outputDevdoc='REMOVE'
153−outputDevman='out'
154−outputDoc='out'
155−outputInclude='out'
156−outputInfo='out'
157−outputLib='out'
158−outputMan='out'
159−outputs='out'
160−export outputs
161−patches=''
162−export patches
163−phases='buildPhase'
164−export phases
165−pkg='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
166−declare -a pkgsBuildBuild=()
167−declare -a pkgsBuildHost=('/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2' '/nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0' '/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46' )
168−declare -a pkgsBuildTarget=()
169−declare -a pkgsHostHost=()
170−declare -a pkgsHostTarget=('/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2' '/nix/store/2dsv152lhz5rxl24fkb6sis3q165swv6-set-java-classpath-hook' '/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4' '/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk' )
171−declare -a pkgsTargetTarget=()
172−declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
173−declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
174−declare -a preConfigureHooks=('mitmCacheConfigureHook' '_multioutConfig' 'gradleConfigureHook' )
175−preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
176−declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
177−preferLocalBuild='1'
178−export preferLocalBuild
179−prefix='/home/iko/opencode-android/outputs/out'
180−declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
181−propagatedBuildInputs=''
182−export propagatedBuildInputs
183−declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
184−propagatedNativeBuildInputs=''
185−export propagatedNativeBuildInputs
186−declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
187−shell='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
188−export shell
189−shellHook='echo "opencode-android dev shell"
190−echo " JDK: $(java -version 2>&1 | head -1)"
191−echo " ANDROID_HOME: $ANDROID_HOME"
192−echo " aapt2: /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2"
193−echo ""
194−echo "Build: ./gradlew assembleDebug"
195−echo "Device: adb devices"
196−'
197−export shellHook
198−stdenv='/nix/store/xknwpg5yjxxsffmwdmpys269543wpdy5-stdenv-linux'
199−export stdenv
200−strictDeps=''
201−export strictDeps
202−system='x86_64-linux'
203−export system
204−declare -a unpackCmdHooks=('_defaultUnpack' )
205−_activatePkgs ()
206−{
207−
208− local hostOffset targetOffset;
209− local pkg;
210− for hostOffset in "${allPlatOffsets[@]}";
211− do
212− local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
213− for targetOffset in "${allPlatOffsets[@]}";
214− do
215− (( hostOffset <= targetOffset )) || continue;
216− local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
217− local pkgsSlice="${!pkgsRef}[@]";
218− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
219− do
220− activatePackage "$pkg" "$hostOffset" "$targetOffset";
221− done;
222− done;
223− done
224−}
225−_addRpathPrefix ()
226−{
227−
228− if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
229− export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
230− fi
231−}
232−_addToEnv ()
233−{
234−
235− local depHostOffset depTargetOffset;
236− local pkg;
237− for depHostOffset in "${allPlatOffsets[@]}";
238− do
239− local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
240− local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
241− for depTargetOffset in "${allPlatOffsets[@]}";
242− do
243− (( depHostOffset <= depTargetOffset )) || continue;
244− local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
245− if [[ -z "${strictDeps-}" ]]; then
246− local visitedPkgs="";
247− for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
248− do
249− if [[ "$visitedPkgs" = *"$pkg"* ]]; then
250− continue;
251− fi;
252− runHook "${!hookRef}" "$pkg";
253− visitedPkgs+=" $pkg";
254− done;
255− else
256− local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
257− local pkgsSlice="${!pkgsRef}[@]";
258− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
259− do
260− runHook "${!hookRef}" "$pkg";
261− done;
262− fi;
263− done;
264− done
265−}
266−_allFlags ()
267−{
268−
269− export system pname name version;
270− while IFS='' read -r varName; do
271− nixTalkativeLog "@${varName}@ -> ${!varName}";
272− args+=("--subst-var" "$varName");
273− done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
274−}
275−_assignFirst ()
276−{
277−
278− local varName="$1";
279− local _var;
280− local REMOVE=REMOVE;
281− shift;
282− for _var in "$@";
283− do
284− if [ -n "${!_var-}" ]; then
285− eval "${varName}"="${_var}";
286− return;
287− fi;
288− done;
289− echo;
290− echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
291− echo " The following variables were all unset or empty:";
292− echo " $*";
293− if [ -z "${out:-}" ]; then
294− echo ' If you do not want an "out" output in your derivation, make sure to define';
295− echo ' the other specific required outputs. This can be achieved by picking one';
296− echo " of the above as an output.";
297− echo ' You do not have to remove "out" if you want to have a different default';
298− echo ' output, because the first output is taken as a default.';
299− echo;
300− fi;
301− return 1
302−}
303−_callImplicitHook ()
304−{
305−
306− local def="$1";
307− local hookName="$2";
308− if declare -F "$hookName" > /dev/null; then
309− nixTalkativeLog "calling implicit '$hookName' function hook";
310− "$hookName";
311− else
312− if type -p "$hookName" > /dev/null; then
313− nixTalkativeLog "sourcing implicit '$hookName' script hook";
314− source "$hookName";
315− else
316− if [ -n "${!hookName:-}" ]; then
317− nixTalkativeLog "evaling implicit '$hookName' string hook";
318− eval "${!hookName}";
319− else
320− return "$def";
321− fi;
322− fi;
323− fi
324−}
325−_defaultUnpack ()
326−{
327−
328− local fn="$1";
329− local destination;
330− if [ -d "$fn" ]; then
331− destination="$(stripHash "$fn")";
332− if [ -e "$destination" ]; then
333− echo "Cannot copy $fn to $destination: destination already exists!";
334− echo "Did you specify two \"srcs\" with the same \"name\"?";
335− return 1;
336− fi;
337− cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
338− else
339− case "$fn" in
340− *.tar.xz | *.tar.lzma | *.txz)
341− ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
342− true ) | tar xf - --mode=+w --warning=no-timestamp
343− ;;
344− *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
345− tar xf "$fn" --mode=+w --warning=no-timestamp
346− ;;
347− *)
348− return 1
349− ;;
350− esac;
351− fi
352−}
353−_doStrip ()
354−{
355−
356− local -ra flags=(dontStripHost dontStripTarget);
357− local -ra debugDirs=(stripDebugList stripDebugListTarget);
358− local -ra allDirs=(stripAllList stripAllListTarget);
359− local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
360− local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
361− stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
362− stripDebugListTarget=${stripDebugListTarget[*]:-};
363− stripAllList=${stripAllList[*]:-};
364− stripAllListTarget=${stripAllListTarget[*]:-};
365− local i;
366− for i in ${!stripCmds[@]};
367− do
368− local -n flag="${flags[$i]}";
369− local -n debugDirList="${debugDirs[$i]}";
370− local -n allDirList="${allDirs[$i]}";
371− local -n stripCmd="${stripCmds[$i]}";
372− local -n ranlibCmd="${ranlibCmds[$i]}";
373− if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
374− continue;
375− fi;
376− stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
377− stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
378− done
379−}
380−_eval ()
381−{
382−
383− if declare -F "$1" > /dev/null 2>&1; then
384− "$@";
385− else
386− eval "$1";
387− fi
388−}
389−_logHook ()
390−{
391−
392− if [[ -z ${NIX_LOG_FD-} ]]; then
393− return;
394− fi;
395− local hookKind="$1";
396− local hookExpr="$2";
397− shift 2;
398− if declare -F "$hookExpr" > /dev/null 2>&1; then
399− nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
400− else
401− if type -p "$hookExpr" > /dev/null; then
402− nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
403− else
404− if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
405− local exprToOutput;
406− if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
407− exprToOutput="$hookExpr";
408− else
409− local hookExprLine;
410− while IFS= read -r hookExprLine; do
411− hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
412− if [[ -n "$hookExprLine" ]]; then
413− exprToOutput+="$hookExprLine\\n ";
414− fi;
415− done <<< "$hookExpr";
416− exprToOutput="${exprToOutput%%\\n }";
417− fi;
418− nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
419− fi;
420− fi;
421− fi
422−}
423−_makeSymlinksRelative ()
424−{
425−
426− local prefixes;
427− prefixes=();
428− for output in $(getAllOutputNames);
429− do
430− [ ! -e "${!output}" ] && continue;
431− prefixes+=("${!output}");
432− done;
433− find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
434− output="$1"
435− link="$2"
436−
437− linkTarget=$(readlink "$link")
438−
439− # only touch links that point inside the same output tree
440− [[ $linkTarget == "$output"/* ]] || exit 0
441−
442− if [ ! -e "$linkTarget" ]; then
443− echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
444− fi
445−
446− echo "making symlink relative: $link"
447− ln -snrf "$linkTarget" "$link"
448− ' _
449−}
450−_moveLib64 ()
451−{
452−
453− if [ "${dontMoveLib64-}" = 1 ]; then
454− return;
455− fi;
456− if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
457− return;
458− fi;
459− echo "moving $prefix/lib64/* to $prefix/lib";
460− mkdir -p $prefix/lib;
461− shopt -s dotglob;
462− for i in $prefix/lib64/*;
463− do
464− mv --no-clobber "$i" $prefix/lib;
465− done;
466− shopt -u dotglob;
467− rmdir $prefix/lib64;
468− ln -s lib $prefix/lib64
469−}
470−_moveSbin ()
471−{
472−
473− if [ "${dontMoveSbin-}" = 1 ]; then
474− return;
475− fi;
476− if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
477− return;
478− fi;
479− echo "moving $prefix/sbin/* to $prefix/bin";
480− mkdir -p $prefix/bin;
481− shopt -s dotglob;
482− for i in $prefix/sbin/*;
483− do
484− mv "$i" $prefix/bin;
485− done;
486− shopt -u dotglob;
487− rmdir $prefix/sbin;
488− ln -s bin $prefix/sbin
489−}
490−_moveSystemdUserUnits ()
491−{
492−
493− if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
494− return;
495− fi;
496− if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
497− return;
498− fi;
499− local source="$prefix/lib/systemd/user";
500− local target="$prefix/share/systemd/user";
501− echo "moving $source/* to $target";
502− mkdir -p "$target";
503− ( shopt -s dotglob;
504− for i in "$source"/*;
505− do
506− mv "$i" "$target";
507− done );
508− rmdir "$source";
509− ln -s "$target" "$source"
510−}
511−_moveToShare ()
512−{
513−
514− if [ -n "$__structuredAttrs" ]; then
515− if [ -z "${forceShare-}" ]; then
516− forceShare=(man doc info);
517− fi;
518− else
519− forceShare=(${forceShare:-man doc info});
520− fi;
521− if [[ -z "$out" ]]; then
522− return;
523− fi;
524− for d in "${forceShare[@]}";
525− do
526− if [ -d "$out/$d" ]; then
527− if [ -d "$out/share/$d" ]; then
528− echo "both $d/ and share/$d/ exist!";
529− else
530− echo "moving $out/$d to $out/share/$d";
531− mkdir -p $out/share;
532− mv $out/$d $out/share/;
533− fi;
534− fi;
535− done
536−}
537−_multioutConfig ()
538−{
539−
540− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
541− return;
542− fi;
543− if [ -z "${shareDocName:-}" ]; then
544− local confScript="${configureScript:-}";
545− if [ -z "$confScript" ] && [ -x ./configure ]; then
546− confScript=./configure;
547− fi;
548− if [ -f "$confScript" ]; then
549− local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
550− fi;
551− if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
552− shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
553− fi;
554− fi;
555− prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
556− prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
557−}
558−_multioutDevs ()
559−{
560−
561− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
562− return;
563− fi;
564− moveToOutput include "${!outputInclude}";
565− moveToOutput lib/pkgconfig "${!outputDev}";
566− moveToOutput share/pkgconfig "${!outputDev}";
567− moveToOutput lib/cmake "${!outputDev}";
568− moveToOutput share/aclocal "${!outputDev}";
569− for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
570− do
571− echo "Patching '$f' includedir to output ${!outputInclude}";
572− sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
573− done
574−}
575−_multioutDocs ()
576−{
577−
578− local REMOVE=REMOVE;
579− moveToOutput share/info "${!outputInfo}";
580− moveToOutput share/doc "${!outputDoc}";
581− moveToOutput share/gtk-doc "${!outputDevdoc}";
582− moveToOutput share/devhelp/books "${!outputDevdoc}";
583− moveToOutput share/man "${!outputMan}";
584− moveToOutput share/man/man3 "${!outputDevman}"
585−}
586−_multioutPropagateDev ()
587−{
588−
589− if [ "$(getAllOutputNames)" = "out" ]; then
590− return;
591− fi;
592− local outputFirst;
593− for outputFirst in $(getAllOutputNames);
594− do
595− break;
596− done;
597− local propagaterOutput="$outputDev";
598− if [ -z "$propagaterOutput" ]; then
599− propagaterOutput="$outputFirst";
600− fi;
601− if [ -z "${propagatedBuildOutputs+1}" ]; then
602− local po_dirty="$outputBin $outputInclude $outputLib";
603− set +o pipefail;
604− propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
605− set -o pipefail;
606− fi;
607− if [ -z "$propagatedBuildOutputs" ]; then
608− return;
609− fi;
610− mkdir -p "${!propagaterOutput}"/nix-support;
611− for output in $propagatedBuildOutputs;
612− do
613− echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
614− done
615−}
616−_nixLogWithLevel ()
617−{
618−
619− [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
620− local logLevel;
621− case "${1:?}" in
622− 0)
623− logLevel=ERROR
624− ;;
625− 1)
626− logLevel=WARN
627− ;;
628− 2)
629− logLevel=NOTICE
630− ;;
631− 3)
632− logLevel=INFO
633− ;;
634− 4)
635− logLevel=TALKATIVE
636− ;;
637− 5)
638− logLevel=CHATTY
639− ;;
640− 6)
641− logLevel=DEBUG
642− ;;
643− 7)
644− logLevel=VOMIT
645− ;;
646− *)
647− echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
648− return 1
649− ;;
650− esac;
651− local callerName="${FUNCNAME[2]}";
652− if [[ $callerName == "_callImplicitHook" ]]; then
653− callerName="${hookName:?}";
654− fi;
655− printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
656−}
657−_overrideFirst ()
658−{
659−
660− if [ -z "${!1-}" ]; then
661− _assignFirst "$@";
662− fi
663−}
664−_pruneLibtoolFiles ()
665−{
666−
667− if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
668− return;
669− fi;
670− find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
671−}
672−_updateSourceDateEpochFromSourceRoot ()
673−{
674−
675− if [ -n "$sourceRoot" ]; then
676− updateSourceDateEpoch "$sourceRoot";
677− fi
678−}
679−activatePackage ()
680−{
681−
682− local pkg="$1";
683− local -r hostOffset="$2";
684− local -r targetOffset="$3";
685− (( hostOffset <= targetOffset )) || exit 1;
686− if [ -f "$pkg" ]; then
687− nixTalkativeLog "sourcing setup hook '$pkg'";
688− source "$pkg";
689− fi;
690− if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
691− addToSearchPath _PATH "$pkg/bin";
692− fi;
693− if (( hostOffset <= -1 )); then
694− addToSearchPath _XDG_DATA_DIRS "$pkg/share";
695− fi;
696− if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
697− addToSearchPath _HOST_PATH "$pkg/bin";
698− fi;
699− if [[ -f "$pkg/nix-support/setup-hook" ]]; then
700− nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
701− source "$pkg/nix-support/setup-hook";
702− fi
703−}
704−addEnvHooks ()
705−{
706−
707− local depHostOffset="$1";
708− shift;
709− local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
710− local pkgHookVar;
711− for pkgHookVar in "${!pkgHookVarsSlice}";
712− do
713− eval "${pkgHookVar}s"'+=("$@")';
714− done
715−}
716−addPkgToClassPath ()
717−{
718−
719− local jar;
720− for jar in $1/share/java/*.jar;
721− do
722− export CLASSPATH=''${CLASSPATH-}''${CLASSPATH:+:}''${jar};
723− done
724−}
725−addToSearchPath ()
726−{
727−
728− addToSearchPathWithCustomDelimiter ":" "$@"
729−}
730−addToSearchPathWithCustomDelimiter ()
731−{
732−
733− local delimiter="$1";
734− local varName="$2";
735− local dir="$3";
736− if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
737− export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
738− fi
739−}
740−appendToVar ()
741−{
742−
743− local -n nameref="$1";
744− local useArray type;
745− if [ -n "$__structuredAttrs" ]; then
746− useArray=true;
747− else
748− useArray=false;
749− fi;
750− if type=$(declare -p "$1" 2> /dev/null); then
751− case "${type#* }" in
752− -A*)
753− echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
754− return 1
755− ;;
756− -a*)
757− useArray=true
758− ;;
759− *)
760− useArray=false
761− ;;
762− esac;
763− fi;
764− shift;
765− if $useArray; then
766− nameref=(${nameref+"${nameref[@]}"} "$@");
767− else
768− nameref="${nameref-} $*";
769− fi
770−}
771−auditTmpdir ()
772−{
773−
774− local dir="$1";
775− [ -e "$dir" ] || return 0;
776− echo "checking for references to $TMPDIR/ in $dir...";
777− local tmpdir elf_fifo script_fifo;
778− tmpdir="$(mktemp -d)";
779− elf_fifo="$tmpdir/elf";
780− script_fifo="$tmpdir/script";
781− mkfifo "$elf_fifo" "$script_fifo";
782− ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
783− if isELF "$file"; then
784− printf '%s\0' "$file" 1>&3;
785− else
786− if isScript "$file"; then
787− filename=${file##*/};
788− dir=${file%/*};
789− if [ -e "$dir/.$filename-wrapped" ]; then
790− printf '%s\0' "$file" 1>&4;
791− fi;
792− fi;
793− fi;
794− done;
795− exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
796− if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
797− echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
798− exit 1
799− fi
800− ' _ < "$elf_fifo" ) & local pid_elf=$!;
801− local pid_script;
802− ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
803− if grep -q -F "$TMPDIR/" "$1"; then
804− echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
805− exit 1
806− fi
807− ' _ < "$script_fifo" ) & local pid_script=$!;
808− wait "$pid_elf" || {
809− echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
810− exit 1
811− };
812− wait "$pid_script" || {
813− echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
814− exit 1
815− };
816− rm -r "$tmpdir"
817−}
818−bintoolsWrapper_addLDVars ()
819−{
820−
821− local role_post;
822− getHostRoleEnvHook;
823− if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
824− export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
825− fi;
826− if [[ -d "$1/lib" ]]; then
827− local -a glob=($1/lib/lib*);
828− if [ "${#glob[*]}" -gt 0 ]; then
829− export NIX_LDFLAGS${role_post}+=" -L$1/lib";
830− fi;
831− fi
832−}
833−buildPhase ()
834−{
835−
836− runHook preBuild;
837− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
838− echo "no Makefile or custom buildPhase, doing nothing";
839− else
840− foundMakefile=1;
841− local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
842− concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
843− echoCmd 'build flags' "${flagsArray[@]}";
844− make ${makefile:+-f $makefile} "${flagsArray[@]}";
845− unset flagsArray;
846− fi;
847− runHook postBuild
848−}
849−ccWrapper_addCVars ()
850−{
851−
852− local role_post;
853− getHostRoleEnvHook;
854− local found=;
855− if [ -d "$1/include" ]; then
856− export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
857− found=1;
858− fi;
859− if [ -d "$1/Library/Frameworks" ]; then
860− export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
861− found=1;
862− fi;
863− if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
864− local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
865− export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
866− fi
867−}
868−checkPhase ()
869−{
870−
871− runHook preCheck;
872− if [[ -z "${foundMakefile:-}" ]]; then
873− echo "no Makefile or custom checkPhase, doing nothing";
874− runHook postCheck;
875− return;
876− fi;
877− if [[ -z "${checkTarget:-}" ]]; then
878− if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
879− checkTarget="check";
880− else
881− if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
882− checkTarget="test";
883− fi;
884− fi;
885− fi;
886− if [[ -z "${checkTarget:-}" ]]; then
887− echo "no check/test target in ${makefile:-Makefile}, doing nothing";
888− else
889− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
890− concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
891− echoCmd 'check flags' "${flagsArray[@]}";
892− make ${makefile:+-f $makefile} "${flagsArray[@]}";
893− unset flagsArray;
894− fi;
895− runHook postCheck
896−}
897−compressManPages ()
898−{
899−
900− local dir="$1";
901− if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
902− return;
903− fi;
904− echo "gzipping man pages under $dir/share/man/";
905− find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
906− find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
907− local target;
908− target="$(readlink -f "$f")";
909− if [ -f "$target".gz ]; then
910− ln -sf "$target".gz "$f".gz && rm "$f";
911− fi;
912− done
913−}
914−concatStringsSep ()
915−{
916−
917− local sep="$1";
918− local name="$2";
919− local type oldifs;
920− if type=$(declare -p "$name" 2> /dev/null); then
921− local -n nameref="$name";
922− case "${type#* }" in
923− -A*)
924− echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
925− return 1
926− ;;
927− -a*)
928− local IFS="$(printf '\036')"
929− ;;
930− *)
931− local IFS=" "
932− ;;
933− esac;
934− local ifs_separated="${nameref[*]}";
935− echo -n "${ifs_separated//"$IFS"/"$sep"}";
936− fi
937−}
938−concatTo ()
939−{
940−
941− local -;
942− set -o noglob;
943− local -n targetref="$1";
944− shift;
945− local arg default name type;
946− for arg in "$@";
947− do
948− IFS="=" read -r name default <<< "$arg";
949− local -n nameref="$name";
950− if [[ -z "${nameref[*]}" && -n "$default" ]]; then
951− targetref+=("$default");
952− else
953− if type=$(declare -p "$name" 2> /dev/null); then
954− case "${type#* }" in
955− -A*)
956− echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
957− return 1
958− ;;
959− -a*)
960− targetref+=("${nameref[@]}")
961− ;;
962− *)
963− if [[ "$name" = *"Array" ]]; then
964− nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
965− targetref+=(${nameref+"${nameref[@]}"});
966− else
967− targetref+=(${nameref-});
968− fi
969− ;;
970− esac;
971− fi;
972− fi;
973− done
974−}
975−configurePhase ()
976−{
977−
978− runHook preConfigure;
979− : "${configureScript=}";
980− if [[ -z "$configureScript" && -x ./configure ]]; then
981− configureScript=./configure;
982− fi;
983− if [ -z "${dontFixLibtool:-}" ]; then
984− export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
985− local i;
986− find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
987− echo "fixing libtool script $i";
988− fixLibtool "$i";
989− done;
990− CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
991− find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
992− rm -f "$CONFIGURE_MTIME_REFERENCE";
993− fi;
994− if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
995− local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
996− if [ "${prefixKeyOrDefault: -1}" = " " ]; then
997− prependToVar configureFlags "$prefix";
998− prependToVar configureFlags "${prefixKeyOrDefault::-1}";
999− else
1000− prependToVar configureFlags "$prefixKeyOrDefault$prefix";
1001− fi;
1002− fi;
1003− if [[ -f "$configureScript" ]]; then
1004− if [ -z "${dontAddDisableDepTrack:-}" ]; then
1005− if grep -q dependency-tracking "$configureScript"; then
1006− prependToVar configureFlags --disable-dependency-tracking;
1007− fi;
1008− fi;
1009− if [ -z "${dontDisableStatic:-}" ]; then
1010− if grep -q enable-static "$configureScript"; then
1011− prependToVar configureFlags --disable-static;
1012− fi;
1013− fi;
1014− if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
1015− patchShebangs --build "$configureScript";
1016− fi;
1017− fi;
1018− if [ -n "$configureScript" ]; then
1019− local -a flagsArray;
1020− concatTo flagsArray configureFlags configureFlagsArray;
1021− echoCmd 'configure flags' "${flagsArray[@]}";
1022− $configureScript "${flagsArray[@]}";
1023− unset flagsArray;
1024− else
1025− echo "no configure script, doing nothing";
1026− fi;
1027− runHook postConfigure
1028−}
1029−consumeEntire ()
1030−{
1031−
1032− if IFS='' read -r -d '' "$1"; then
1033− echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
1034− return 1;
1035− fi
1036−}
1037−definePhases ()
1038−{
1039−
1040− if [ -z "${phases[*]:-}" ]; then
1041− phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
1042− fi
1043−}
1044−distPhase ()
1045−{
1046−
1047− runHook preDist;
1048− local flagsArray=();
1049− concatTo flagsArray distFlags distFlagsArray distTarget=dist;
1050− echo 'dist flags: %q' "${flagsArray[@]}";
1051− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1052− if [ "${dontCopyDist:-0}" != 1 ]; then
1053− mkdir -p "$out/tarballs";
1054− cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
1055− fi;
1056− runHook postDist
1057−}
1058−dumpVars ()
1059−{
1060−
1061− if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
1062− local old_umask;
1063− old_umask=$(umask);
1064− umask 0077;
1065− export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
1066− umask "$old_umask";
1067− fi
1068−}
1069−echoCmd ()
1070−{
1071−
1072− printf "%s:" "$1";
1073− shift;
1074− printf ' %q' "$@";
1075− echo
1076−}
1077−exitHandler ()
1078−{
1079−
1080− exitCode="$?";
1081− set +e;
1082− if [ -n "${showBuildStats:-}" ]; then
1083− read -r -d '' -a buildTimes < <(times);
1084− echo "build times:";
1085− echo "user time for the shell ${buildTimes[0]}";
1086− echo "system time for the shell ${buildTimes[1]}";
1087− echo "user time for all child processes ${buildTimes[2]}";
1088− echo "system time for all child processes ${buildTimes[3]}";
1089− fi;
1090− if (( "$exitCode" != 0 )); then
1091− runHook failureHook;
1092− if [ -n "${succeedOnFailure:-}" ]; then
1093− echo "build failed with exit code $exitCode (ignored)";
1094− mkdir -p "$out/nix-support";
1095− printf "%s" "$exitCode" > "$out/nix-support/failed";
1096− exit 0;
1097− fi;
1098− else
1099− runHook exitHook;
1100− fi;
1101− return "$exitCode"
1102−}
1103−findInputs ()
1104−{
1105−
1106− local -r pkg="$1";
1107− local -r hostOffset="$2";
1108− local -r targetOffset="$3";
1109− (( hostOffset <= targetOffset )) || exit 1;
1110− local varVar="${pkgAccumVarVars[hostOffset + 1]}";
1111− local varRef="$varVar[$((targetOffset - hostOffset))]";
1112− local var="${!varRef}";
1113− unset -v varVar varRef;
1114− local varSlice="$var[*]";
1115− case " ${!varSlice-} " in
1116− *" $pkg "*)
1117− return 0
1118− ;;
1119− esac;
1120− unset -v varSlice;
1121− eval "$var"'+=("$pkg")';
1122− if ! [ -e "$pkg" ]; then
1123− echo "build input $pkg does not exist" 1>&2;
1124− exit 1;
1125− fi;
1126− function mapOffset ()
1127− {
1128− local -r inputOffset="$1";
1129− local -n outputOffset="$2";
1130− if (( inputOffset <= 0 )); then
1131− outputOffset=$((inputOffset + hostOffset));
1132− else
1133− outputOffset=$((inputOffset - 1 + targetOffset));
1134− fi
1135− };
1136− local relHostOffset;
1137− for relHostOffset in "${allPlatOffsets[@]}";
1138− do
1139− local files="${propagatedDepFilesVars[relHostOffset + 1]}";
1140− local hostOffsetNext;
1141− mapOffset "$relHostOffset" hostOffsetNext;
1142− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1143− local relTargetOffset;
1144− for relTargetOffset in "${allPlatOffsets[@]}";
1145− do
1146− (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
1147− local fileRef="${files}[$relTargetOffset - $relHostOffset]";
1148− local file="${!fileRef}";
1149− unset -v fileRef;
1150− local targetOffsetNext;
1151− mapOffset "$relTargetOffset" targetOffsetNext;
1152− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1153− [[ -f "$pkg/nix-support/$file" ]] || continue;
1154− local pkgNext;
1155− read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
1156− for pkgNext in $pkgNext;
1157− do
1158− findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
1159− done;
1160− done;
1161− done
1162−}
1163−fixLibtool ()
1164−{
1165−
1166− local search_path;
1167− for flag in $NIX_LDFLAGS;
1168− do
1169− case $flag in
1170− -L*)
1171− search_path+=" ${flag#-L}"
1172− ;;
1173− esac;
1174− done;
1175− sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
1176−}
1177−fixupPhase ()
1178−{
1179−
1180− local output;
1181− for output in $(getAllOutputNames);
1182− do
1183− if [ -e "${!output}" ]; then
1184− chmod -R u+w,u-s,g-s "${!output}";
1185− fi;
1186− done;
1187− runHook preFixup;
1188− local output;
1189− for output in $(getAllOutputNames);
1190− do
1191− prefix="${!output}" runHook fixupOutput;
1192− done;
1193− recordPropagatedDependencies;
1194− if [ -n "${setupHook:-}" ]; then
1195− mkdir -p "${!outputDev}/nix-support";
1196− substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
1197− fi;
1198− if [ -n "${setupHooks:-}" ]; then
1199− mkdir -p "${!outputDev}/nix-support";
1200− local hook;
1201− for hook in ${setupHooks[@]};
1202− do
1203− local content;
1204− consumeEntire content < "$hook";
1205− substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
1206− unset -v content;
1207− done;
1208− unset -v hook;
1209− fi;
1210− if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
1211− mkdir -p "${!outputBin}/nix-support";
1212− printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
1213− fi;
1214− runHook postFixup
1215−}
1216−genericBuild ()
1217−{
1218−
1219− export GZIP_NO_TIMESTAMPS=1;
1220− if [ -f "${buildCommandPath:-}" ]; then
1221− source "$buildCommandPath";
1222− return;
1223− fi;
1224− if [ -n "${buildCommand:-}" ]; then
1225− eval "$buildCommand";
1226− return;
1227− fi;
1228− definePhases;
1229− for curPhase in ${phases[*]};
1230− do
1231− runPhase "$curPhase";
1232− done
1233−}
1234−getAllOutputNames ()
1235−{
1236−
1237− if [ -n "$__structuredAttrs" ]; then
1238− echo "${!outputs[*]}";
1239− else
1240− echo "$outputs";
1241− fi
1242−}
1243−getHostRole ()
1244−{
1245−
1246− getRole "$hostOffset"
1247−}
1248−getHostRoleEnvHook ()
1249−{
1250−
1251− getRole "$depHostOffset"
1252−}
1253−getRole ()
1254−{
1255−
1256− case $1 in
1257− -1)
1258− role_post='_FOR_BUILD'
1259− ;;
1260− 0)
1261− role_post=''
1262− ;;
1263− 1)
1264− role_post='_FOR_TARGET'
1265− ;;
1266− *)
1267− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1268− return 1
1269− ;;
1270− esac
1271−}
1272−getTargetRole ()
1273−{
1274−
1275− getRole "$targetOffset"
1276−}
1277−getTargetRoleEnvHook ()
1278−{
1279−
1280− getRole "$depTargetOffset"
1281−}
1282−getTargetRoleWrapper ()
1283−{
1284−
1285− case $targetOffset in
1286− -1)
1287− export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
1288− ;;
1289− 0)
1290− export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
1291− ;;
1292− 1)
1293− export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
1294− ;;
1295− *)
1296− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1297− return 1
1298− ;;
1299− esac
1300−}
1301−gradle ()
1302−{
1303−
1304− local flagsArray=();
1305− concatTo flagsArray gradleFlags gradleFlagsArray;
1306− command gradle "${flagsArray[@]}" "$@"
1307−}
1308−gradleBuildPhase ()
1309−{
1310−
1311− runHook preBuild;
1312− gradle ${enableParallelBuilding:+--parallel} ${gradleBuildTask:-assemble};
1313− runHook postBuild
1314−}
1315−gradleCheckPhase ()
1316−{
1317−
1318− runHook preCheck;
1319− gradle ${enableParallelChecking:+--parallel} ${gradleCheckTask:-test};
1320− runHook postCheck
1321−}
1322−gradleConfigureHook ()
1323−{
1324−
1325− if [ -z "${GRADLE_USER_HOME-}" ]; then
1326− GRADLE_USER_HOME="$(mktemp -d)";
1327− fi;
1328− export GRADLE_USER_HOME;
1329− export TERM=dumb;
1330− gradleFlagsArray+=(--no-daemon -Dorg.gradle.console=plain --init-script "${gradleInitScript:-/nix/store/qyybvyq09wv36br9lfd8jac92fgwy8f2-init-build.gradle}");
1331− if [ -n "${MITM_CACHE_CA-}" ]; then
1332− if [ -z "${MITM_CACHE_KEYSTORE-}" ]; then
1333− MITM_CACHE_KEYSTORE="$MITM_CACHE_CERT_DIR/keystore";
1334− MITM_CACHE_KS_PWD="$(head -c10 /dev/random | base32)";
1335− echo y | /nix/store/vs2j17dr25ph3h1k4w1ngxnn8y5br1sa-openjdk-21.0.12+2/bin/keytool -importcert -file "$MITM_CACHE_CA" -alias alias -keystore "$MITM_CACHE_KEYSTORE" -storepass "$MITM_CACHE_KS_PWD";
1336− fi;
1337− gradleFlagsArray+=(-Dhttp.proxyHost="$MITM_CACHE_HOST" -Dhttp.proxyPort="$MITM_CACHE_PORT");
1338− gradleFlagsArray+=(-Dhttps.proxyHost="$MITM_CACHE_HOST" -Dhttps.proxyPort="$MITM_CACHE_PORT");
1339− gradleFlagsArray+=(-Djavax.net.ssl.trustStore="$MITM_CACHE_KEYSTORE" -Djavax.net.ssl.trustStorePassword="$MITM_CACHE_KS_PWD");
1340− else
1341− gradleFlagsArray+=(--offline);
1342− fi;
1343− if ! [[ -v enableParallelBuilding ]]; then
1344− enableParallelBuilding=1;
1345− fi;
1346− if ! [[ -v enableParallelChecking ]]; then
1347− enableParallelChecking=1;
1348− fi;
1349− if ! [[ -v enableParallelUpdating ]]; then
1350− enableParallelUpdating=1;
1351− fi
1352−}
1353−gradleUpdateScript ()
1354−{
1355−
1356− runHook preBuild;
1357− runHook preGradleUpdate;
1358− gradle ${enableParallelUpdating:+--parallel} ${gradleUpdateTask:-nixDownloadDeps};
1359− runHook postGradleUpdate
1360−}
1361−installCheckPhase ()
1362−{
1363−
1364− runHook preInstallCheck;
1365− if [[ -z "${foundMakefile:-}" ]]; then
1366− echo "no Makefile or custom installCheckPhase, doing nothing";
1367− else
1368− if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
1369− echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
1370− else
1371− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1372− concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
1373− echoCmd 'installcheck flags' "${flagsArray[@]}";
1374− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1375− unset flagsArray;
1376− fi;
1377− fi;
1378− runHook postInstallCheck
1379−}
1380−installPhase ()
1381−{
1382−
1383− runHook preInstall;
1384− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
1385− echo "no Makefile or custom installPhase, doing nothing";
1386− runHook postInstall;
1387− return;
1388− else
1389− foundMakefile=1;
1390− fi;
1391− if [ -n "$prefix" ]; then
1392− mkdir -p "$prefix";
1393− fi;
1394− local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1395− concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
1396− echoCmd 'install flags' "${flagsArray[@]}";
1397− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1398− unset flagsArray;
1399− runHook postInstall
1400−}
1401−isELF ()
1402−{
1403−
1404− local fn="$1";
1405− local fd;
1406− local magic;
1407− exec {fd}< "$fn";
1408− LANG=C read -r -n 4 -u "$fd" magic;
1409− exec {fd}>&-;
1410− if [ "$magic" = 'ELF' ]; then
1411− return 0;
1412− else
1413− return 1;
1414− fi
1415−}
1416−isMachO ()
1417−{
1418−
1419− local fn="$1";
1420− local fd;
1421− local magic;
1422− exec {fd}< "$fn";
1423− LANG=C read -r -n 4 -u "$fd" magic;
1424− exec {fd}>&-;
1425− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
1426− return 0;
1427− else
1428− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
1429− return 0;
1430− else
1431− if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
1432− return 0;
1433− else
1434− return 1;
1435− fi;
1436− fi;
1437− fi
1438−}
1439−isScript ()
1440−{
1441−
1442− local fn="$1";
1443− local fd;
1444− local magic;
1445− exec {fd}< "$fn";
1446− LANG=C read -r -n 2 -u "$fd" magic;
1447− exec {fd}>&-;
1448− if [[ "$magic" =~ \#! ]]; then
1449− return 0;
1450− else
1451− return 1;
1452− fi
1453−}
1454−mapOffset ()
1455−{
1456−
1457− local -r inputOffset="$1";
1458− local -n outputOffset="$2";
1459− if (( inputOffset <= 0 )); then
1460− outputOffset=$((inputOffset + hostOffset));
1461− else
1462− outputOffset=$((inputOffset - 1 + targetOffset));
1463− fi
1464−}
1465−mitmCacheConfigureHook ()
1466−{
1467−
1468− if [ -d "$mitmCache" ] && [ -z "$MITM_CACHE_CERT_DIR" ]; then
1469− MITM_CACHE_CERT_DIR="$(mktemp -d)";
1470− pushd "$MITM_CACHE_CERT_DIR";
1471− MITM_CACHE_CA="$MITM_CACHE_CERT_DIR/ca.cer";
1472− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl genrsa -out ca.key 2048;
1473− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 -out ca.cer -subj "/C=AL/ST=a/L=a/O=a/OU=a/CN=example.org";
1474− MITM_CACHE_HOST="127.0.0.1";
1475− MITM_CACHE_PORT="${mitmCachePort:-$(/nix/store/ibdb2kp2gkqqbicwxp663s39sd5824zg-python3.13-ephemeral-port-reserve-1.1.4/bin/ephemeral-port-reserve "$MITM_CACHE_HOST")}";
1476− MITM_CACHE_ADDRESS="$MITM_CACHE_HOST:$MITM_CACHE_PORT";
1477− export http_proxy="$MITM_CACHE_ADDRESS";
1478− export https_proxy="$MITM_CACHE_ADDRESS";
1479− export SSL_CERT_FILE="$MITM_CACHE_CA";
1480− export NIX_SSL_CERT_FILE="$MITM_CACHE_CA";
1481− mitm-cache -l"$MITM_CACHE_ADDRESS" replay "$mitmCache" > /dev/null 2> /dev/null & popd;
1482− fi
1483−}
1484−moveToOutput ()
1485−{
1486−
1487− local patt="$1";
1488− local dstOut="$2";
1489− local output;
1490− for output in $(getAllOutputNames);
1491− do
1492− if [ "${!output}" = "$dstOut" ]; then
1493− continue;
1494− fi;
1495− local srcPath;
1496− for srcPath in "${!output}"/$patt;
1497− do
1498− if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
1499− continue;
1500− fi;
1501− if [ "$dstOut" = REMOVE ]; then
1502− echo "Removing $srcPath";
1503− rm -r "$srcPath";
1504− else
1505− local dstPath="$dstOut${srcPath#${!output}}";
1506− echo "Moving $srcPath to $dstPath";
1507− if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
1508− rmdir "$srcPath" --ignore-fail-on-non-empty;
1509− if [ -d "$srcPath" ]; then
1510− mv -t "$dstPath" "$srcPath"/*;
1511− rmdir "$srcPath";
1512− fi;
1513− else
1514− mkdir -p "$(readlink -m "$dstPath/..")";
1515− mv "$srcPath" "$dstPath";
1516− fi;
1517− fi;
1518− local srcParent="$(readlink -m "$srcPath/..")";
1519− if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
1520− echo "Removing empty $srcParent/ and (possibly) its parents";
1521− rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
1522− fi;
1523− done;
1524− done
1525−}
1526−nixChattyLog ()
1527−{
1528−
1529− _nixLogWithLevel 5 "$*"
1530−}
1531−nixDebugLog ()
1532−{
1533−
1534− _nixLogWithLevel 6 "$*"
1535−}
1536−nixErrorLog ()
1537−{
1538−
1539− _nixLogWithLevel 0 "$*"
1540−}
1541−nixInfoLog ()
1542−{
1543−
1544− _nixLogWithLevel 3 "$*"
1545−}
1546−nixLog ()
1547−{
1548−
1549− [[ -z ${NIX_LOG_FD-} ]] && return 0;
1550− local callerName="${FUNCNAME[1]}";
1551− if [[ $callerName == "_callImplicitHook" ]]; then
1552− callerName="${hookName:?}";
1553− fi;
1554− printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
1555−}
1556−nixNoticeLog ()
1557−{
1558−
1559− _nixLogWithLevel 2 "$*"
1560−}
1561−nixTalkativeLog ()
1562−{
1563−
1564− _nixLogWithLevel 4 "$*"
1565−}
1566−nixVomitLog ()
1567−{
1568−
1569− _nixLogWithLevel 7 "$*"
1570−}
1571−nixWarnLog ()
1572−{
1573−
1574− _nixLogWithLevel 1 "$*"
1575−}
1576−noBrokenSymlinks ()
1577−{
1578−
1579− local -r output="${1:?}";
1580− local path;
1581− local pathParent;
1582− local symlinkTarget;
1583− local -i numDanglingSymlinks=0;
1584− local -i numReflexiveSymlinks=0;
1585− local -i numUnreadableSymlinks=0;
1586− if [[ ! -e $output ]]; then
1587− nixWarnLog "skipping non-existent output $output";
1588− return 0;
1589− fi;
1590− nixInfoLog "running on $output";
1591− while IFS= read -r -d '' path; do
1592− pathParent="$(dirname "$path")";
1593− if ! symlinkTarget="$(readlink "$path")"; then
1594− nixErrorLog "the symlink $path is unreadable";
1595− numUnreadableSymlinks+=1;
1596− continue;
1597− fi;
1598− if [[ $symlinkTarget == /* ]]; then
1599− nixInfoLog "symlink $path points to absolute target $symlinkTarget";
1600− else
1601− nixInfoLog "symlink $path points to relative target $symlinkTarget";
1602− symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
1603− fi;
1604− if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
1605− nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
1606− numDanglingSymlinks+=1;
1607− continue;
1608− fi;
1609− if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
1610− nixInfoLog "symlink $path points outside the Nix store; ignoring";
1611− continue;
1612− fi;
1613− if [[ $path == "$symlinkTarget" ]]; then
1614− nixErrorLog "the symlink $path is reflexive";
1615− numReflexiveSymlinks+=1;
1616− else
1617− if [[ ! -e $symlinkTarget ]]; then
1618− nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
1619− numDanglingSymlinks+=1;
1620− else
1621− nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
1622− fi;
1623− fi;
1624− done < <(find "$output" -type l -print0);
1625− if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
1626− nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
1627− exit 1;
1628− fi;
1629− return 0
1630−}
1631−noBrokenSymlinksInAllOutputs ()
1632−{
1633−
1634− if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
1635− for output in $(getAllOutputNames);
1636− do
1637− noBrokenSymlinks "${!output}";
1638− done;
1639− fi
1640−}
1641−patchELF ()
1642−{
1643−
1644− local dir="$1";
1645− [ -e "$dir" ] || return 0;
1646− echo "shrinking RPATHs of ELF executables and libraries in $dir";
1647− local i;
1648− while IFS= read -r -d '' i; do
1649− if [[ "$i" =~ .build-id ]]; then
1650− continue;
1651− fi;
1652− if ! isELF "$i"; then
1653− continue;
1654− fi;
1655− echo "shrinking $i";
1656− patchelf --shrink-rpath "$i" || true;
1657− done < <(find "$dir" -type f -print0)
1658−}
1659−patchPhase ()
1660−{
1661−
1662− runHook prePatch;
1663− local -a patchesArray;
1664− concatTo patchesArray patches;
1665− local -a flagsArray;
1666− concatTo flagsArray patchFlags=-p1;
1667− for i in "${patchesArray[@]}";
1668− do
1669− echo "applying patch $i";
1670− local uncompress=cat;
1671− case "$i" in
1672− *.gz)
1673− uncompress="gzip -d"
1674− ;;
1675− *.bz2)
1676− uncompress="bzip2 -d"
1677− ;;
1678− *.xz)
1679− uncompress="xz -d"
1680− ;;
1681− *.lzma)
1682− uncompress="lzma -d"
1683− ;;
1684− esac;
1685− $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
1686− done;
1687− runHook postPatch
1688−}
1689−patchShebangs ()
1690−{
1691−
1692− local pathName;
1693− local update=false;
1694− while [[ $# -gt 0 ]]; do
1695− case "$1" in
1696− --host)
1697− pathName=HOST_PATH;
1698− shift
1699− ;;
1700− --build)
1701− pathName=PATH;
1702− shift
1703− ;;
1704− --update)
1705− update=true;
1706− shift
1707− ;;
1708− --)
1709− shift;
1710− break
1711− ;;
1712− -* | --*)
1713− echo "Unknown option $1 supplied to patchShebangs" 1>&2;
1714− return 1
1715− ;;
1716− *)
1717− break
1718− ;;
1719− esac;
1720− done;
1721− echo "patching script interpreter paths in $@";
1722− local f;
1723− local oldPath;
1724− local newPath;
1725− local arg0;
1726− local args;
1727− local oldInterpreterLine;
1728− local newInterpreterLine;
1729− if [[ $# -eq 0 ]]; then
1730− echo "No arguments supplied to patchShebangs" 1>&2;
1731− return 0;
1732− fi;
1733− local f;
1734− while IFS= read -r -d '' f; do
1735− isScript "$f" || continue;
1736− read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
1737− read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
1738− if [[ -z "${pathName:-}" ]]; then
1739− if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
1740− pathName=HOST_PATH;
1741− else
1742− pathName=PATH;
1743− fi;
1744− fi;
1745− if [[ "$oldPath" == *"/bin/env" ]]; then
1746− if [[ $arg0 == "-S" ]]; then
1747− arg0=${args%% *};
1748− [[ "$args" == *" "* ]] && args=${args#* } || args=;
1749− newPath="$(PATH="${!pathName}" type -P "env" || true)";
1750− args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
1751− else
1752− if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
1753− echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
1754− exit 1;
1755− else
1756− newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
1757− fi;
1758− fi;
1759− else
1760− if [[ -z $oldPath ]]; then
1761− oldPath="/bin/sh";
1762− fi;
1763− newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
1764− args="$arg0 $args";
1765− fi;
1766− newInterpreterLine="$newPath $args";
1767− newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
1768− if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
1769− if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
1770− echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
1771− escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
1772− timestamp=$(stat --printf "%y" "$f");
1773− tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
1774− sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
1775− local restoreReadOnly;
1776− if [[ ! -w "$f" ]]; then
1777− chmod +w "$f";
1778− restoreReadOnly=true;
1779− fi;
1780− cat "$tmpFile" > "$f";
1781− rm "$tmpFile";
1782− if [[ -n "${restoreReadOnly:-}" ]]; then
1783− chmod -w "$f";
1784− fi;
1785− touch --date "$timestamp" "$f";
1786− fi;
1787− fi;
1788− done < <(find "$@" -type f -perm -0100 -print0)
1789−}
1790−patchShebangsAuto ()
1791−{
1792−
1793− if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
1794− if [[ "$output" != out && "$output" = "$outputDev" ]]; then
1795− patchShebangs --build "$prefix";
1796− else
1797− patchShebangs --host "$prefix";
1798− fi;
1799− fi
1800−}
1801−prependToVar ()
1802−{
1803−
1804− local -n nameref="$1";
1805− local useArray type;
1806− if [ -n "$__structuredAttrs" ]; then
1807− useArray=true;
1808− else
1809− useArray=false;
1810− fi;
1811− if type=$(declare -p "$1" 2> /dev/null); then
1812− case "${type#* }" in
1813− -A*)
1814− echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
1815− return 1
1816− ;;
1817− -a*)
1818− useArray=true
1819− ;;
1820− *)
1821− useArray=false
1822− ;;
1823− esac;
1824− fi;
1825− shift;
1826− if $useArray; then
1827− nameref=("$@" ${nameref+"${nameref[@]}"});
1828− else
1829− nameref="$* ${nameref-}";
1830− fi
1831−}
1832−printLines ()
1833−{
1834−
1835− (( "$#" > 0 )) || return 0;
1836− printf '%s\n' "$@"
1837−}
1838−printPhases ()
1839−{
1840−
1841− definePhases;
1842− local phase;
1843− for phase in ${phases[*]};
1844− do
1845− printf '%s\n' "$phase";
1846− done
1847−}
1848−printWords ()
1849−{
1850−
1851− (( "$#" > 0 )) || return 0;
1852− printf '%s ' "$@"
1853−}
1854−recordPropagatedDependencies ()
1855−{
1856−
1857− declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
1858− declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
1859− local propagatedInputsIndex;
1860− for propagatedInputsIndex in "${!flatVars[@]}";
1861− do
1862− local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
1863− local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
1864− [[ -n "${!propagatedInputsSlice}" ]] || continue;
1865− mkdir -p "${!outputDev}/nix-support";
1866− printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
1867− done
1868−}
1869−runHook ()
1870−{
1871−
1872− local hookName="$1";
1873− shift;
1874− local hooksSlice="${hookName%Hook}Hooks[@]";
1875− local hook;
1876− for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1877− do
1878− _logHook "$hookName" "$hook" "$@";
1879− _eval "$hook" "$@";
1880− done;
1881− return 0
1882−}
1883−runOneHook ()
1884−{
1885−
1886− local hookName="$1";
1887− shift;
1888− local hooksSlice="${hookName%Hook}Hooks[@]";
1889− local hook ret=1;
1890− for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1891− do
1892− _logHook "$hookName" "$hook" "$@";
1893− if _eval "$hook" "$@"; then
1894− ret=0;
1895− break;
1896− fi;
1897− done;
1898− return "$ret"
1899−}
1900−runPhase ()
1901−{
1902−
1903− local curPhase="$*";
1904− if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
1905− return;
1906− fi;
1907− if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
1908− return;
1909− fi;
1910− if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
1911− return;
1912− fi;
1913− if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
1914− return;
1915− fi;
1916− if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
1917− return;
1918− fi;
1919− if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
1920− return;
1921− fi;
1922− if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
1923− return;
1924− fi;
1925− if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
1926− return;
1927− fi;
1928− if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
1929− return;
1930− fi;
1931− showPhaseHeader "$curPhase";
1932− dumpVars;
1933− local startTime endTime;
1934− startTime=$(date +"%s");
1935− eval "${!curPhase:-$curPhase}";
1936− endTime=$(date +"%s");
1937− showPhaseFooter "$curPhase" "$startTime" "$endTime";
1938− if [ "$curPhase" = unpackPhase ]; then
1939− [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
1940− cd -- "${sourceRoot:-.}";
1941− fi
1942−}
1943−showPhaseFooter ()
1944−{
1945−
1946− local phase="$1";
1947− local startTime="$2";
1948− local endTime="$3";
1949− local delta=$(( endTime - startTime ));
1950− (( delta < 30 )) && return;
1951− local H=$((delta/3600));
1952− local M=$((delta%3600/60));
1953− local S=$((delta%60));
1954− echo -n "$phase completed in ";
1955− (( H > 0 )) && echo -n "$H hours ";
1956− (( M > 0 )) && echo -n "$M minutes ";
1957− echo "$S seconds"
1958−}
1959−showPhaseHeader ()
1960−{
1961−
1962− local phase="$1";
1963− echo "Running phase: $phase";
1964− if [[ -z ${NIX_LOG_FD-} ]]; then
1965− return;
1966− fi;
1967− printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
1968−}
1969−stripDirs ()
1970−{
1971−
1972− local cmd="$1";
1973− local ranlibCmd="$2";
1974− local paths="$3";
1975− local stripFlags="$4";
1976− local excludeFlags=();
1977− local pathsNew=;
1978− [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
1979− [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
1980− local pattern;
1981− if [ -n "${stripExclude:-}" ]; then
1982− for pattern in "${stripExclude[@]}";
1983− do
1984− excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
1985− done;
1986− fi;
1987− local p;
1988− for p in ${paths};
1989− do
1990− if [ -e "$prefix/$p" ]; then
1991− pathsNew="${pathsNew} $prefix/$p";
1992− fi;
1993− done;
1994− paths=${pathsNew};
1995− if [ -n "${paths}" ]; then
1996− echo "stripping (with command $cmd and flags $stripFlags) in $paths";
1997− local striperr;
1998− striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
1999− find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
2000− [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
2001− rm "$striperr";
2002− find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
2003− fi
2004−}
2005−stripHash ()
2006−{
2007−
2008− local strippedName casematchOpt=0;
2009− strippedName="$(basename -- "$1")";
2010− shopt -q nocasematch && casematchOpt=1;
2011− shopt -u nocasematch;
2012− if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
2013− echo "${strippedName:33}";
2014− else
2015− echo "$strippedName";
2016− fi;
2017− if (( casematchOpt )); then
2018− shopt -s nocasematch;
2019− fi
2020−}
2021−substitute ()
2022−{
2023−
2024− local input="$1";
2025− local output="$2";
2026− shift 2;
2027− if [ ! -f "$input" ]; then
2028− echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
2029− return 1;
2030− fi;
2031− local content;
2032− consumeEntire content < "$input";
2033− if [ -e "$output" ]; then
2034− chmod +w "$output";
2035− fi;
2036− substituteStream content "file '$input'" "$@" > "$output"
2037−}
2038−substituteAll ()
2039−{
2040−
2041− local input="$1";
2042− local output="$2";
2043− local -a args=();
2044− _allFlags;
2045− substitute "$input" "$output" "${args[@]}"
2046−}
2047−substituteAllInPlace ()
2048−{
2049−
2050− local fileName="$1";
2051− shift;
2052− substituteAll "$fileName" "$fileName" "$@"
2053−}
2054−substituteAllStream ()
2055−{
2056−
2057− local -a args=();
2058− _allFlags;
2059− substituteStream "$1" "$2" "${args[@]}"
2060−}
2061−substituteInPlace ()
2062−{
2063−
2064− local -a fileNames=();
2065− for arg in "$@";
2066− do
2067− if [[ "$arg" = "--"* ]]; then
2068− break;
2069− fi;
2070− fileNames+=("$arg");
2071− shift;
2072− done;
2073− if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
2074− echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
2075− return 1;
2076− fi;
2077− for file in "${fileNames[@]}";
2078− do
2079− substitute "$file" "$file" "$@";
2080− done
2081−}
2082−substituteStream ()
2083−{
2084−
2085− local var=$1;
2086− local description=$2;
2087− shift 2;
2088− while (( "$#" )); do
2089− local replace_mode="$1";
2090− case "$1" in
2091− --replace)
2092− if ! "$_substituteStream_has_warned_replace_deprecation"; then
2093− echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
2094− _substituteStream_has_warned_replace_deprecation=true;
2095− fi;
2096− replace_mode='--replace-warn'
2097− ;&
2098− --replace-quiet | --replace-warn | --replace-fail)
2099− pattern="$2";
2100− replacement="$3";
2101− shift 3;
2102− if ! [[ "${!var}" == *"$pattern"* ]]; then
2103− if [ "$replace_mode" == --replace-warn ]; then
2104− printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2105− else
2106− if [ "$replace_mode" == --replace-fail ]; then
2107− printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2108− return 1;
2109− fi;
2110− fi;
2111− fi;
2112− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2113− ;;
2114− --subst-var)
2115− local varName="$2";
2116− shift 2;
2117− if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
2118− echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
2119− return 1;
2120− fi;
2121− if [ -z ${!varName+x} ]; then
2122− echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
2123− return 1;
2124− fi;
2125− pattern="@$varName@";
2126− replacement="${!varName}";
2127− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2128− ;;
2129− --subst-var-by)
2130− pattern="@$2@";
2131− replacement="$3";
2132− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
2133− shift 3
2134− ;;
2135− *)
2136− echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
2137− return 1
2138− ;;
2139− esac;
2140− done;
2141− printf "%s" "${!var}"
2142−}
2143−unpackFile ()
2144−{
2145−
2146− curSrc="$1";
2147− echo "unpacking source archive $curSrc";
2148− if ! runOneHook unpackCmd "$curSrc"; then
2149− echo "do not know how to unpack source archive $curSrc";
2150− exit 1;
2151− fi
2152−}
2153−unpackPhase ()
2154−{
2155−
2156− runHook preUnpack;
2157− if [ -z "${srcs:-}" ]; then
2158− if [ -z "${src:-}" ]; then
2159− echo 'variable $src or $srcs should point to the source';
2160− exit 1;
2161− fi;
2162− srcs="$src";
2163− fi;
2164− local -a srcsArray;
2165− concatTo srcsArray srcs;
2166− local dirsBefore="";
2167− for i in *;
2168− do
2169− if [ -d "$i" ]; then
2170− dirsBefore="$dirsBefore $i ";
2171− fi;
2172− done;
2173− for i in "${srcsArray[@]}";
2174− do
2175− unpackFile "$i";
2176− done;
2177− : "${sourceRoot=}";
2178− if [ -n "${setSourceRoot:-}" ]; then
2179− runOneHook setSourceRoot;
2180− else
2181− if [ -z "$sourceRoot" ]; then
2182− for i in *;
2183− do
2184− if [ -d "$i" ]; then
2185− case $dirsBefore in
2186− *\ $i\ *)
2187−
2188− ;;
2189− *)
2190− if [ -n "$sourceRoot" ]; then
2191− echo "unpacker produced multiple directories";
2192− exit 1;
2193− fi;
2194− sourceRoot="$i"
2195− ;;
2196− esac;
2197− fi;
2198− done;
2199− fi;
2200− fi;
2201− if [ -z "$sourceRoot" ]; then
2202− echo "unpacker appears to have produced no directories";
2203− exit 1;
2204− fi;
2205− echo "source root is $sourceRoot";
2206− if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
2207− chmod -R u+w -- "$sourceRoot";
2208− fi;
2209− runHook postUnpack
2210−}
2211−updateAutotoolsGnuConfigScriptsPhase ()
2212−{
2213−
2214− if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
2215− return;
2216− fi;
2217− for script in config.sub config.guess;
2218− do
2219− for f in $(find . -type f -name "$script");
2220− do
2221− echo "Updating Autotools / GNU config script to a newer upstream version: $f";
2222− cp -f "/nix/store/4i7gvqwiq5mbl5pxh43frzq2nibxvwb5-gnu-config-2024-01-01/$script" "$f";
2223− done;
2224− done
2225−}
2226−updateSourceDateEpoch ()
2227−{
2228−
2229− local path="$1";
2230− [[ $path == -* ]] && path="./$path";
2231− local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
2232− local time="${res[0]//\.[0-9]*/}";
2233− local newestFile="${res[1]}";
2234− if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
2235− echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
2236− export SOURCE_DATE_EPOCH="$time";
2237− local now="$(date +%s)";
2238− if [ "$time" -gt $((now - 60)) ]; then
2239− echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
2240− fi;
2241− fi
2242−}
2243−PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
2244−XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
2245−export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
2246−export TMP="$NIX_BUILD_TOP"
2247−export TMPDIR="$NIX_BUILD_TOP"
2248−export TEMP="$NIX_BUILD_TOP"
2249−export TEMPDIR="$NIX_BUILD_TOP"
2250−eval "${shellHook:-}"
.github/workflows/build.yml+12−16
@@ -12,7 +12,19 @@ concurrency:
12 cancel-in-progress: true
12 cancel-in-progress: true
13
13
14jobs:
14jobs:
15+ # The Crux core is plain Rust — build and unit-test it natively (the Android
16+ # shell mirrors this logic; see README).
17+ core:
18+ name: Crux core
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - name: Test the Crux core
23+ working-directory: shared
24+ run: cargo test
25+
15 build:
26 build:
27+ name: Android app
16 runs-on: ubuntu-latest
28 runs-on: ubuntu-latest
17
29
18 steps:
30 steps:
@@ -30,31 +42,15 @@ jobs:
30 - name: Run unit tests
42 - name: Run unit tests
31 run: ./gradlew testDebugUnitTest --no-daemon
43 run: ./gradlew testDebugUnitTest --no-daemon
32
44
33− - name: Static analysis (detekt)
34− run: ./gradlew detekt --no-daemon
35−
36 - name: Lint
45 - name: Lint
37 run: ./gradlew lintDebug --no-daemon
46 run: ./gradlew lintDebug --no-daemon
38
47
39 - name: Build debug APK
48 - name: Build debug APK
40 run: ./gradlew assembleDebug --no-daemon
49 run: ./gradlew assembleDebug --no-daemon
41
50
42− - name: Build release APK
43− run: ./gradlew assembleRelease --no-daemon
44−
45 - name: Upload debug APK
51 - name: Upload debug APK
46 uses: actions/upload-artifact@v4
52 uses: actions/upload-artifact@v4
47 with:
53 with:
48 name: opencode-debug.apk
54 name: opencode-debug.apk
49 path: app/build/outputs/apk/debug/*.apk
55 path: app/build/outputs/apk/debug/*.apk
50 if-no-files-found: error
56 if-no-files-found: error
51−
52− # The release output dir is `release/` when unsigned but `release-signed/` (or a
53− # variant name) once a signing config is applied. Match both so CI keeps working
54− # once the OPENCODE_STORE_* secrets are wired up.
55− - name: Upload release APK
56− uses: actions/upload-artifact@v4
57− with:
58− name: opencode-release.apk
59− path: app/build/outputs/apk/**/*.apk
60− if-no-files-found: error
.github/workflows/release.ymlnew file+113
@@ -0,0 +1,113 @@
1+name: Release
2+
3+# Build a minified, signed release APK and publish it as a GitHub Release.
4+#
5+# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6+# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7+# Actions tab (workflow_dispatch). For a branch push or manual run the version
8+# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9+# tag name (with the leading `v` stripped) as the version.
10+#
11+# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12+# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13+#
14+# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15+# with that keystore; otherwise the Gradle build falls back to the debug key so
16+# the released APK is still installable (see app/build.gradle.kts). The version
17+# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
18+on:
19+ push:
20+ branches: [master]
21+ tags: ["v*"]
22+ workflow_dispatch:
23+
24+# Never abort a release mid-flight — a half-published release with a missing
25+# artifact is worse than a slightly delayed one.
26+concurrency:
27+ group: ${{ github.workflow }}-${{ github.ref }}
28+ cancel-in-progress: false
29+
30+jobs:
31+ release:
32+ runs-on: ubuntu-latest
33+ permissions:
34+ contents: write # push the tag and create the release
35+ id-token: write # OIDC so GitHub can sign the provenance attestation
36+ attestations: write # publish the attestation record
37+ env:
38+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
42+ steps:
43+ - uses: actions/checkout@v4
44+ with:
45+ # Full history so the versionCode (git rev-list --count HEAD) is
46+ # accurate instead of collapsing to 1 on a shallow clone.
47+ fetch-depth: 0
48+
49+ - name: Compute version
50+ id: version
51+ run: |
52+ if [ "${{ github.ref_type }}" = "tag" ]; then
53+ VERSION="${GITHUB_REF_NAME#v}"
54+ TAG="${GITHUB_REF_NAME}"
55+ else
56+ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57+ TAG="v${VERSION}"
58+ fi
59+ CODE="$(git rev-list --count HEAD)"
60+ {
61+ echo "version=$VERSION"
62+ echo "tag=$TAG"
63+ echo "code=$CODE"
64+ } >> "$GITHUB_OUTPUT"
65+ echo "Releasing $TAG (version $VERSION, code $CODE)"
66+
67+ - uses: actions/setup-java@v4
68+ with:
69+ distribution: temurin
70+ java-version: 17
71+
72+ - uses: android-actions/setup-android@v3
73+
74+ - uses: gradle/actions/setup-gradle@v4
75+
76+ # The keystore ships as base64 in a secret so it never touches the repo.
77+ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
78+ - name: Decode signing keystore
79+ if: env.OPENCODE_STORE_BASE64 != ''
80+ run: |
81+ STORE_FILE="$RUNNER_TEMP/release.keystore"
82+ printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83+ echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84+
85+ - name: Build release APK
86+ run: |
87+ ./gradlew assembleRelease \
88+ -PversionName="${{ steps.version.outputs.version }}" \
89+ -PversionCode="${{ steps.version.outputs.code }}" \
90+ --no-daemon
91+
92+ # Rename to a stable, version-tagged filename for the release attachment.
93+ - name: Stage APK
94+ run: |
95+ mkdir -p staging
96+ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97+ cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98+
99+ # Signed build-provenance attestation for the exact artifact being
100+ # published; later verifiable with `gh attestation verify <apk>`.
101+ - name: Attest build provenance
102+ uses: actions/attest-build-provenance@v2
103+ with:
104+ subject-path: staging/*.apk
105+
106+ - name: Create release
107+ uses: softprops/action-gh-release@v2
108+ with:
109+ tag_name: ${{ steps.version.outputs.tag }}
110+ name: ${{ steps.version.outputs.version }}
111+ make_latest: true
112+ generate_release_notes: true
113+ files: staging/*.apk
.gitignore+20
@@ -1,6 +1,8 @@
1# Gradle
1# Gradle
2.gradle/
2.gradle/
3build/
3build/
4+*.apk
5+*.aab
4
6
5# Kotlin
7# Kotlin
6.kotlin/
8.kotlin/
@@ -29,3 +31,21 @@ Thumbs.db
29
31
30# Nix
32# Nix
31result
33result
34+result-*
35+.direnv/
36+
37+# Local tooling / editor state (not part of the project)
38+.crush/
39+.crush.db
40+*.log
41+
42+# Rust
43+shared/target/
44+/target/
45+
46+# The `generated/` shared type sources ARE committed (regenerable via
47+# `cargo run --features typegen --bin codegen`); only the built JNI .so
48+# artifacts are ignored.
49+generated/jniLibs/
50+
51+# Gradle wrapper jar is committed so CI can run ./gradlew
AGENTS.mddeleted−197
@@ -1,197 +0,0 @@
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.mdnew file+40
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+126−64
@@ -1,91 +1,153 @@
1# opencode-android
1# opencode-android
2
2
3−A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4+`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5+and **Jetpack Compose**.
4
6
5−Application id: `soy.iko.opencode`
7+Connect to a running `opencode serve`, browse and create sessions, and chat with
8+the agent — assistant replies (text, reasoning, and tool calls) stream in live
9+over the server's SSE event stream.
6
10
7−## Set up a server and connect to it
11+<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
8
12
9−This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
13+## Architecture
10−need an opencode server running somewhere reachable from your device.
11−
12−### 1. Start the server
13
14
14−On the host that has your project and opencode installed:
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.
15
18
16−```bash
19+```
17−opencode serve --hostname 0.0.0.0 --port 4096
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+└───────────────────────────────────────────────────────────────────────┘
18```
33```
19
34
20−Useful flags:
35+### Why a Kotlin port instead of a JNI `.so`?
21
36
22−| Flag | Default | Description |
37+Crux normally compiles the Rust core to a native library and the shell talks to
23−| --- | --- | --- |
38+it over an FFI bridge. That requires an Android cross-compilation toolchain
24−| `--port` | `4096` | Port to listen on |
39+(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
25−| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
40+environment intentionally ships **without** an NDK, so instead:
26−| `--mdns` | `false` | Enable mDNS discovery |
27−| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
28
41
29−The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
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`.
30
46
31−### 2. (Optional) Add authentication
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.
32
51
33−opencode has no auth by default. To protect the server with HTTP Basic auth:
52+## Project layout
34
53
35−```bash
54+```
36−OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
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
37```
66```
38
67
39−The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
68+## Getting started
40−app's add-server dialog.
41−
42−### 3. Connect from the app
43
69
44−**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
70+### Dev shell
45
71
46−**Over USB only** — forward the port to the device and point the app at localhost:
72+Everything you need is provided by the Nix flake:
47
73
48−```bash
74+```sh
49−adb reverse tcp:4096 tcp:4096
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
50```
76```
51
77
52−Then use base URL `http://127.0.0.1:4096` in the app.
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79+build-tools 35.0.0) on your `PATH`.
53
80
54−> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
81+### Build & test
55−> a reverse proxy, use an `https://` base URL instead.
56
82
57−## Architecture
83+```sh
84+# Rust core — logic + streaming unit tests
85+cd shared && cargo test
86+
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89+
90+# Android unit tests + lint
91+./gradlew testDebugUnitTest lintDebug
92+```
93+
94+### Run against a server
95+
96+Start opencode's server on your machine or LAN:
58
97
59−- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
98+```sh
60−- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
99+opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
61− (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
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```
100```
84
101
85−## Install & run
102+Install the APK, launch the app, and enter the server URL (e.g.
103+`http://192.168.1.10:4096`). If the server was started with a password the app
104+detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105+
106+> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
107+
108+## The opencode API this client speaks
109+
110+- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111+ HTTP Basic auth.
112+- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113+- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114+- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115+ streams over SSE).
116+- **Cancel** — `POST /session/{id}/abort`.
117+- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118+ `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119+ `session.idle` (clears the "generating" state), `session.error`, and
120+ `session.{created,updated,deleted}` incrementally. Unknown event types are
121+ ignored so new server versions don't break the stream.
122+
123+## Continuous integration & releases
124+
125+- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126+ unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127+ and builds the debug APK (uploaded as an artifact).
128+- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129+ release — it builds a minified, signed release APK and publishes a GitHub
130+ Release with the APK attached and a signed build-provenance attestation.
131+
132+ ```sh
133+ git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134+ ```
135+
136+ The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137+ timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138+
139+ **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140+ keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141+ `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142+ build falls back to the debug key, so the APK is still installable.
143+
144+## Notes & limitations
145+
146+- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147+ **permission** prompts (`permission.updated`) are not yet surfaced.
148+- Model/agent selection uses the server's configured default (no picker yet).
149+- No `.so` is built in this environment (see *Architecture* above).
150+
151+## License
86
152
87−```bash
153+Apache-2.0.
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+44−96
@@ -1,21 +1,21 @@
1plugins {
1plugins {
2 alias(libs.plugins.android.application)
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.serialization)
5 alias(libs.plugins.kotlin.compose)
4 alias(libs.plugins.kotlin.compose)
6− alias(libs.plugins.detekt)
5+ alias(libs.plugins.kotlin.serialization)
7}
6}
8
7
9−/**
8+// Version is overridable from the release workflow via
10− * Monotonically increasing versionCode: the git commit count on HEAD, so each release
9+// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
11− * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
10+val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
12− * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
11+val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
13− */
12+
14−val gitCommitCount: Int = runCatching {
13+// A release keystore is supplied by CI (decoded from a secret) via env vars.
15− providers.exec {
14+// When it's absent — local builds, or CI before signing is configured — the
16− commandLine("git", "rev-list", "--count", "HEAD")
15+// release build falls back to the debug signing config so the APK is still
17− }.standardOutput.asText.get().trim().toInt()
16+// installable, rather than producing an uninstallable unsigned APK.
18−}.getOrDefault(1)
17+val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19
19
20android {
20android {
21 namespace = "soy.iko.opencode"
21 namespace = "soy.iko.opencode"
@@ -25,37 +25,35 @@ android {
25 applicationId = "soy.iko.opencode"
25 applicationId = "soy.iko.opencode"
26 minSdk = 26
26 minSdk = 26
27 targetSdk = 35
27 targetSdk = 35
28− // Monotonically increasing across builds: derived from the git commit count so
28+ versionCode = appVersionCode
29− // each release ships a strictly higher versionCode without manual bookkeeping
30− // (and never triggers a "downgrade" install block). Falls back to 1 when the
31− // .git history isn't available (e.g. a plain source export).
32− versionCode = gitCommitCount
33− versionName = "0.1.0"
29+ versionName = appVersionName
30+
34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
35 }
32 }
36
33
37− // Release builds are signed when keystore credentials are supplied via env vars
34+ signingConfigs {
38− // (so CI can publish an installable APK). When the env vars are absent, the
35+ if (hasReleaseKeystore) {
39− // release build falls back to unsigned — same behavior as before — so local
40− // builds keep working without any keystore setup.
41− val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
42− signingConfigs.create("release") {
36+ create("release") {
43− storeFile = file(path)
37+ storeFile = file(releaseStorePath!!)
44− storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
38+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
45− keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
39+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
46− keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
40+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41+ }
47 }
42 }
48 }
43 }
49
44
50 buildTypes {
45 buildTypes {
51 release {
46 release {
52 isMinifyEnabled = true
47 isMinifyEnabled = true
53− isShrinkResources = true
54 proguardFiles(
48 proguardFiles(
55 getDefaultProguardFile("proguard-android-optimize.txt"),
49 getDefaultProguardFile("proguard-android-optimize.txt"),
56 "proguard-rules.pro",
50 "proguard-rules.pro",
57 )
51 )
58− releaseSigning?.let { signingConfig = it }
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
59 }
57 }
60 }
58 }
61
59
@@ -64,92 +62,42 @@ android {
64 targetCompatibility = JavaVersion.VERSION_17
62 targetCompatibility = JavaVersion.VERSION_17
65 }
63 }
66
64
67− // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
65+ kotlinOptions {
68− // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
66+ jvmTarget = "17"
69− kotlin {
70− compilerOptions {
71− jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72− }
73 }
67 }
74
68
75 buildFeatures {
69 buildFeatures {
76 compose = true
70 compose = true
77 }
71 }
78
72
79− lint {
80− // Fail CI on real errors. Warnings are reported but don't break the build so
81− // minor/style issues don't gate development.
82− abortOnError = true
83− // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84− // 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−
90 packaging {
73 packaging {
91 resources {
74 resources {
92 excludes += "/META-INF/{AL2.0,LGPL2.1}"
75 excludes += "/META-INF/{AL2.0,LGPL2.1}"
93 }
76 }
94 }
77 }
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− }
105}
78}
106
79
107dependencies {
80dependencies {
108 implementation(libs.androidx.core.ktx)
81 implementation(libs.androidx.core.ktx)
109− implementation(libs.androidx.core.splashscreen)
82+ implementation(libs.androidx.lifecycle.runtime.ktx)
110− implementation(libs.androidx.activity.compose)
111− implementation(libs.androidx.lifecycle.viewmodel.compose)
112 implementation(libs.androidx.lifecycle.runtime.compose)
83 implementation(libs.androidx.lifecycle.runtime.compose)
113− implementation(libs.androidx.navigation.compose)
84+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
114−
115− implementation(platform(libs.compose.bom))
116− implementation(libs.compose.ui)
117− implementation(libs.compose.ui.graphics)
85+ implementation(libs.androidx.lifecycle.viewmodel.compose)
118− implementation(libs.compose.ui.tooling.preview)
119− implementation(libs.compose.material3)
120− implementation(libs.compose.material.icons.extended)
86+ implementation(libs.androidx.activity.compose)
121− debugImplementation(libs.compose.ui.tooling)
122
87
123− implementation(libs.ktor.client.core)
88+ implementation(platform(libs.androidx.compose.bom))
124− implementation(libs.ktor.client.okhttp)
89+ implementation(libs.androidx.compose.ui)
125− implementation(libs.ktor.client.content.negotiation)
90+ implementation(libs.androidx.compose.ui.graphics)
126− implementation(libs.ktor.serialization.kotlinx.json)
91+ implementation(libs.androidx.compose.ui.tooling.preview)
127− implementation(libs.ktor.client.auth)
92+ implementation(libs.androidx.compose.material3)
93+ implementation(libs.androidx.compose.material.icons.extended)
94+ debugImplementation(libs.androidx.compose.ui.tooling)
128
95
129− implementation(libs.kotlinx.serialization.json)
130 implementation(libs.kotlinx.coroutines.android)
96 implementation(libs.kotlinx.coroutines.android)
131−
132− implementation(libs.androidx.datastore.preferences)
133− implementation(libs.androidx.security.crypto)
134− implementation(libs.androidx.biometric)
97+ implementation(libs.kotlinx.serialization.json)
135− implementation(libs.coil.compose)
136− implementation(libs.markdown.renderer)
98+ implementation(libs.okhttp)
137− implementation(libs.markdown.renderer.m3)
99+ implementation(libs.okhttp.sse)
138
100
139 testImplementation(libs.junit)
101 testImplementation(libs.junit)
140 testImplementation(libs.kotlinx.coroutines.test)
102 testImplementation(libs.kotlinx.coroutines.test)
141− testImplementation(libs.ktor.client.mock)
142−
143− androidTestImplementation(libs.androidx.test.ext.junit)
144− androidTestImplementation(libs.androidx.test.runner)
145− androidTestImplementation(libs.androidx.test.core)
146− androidTestImplementation(libs.kotlinx.coroutines.test)
147−}
148−
149−detekt {
150− buildUponDefaultConfig = true
151− // Carry forward the current baseline so CI only fails on *new* findings;
152− // existing issues are grandfathered rather than blocking the migration.
153− baseline = file("detekt-baseline.xml")
154− config.setFrom("$rootDir/config/detekt/detekt.yml")
155}
103}
app/detekt-baseline.xmldeleted−17
@@ -1,17 +0,0 @@
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, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
8− <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
9− <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
10− <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
11− <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
12− <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
13− <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
14− <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
15− <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
16− </CurrentIssues>
17−</SmellBaseline>
app/proguard-rules.pro+13−44
@@ -1,47 +1,16 @@
1−# --- kotlinx.serialization ---
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−# The plugin generates serializers; keep them and the $$serializer companion members.
3−-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
2+-keepattributes *Annotation*, InnerClasses
4-dontnote kotlinx.serialization.**
3-dontnote kotlinx.serialization.**
5−-keepclassmembers class soy.iko.opencode.** {
6− *** Companion;
7−}
8−-keepclasseswithmembers class soy.iko.opencode.** {
9− kotlinx.serialization.KSerializer serializer(...);
10−}
11−# Keep @Serializable classes themselves so their generated serializers can reference them.
12−-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13−-keepclassmembers class soy.iko.opencode.** {
14− *** Companion;
15−}
16−# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
18
4
19−# --- Ktor / OkHttp (HTTP client + SSE) ---
5+-keepclassmembers class **$$serializer { *; }
20−# Don't warn about missing optional dependencies; keep only the classes that are
6+-keepclasseswithmembers class * {
21−# accessed via reflection (Ktor engine registration, OkHttp internals that R8
7+ @kotlinx.serialization.Serializable <methods>;
22−# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
8+}
23−# so R8 can still eliminate dead code and shrink the APK.
9+-if @kotlinx.serialization.Serializable class **
24−-dontwarn io.ktor.**
10+-keep, allowobfuscation, allowshrinking class <1>
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.**
44
11
45−# --- androidx.security.crypto pulls in Tink, which references compile-only
12+# OkHttp / okio — platform-specific bits referenced reflectively.
46−# com.google.errorprone annotations. They aren't on the runtime classpath.
13+-dontwarn okhttp3.internal.platform.**
47−-dontwarn com.google.errorprone.annotations.**
14+-dontwarn org.conscrypt.**
15+-dontwarn org.bouncycastle.**
16+-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertNotNull
6−import org.junit.Assert.assertTrue
7−import org.junit.Test
8−import org.junit.runner.RunWith
9−
10−/**
11− * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12− * are initialized on startup, and the notification channels are created.
13− */
14−@RunWith(AndroidJUnit4::class)
15−class ApplicationBootstrapTest {
16−
17− @Test
18− fun containerIsInitialized() {
19− val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20− assertNotNull(app.container)
21− assertNotNull(app.container.settingsStore)
22− }
23−
24− @Test
25− fun notificationChannelsExist() {
26− if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27− val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28− // Touching the app instance runs Application.onCreate, which creates the channels.
29− ApplicationProvider.getApplicationContext<OpencodeApp>()
30− val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31− as android.app.NotificationManager
32− val ids = nm.notificationChannels.map { it.id }
33− assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34− assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35− }
36−}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktdeleted−67
@@ -1,67 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertNotNull
7−import org.junit.Assert.assertNull
8−import org.junit.Assert.assertTrue
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−/**
13− * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14− * real app-private filesystem: a simulated crash is written, then read back, listed, and
15− * deleted through the public API.
16− */
17−@RunWith(AndroidJUnit4::class)
18−class CrashLoggerTest {
19−
20− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21− private val logger = CrashLogger.get(context)
22−
23− @Test
24− fun writeReadDeleteRoundTrip() {
25− logger.clearAll()
26−
27− // Simulate an uncaught exception by invoking the report path directly.
28− val report = writeFakeReport()
29−
30− logger.refresh()
31− assertEquals(1, logger.reportCount())
32−
33− val content = logger.readReport(report)
34− assertNotNull(content)
35− assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36− assertTrue("report should contain device metadata", content.contains("Android:"))
37−
38− logger.deleteReport(report)
39− assertEquals(0, logger.reportCount())
40− assertNull(logger.readReport(report))
41− }
42−
43− @Test
44− fun clearAllRemovesEveryReport() {
45− writeFakeReport("a")
46− writeFakeReport("b")
47− logger.refresh()
48− assertTrue(logger.reportCount() >= 2)
49−
50− logger.clearAll()
51− assertEquals(0, logger.reportCount())
52− }
53−
54− private fun writeFakeReport(tag: String = "test"): String {
55− val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56− val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57− java.io.File(dir, name).writeText(
58− """
59− opencode-android crash report
60− Android: test
61− java.lang.IllegalStateException: boom $tag
62− at Crasher.crash(Crasher.kt:1)
63− """.trimIndent(),
64− )
65− return name
66− }
67−}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktdeleted−49
@@ -1,49 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import kotlinx.coroutines.CoroutineScope
6−import kotlinx.coroutines.Dispatchers
7−import kotlinx.coroutines.SupervisorJob
8−import org.junit.Assert.assertEquals
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−@RunWith(AndroidJUnit4::class)
13−class DraftStoreTest {
14−
15− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16−
17− @Test
18− fun setGetRemoveRoundTrip() {
19− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20− val store = DraftStore(context, scope)
21−
22− // Use a unique session ID so it doesn't collide with other tests.
23− val sid = "test-${System.nanoTime()}"
24−
25− store.set(sid, "hello world")
26− assertEquals("hello world", store.get(sid))
27−
28− // Overwrite
29− store.set(sid, "updated")
30− assertEquals("updated", store.get(sid))
31−
32− // Blank clears the entry
33− store.set(sid, " ")
34− assertEquals("", store.get(sid))
35−
36− // Explicit remove
37− store.set(sid, "text")
38− store.remove(sid)
39− assertEquals("", store.get(sid))
40−
41− scope.cancel()
42− }
43−
44− @Test
45− fun getReturnsEmptyForUnknownSession() {
46− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47− assertEquals("", store.get("never-set-${System.nanoTime()}"))
48− }
49−}
app/src/main/AndroidManifest.xml+7−97
@@ -1,116 +1,26 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3− xmlns:tools="http://schemas.android.com/tools">
4
3
5 <uses-permission android:name="android.permission.INTERNET" />
4 <uses-permission android:name="android.permission.INTERNET" />
6 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7− <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8− <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9− <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10−
11− <!-- Camera is used opportunistically for attachment capture via the system camera app
12− (ACTION_IMAGE_CAPTURE), so it's optional — devices without a camera still install. -->
13− <uses-feature android:name="android.hardware.camera.any" android:required="false" />
14
6
15 <application
7 <application
16− android:name=".OpencodeApp"
17− android:allowBackup="false"
8+ android:allowBackup="true"
18− android:dataExtractionRules="@xml/data_extraction_rules"
9+ android:icon="@android:drawable/sym_def_app_icon"
19− android:fullBackupContent="false"
20− android:icon="@mipmap/ic_launcher"
21 android:label="@string/app_name"
10 android:label="@string/app_name"
22− android:networkSecurityConfig="@xml/network_security_config"
23 android:supportsRtl="true"
11 android:supportsRtl="true"
24− android:theme="@style/Theme.OpencodeAndroid">
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
25
14
26 <activity
15 <activity
27 android:name=".MainActivity"
16 android:name=".MainActivity"
28 android:exported="true"
17 android:exported="true"
29− android:launchMode="singleTop"
18+ android:windowSoftInputMode="adjustResize"
30− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31− android:enableOnBackInvokedCallback="true"
32− tools:targetApi="33"
33− android:theme="@style/Theme.OpencodeAndroid.Splash">
19+ android:theme="@style/Theme.OpencodeAndroid">
34 <intent-filter>
20 <intent-filter>
35 <action android:name="android.intent.action.MAIN" />
21 <action android:name="android.intent.action.MAIN" />
36 <category android:name="android.intent.category.LAUNCHER" />
22 <category android:name="android.intent.category.LAUNCHER" />
37 </intent-filter>
23 </intent-filter>
38− <intent-filter>
39− <action android:name="android.intent.action.SEND" />
40− <category android:name="android.intent.category.DEFAULT" />
41− <data android:mimeType="text/plain" />
42− </intent-filter>
43− <!-- Receive images shared from other apps (single or multiple) to attach to a prompt. -->
44− <intent-filter>
45− <action android:name="android.intent.action.SEND" />
46− <category android:name="android.intent.category.DEFAULT" />
47− <data android:mimeType="image/*" />
48− </intent-filter>
49− <intent-filter>
50− <action android:name="android.intent.action.SEND_MULTIPLE" />
51− <category android:name="android.intent.category.DEFAULT" />
52− <data android:mimeType="image/*" />
53− </intent-filter>
54− <!-- Deep link to a session: opencode://session/{sessionId} -->
55− <intent-filter>
56− <action android:name="android.intent.action.VIEW" />
57− <category android:name="android.intent.category.DEFAULT" />
58− <category android:name="android.intent.category.BROWSABLE" />
59− <data android:scheme="opencode" android:host="session" />
60− </intent-filter>
61 </activity>
24 </activity>
62−
63− <service
64− android:name=".notification.RunForegroundService"
65− android:exported="false"
66− android:foregroundServiceType="dataSync" />
67−
68− <!-- Handles notification action buttons (permission responses, inline reply). Not
69− exported: only the app's own PendingIntents target it. -->
70− <receiver
71− android:name=".notification.NotificationActionReceiver"
72− android:exported="false" />
73−
74− <!-- Home-screen widget listing recent sessions. -->
75− <receiver
76− android:name=".platform.SessionsWidgetProvider"
77− android:exported="true">
78− <intent-filter>
79− <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
80− </intent-filter>
81− <meta-data
82− android:name="android.appwidget.provider"
83− android:resource="@xml/sessions_widget_info" />
84− </receiver>
85−
86− <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS. -->
87− <service
88− android:name=".platform.SessionsWidgetService"
89− android:permission="android.permission.BIND_REMOTEVIEWS"
90− android:exported="false" />
91−
92− <!-- Quick-settings tile: start a new session. Bound by the system. -->
93− <service
94− android:name=".platform.NewSessionTileService"
95− android:icon="@drawable/ic_launcher_foreground"
96− android:label="@string/tile_new_session"
97− android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
98− android:exported="true">
99− <intent-filter>
100− <action android:name="android.service.quicksettings.action.QS_TILE" />
101− </intent-filter>
102− </service>
103−
104− <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
105− <provider
106− android:name="androidx.core.content.FileProvider"
107− android:authorities="${applicationId}.fileprovider"
108− android:exported="false"
109− android:grantUriPermissions="true">
110− <meta-data
111− android:name="android.support.FILE_PROVIDER_PATHS"
112− android:resource="@xml/file_paths" />
113− </provider>
114 </application>
25 </application>
115−
116</manifest>
26</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import androidx.core.content.edit
6+import androidx.lifecycle.AndroidViewModel
7+import androidx.lifecycle.viewModelScope
8+import kotlinx.coroutines.flow.distinctUntilChangedBy
9+import kotlinx.coroutines.launch
10+import soy.iko.opencode.core.Event
11+import soy.iko.opencode.core.OpencodeCore
12+
13+/**
14+ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15+ * connection) survives configuration changes. It also persists the last server
16+ * URL / username so reconnecting is one tap.
17+ */
18+class CoreViewModel(app: Application) : AndroidViewModel(app) {
19+ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20+
21+ val core = OpencodeCore(viewModelScope)
22+
23+ init {
24+ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25+ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26+ // The password is stored in plain text in SharedPreferences — a deliberate
27+ // convenience trade-off for a local dev tool so reconnecting is one tap.
28+ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29+
30+ // On a fresh process after a configuration change, resume the SSE stream if
31+ // the core is already connected (no-op otherwise — it guards on `connected`).
32+ core.resumeStreamingIfConnected()
33+
34+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35+ // aren't already connected, kick off the connection attempt on launch. The core
36+ // handles an empty/invalid URL, so this only fires when there's something to try.
37+ core.view.value.let { restored ->
38+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39+ core.dispatch(Event.Connect)
40+ }
41+ }
42+
43+ // Persist connection details once a connection succeeds.
44+ viewModelScope.launch {
45+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46+ if (state.connected) {
47+ prefs.edit {
48+ putString(KEY_URL, state.serverUrl)
49+ putString(KEY_USER, state.username)
50+ putString(KEY_PASSWORD, state.password)
51+ }
52+ }
53+ }
54+ }
55+ }
56+
57+ override fun onCleared() {
58+ core.shutdown()
59+ }
60+
61+ private companion object {
62+ const val KEY_URL = "serverUrl"
63+ const val KEY_USER = "username"
64+ const val KEY_PASSWORD = "password"
65+ }
66+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−181
@@ -1,197 +1,31 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
3−import android.Manifest
4−import android.content.Intent
5−import android.content.pm.PackageManager
6−import android.net.Uri
7−import android.os.Build
8import android.os.Bundle
3import android.os.Bundle
9−import android.view.WindowManager
4+import androidx.activity.ComponentActivity
10−import androidx.core.content.IntentCompat
5+import androidx.activity.SystemBarStyle
11−import soy.iko.opencode.data.network.NetworkConfig
12import androidx.activity.compose.setContent
6import androidx.activity.compose.setContent
13import androidx.activity.enableEdgeToEdge
7import androidx.activity.enableEdgeToEdge
14−import androidx.activity.result.contract.ActivityResultContracts
15−import androidx.compose.foundation.isSystemInDarkTheme
16−import androidx.compose.runtime.LaunchedEffect
17import androidx.compose.runtime.getValue
8import androidx.compose.runtime.getValue
18−import androidx.compose.runtime.remember
19−import kotlinx.coroutines.flow.combine
20−import androidx.core.content.ContextCompat
21−import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22−import androidx.fragment.app.FragmentActivity
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
9import androidx.lifecycle.compose.collectAsStateWithLifecycle
24−import soy.iko.opencode.data.repo.ThemeMode
10+import androidx.lifecycle.viewmodel.compose.viewModel
25−import soy.iko.opencode.ui.AppLockGate
26−import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
11+import soy.iko.opencode.ui.OpencodeApp
27import soy.iko.opencode.ui.theme.OpencodeTheme
12import soy.iko.opencode.ui.theme.OpencodeTheme
28
13
29−// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
30−// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
31−// /activity-result APIs are unaffected.
32−class MainActivity : FragmentActivity() {
14+class MainActivity : ComponentActivity() {
33−
34− private val requestNotificationPermission = registerForActivityResult(
35− ActivityResultContracts.RequestPermission(),
36− ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
37−
38− private var shareIntentHandled = false
39− private var openSessionHandled = false
40− private var newSessionHandled = false
41−
42 override fun onCreate(savedInstanceState: Bundle?) {
15 override fun onCreate(savedInstanceState: Bundle?) {
43− val splash = installSplashScreen()
16+ enableEdgeToEdge(
17+ statusBarStyle = SystemBarStyle.auto(
18+ lightScrim = 0xFF0F1115.toInt(),
19+ darkScrim = 0xFF0F1115.toInt(),
20+ ),
21+ )
44 super.onCreate(savedInstanceState)
22 super.onCreate(savedInstanceState)
45− enableEdgeToEdge()
46− savedInstanceState?.let {
47− shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
48− openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
49− newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
50− }
51− val container = (application as OpencodeApp).container
52− handleIntent(intent)
53− if (savedInstanceState == null) maybeRequestNotificationPermission()
54− // Hold the splash until the persisted theme settings load, so we never paint a
55− // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
56− // user's real choice — the flash the SettingsScreen's null-gate already avoids.
57− var themeLoaded = false
58− splash.setKeepOnScreenCondition { !themeLoaded }
59 setContent {
23 setContent {
60− val theme by remember(container) {
24+ OpencodeTheme {
61− container.settingsStore.themeMode
25+ val vm: CoreViewModel = viewModel()
62− .combine(container.settingsStore.dynamicColor, ::Pair)
26+ val state by vm.core.view.collectAsStateWithLifecycle()
63− }.collectAsStateWithLifecycle(initialValue = null)
27+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
64− val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = false)
65− LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
66− // Hide the app's content from screenshots / the recents thumbnail while app lock
67− // is enabled, so protected server details don't leak there.
68− LaunchedEffect(appLock) {
69− if (appLock) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
70− else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
71 }
28 }
72− theme?.let { (themeMode, dynamicColor) ->
73− val dark = when (themeMode) {
74− ThemeMode.SYSTEM -> isSystemInDarkTheme()
75− ThemeMode.LIGHT -> false
76− ThemeMode.DARK -> true
77− }
78− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
79− AppLockGate(enabled = appLock) {
80− OpencodeAppUi(container = container)
81− }
82− }
83− }
84− }
85− }
86−
87− override fun onNewIntent(intent: Intent) {
88− super.onNewIntent(intent)
89− setIntent(intent)
90− shareIntentHandled = false
91− openSessionHandled = false
92− newSessionHandled = false
93− handleIntent(intent)
94− }
95−
96− // Track real foreground state so background notifications (permission requests,
97− // completions, errors) still fire for a session the user "has open" but has walked
98− // away from — the chat screen isn't disposed when the phone is locked, so
99− // container.currentSession stays set and can't be used as a foreground signal.
100− override fun onStart() {
101− super.onStart()
102− (application as OpencodeApp).container.setForeground(true)
103− }
104−
105− override fun onStop() {
106− super.onStop()
107− (application as OpencodeApp).container.setForeground(false)
108− }
109−
110− /** Capture text shared from another app so it can be prefilled into a session draft,
111− * and session ids from notification taps / deep links so we can open them. */
112− private fun handleIntent(intent: Intent?) {
113− val container = (application as OpencodeApp).container
114− val action = intent?.action
115− // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
116− // process-death restore (which re-delivers the intent) doesn't spawn a second session.
117− if (action == ACTION_NEW_SESSION && !newSessionHandled) {
118− container.requestNewSession()
119− newSessionHandled = true
120 }
29 }
121− if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
122− val type = intent.type.orEmpty()
123− if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
124− // A single shared image → stage as an attachment in the next opened session.
125− val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
126− if (uri != null) {
127− container.setPendingSharedMedia(listOf(uri.toString()))
128− shareIntentHandled = true
129− }
130− } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
131− val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
132− ?.take(NetworkConfig.maxAttachments)
133− .orEmpty()
134− if (uris.isNotEmpty()) {
135− container.setPendingSharedMedia(uris.map { it.toString() })
136− shareIntentHandled = true
137− }
138− } else {
139− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
140− ?.takeIf { it.isNotBlank() }
141− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
142− if (text != null) {
143− container.setPendingShare(text)
144− shareIntentHandled = true
145− }
146− }
147− }
148− // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
149− val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
150− ?.data
151− ?.takeIf { it.host == "session" }
152− ?.lastPathSegment
153− val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
154− // Validate: session ids are opaque server-generated identifiers. Reject anything
155− // with path separators or other traversal/control characters so a malicious deep
156− // link can't inject path components into the REST URL path.
157− // Guard against re-firing the retained ACTION_VIEW intent after process-death
158− // restore: onCreate calls handleIntent again with the original intent, so without
159− // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
160− if (!openSessionHandled) {
161− id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
162− container.requestOpenSession(it)
163− openSessionHandled = true
164− }
165− }
166− }
167−
168− companion object {
169− /** Intent extra carrying a session id to open (notifications / deep links / widget). */
170− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
171−
172− /** Action for the "New session" launcher shortcut and quick-settings tile. */
173− const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
174−
175− /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
176− private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
177−
178− private const val KEY_SHARE_HANDLED = "shareIntentHandled"
179− private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
180− private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
181− }
182−
183− override fun onSaveInstanceState(outState: Bundle) {
184− super.onSaveInstanceState(outState)
185− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
186− outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
187− outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
188− }
189−
190− /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
191− private fun maybeRequestNotificationPermission() {
192− if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
193− val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
194− PackageManager.PERMISSION_GRANTED
195− if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
196 }
30 }
197}
31}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−59
@@ -1,59 +0,0 @@
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.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.Dispatchers
4+import kotlinx.coroutines.suspendCancellableCoroutine
5+import kotlinx.coroutines.withContext
6+import okhttp3.Call
7+import okhttp3.Callback
8+import okhttp3.MediaType.Companion.toMediaType
9+import okhttp3.OkHttpClient
10+import okhttp3.Request
11+import okhttp3.RequestBody.Companion.toRequestBody
12+import okhttp3.Response
13+import java.io.IOException
14+import java.util.concurrent.TimeUnit
15+import kotlin.coroutines.resume
16+import kotlin.coroutines.resumeWithException
17+
18+/** A completed HTTP response (any status code — non-2xx is not an error here). */
19+data class HttpResp(val code: Int, val body: String)
20+
21+/**
22+ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23+ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24+ * carrying its status code, matching the Rust core's `HttpResult` handling.
25+ */
26+class HttpClient(
27+ val okhttp: OkHttpClient = OkHttpClient.Builder()
28+ .connectTimeout(15, TimeUnit.SECONDS)
29+ .readTimeout(30, TimeUnit.SECONDS)
30+ .build(),
31+) {
32+ private val jsonMedia = "application/json".toMediaType()
33+
34+ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35+ exec(url, auth) { get() }
36+
37+ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38+ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39+
40+ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41+ exec(url, auth) { delete() }
42+
43+ // Build and run the request off the main thread. Building it — parsing [url]
44+ // in particular — can throw (e.g. a URL with no scheme), so that happens
45+ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46+ // instead of crashing the caller's coroutine.
47+ private suspend fun exec(
48+ url: String,
49+ auth: String?,
50+ method: Request.Builder.() -> Request.Builder,
51+ ): Result<HttpResp> = withContext(Dispatchers.IO) {
52+ runCatching {
53+ val builder = Request.Builder().url(url).method()
54+ builder.header("User-Agent", "opencode-android/1.0")
55+ if (auth != null) builder.header("Authorization", auth)
56+ await(okhttp.newCall(builder.build()))
57+ }
58+ }
59+
60+ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61+ cont.invokeOnCancellation { runCatching { call.cancel() } }
62+ call.enqueue(object : Callback {
63+ override fun onFailure(call: Call, e: IOException) {
64+ if (cont.isActive) cont.resumeWithException(e)
65+ }
66+
67+ override fun onResponse(call: Call, response: Response) {
68+ response.use {
69+ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70+ if (cont.isActive) cont.resume(HttpResp(it.code, body))
71+ }
72+ }
73+ })
74+ }
75+}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktnew file+668
@@ -0,0 +1,668 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
5+import kotlinx.coroutines.delay
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import java.util.Base64
11+
12+/**
13+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14+ *
15+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16+ * observable [view]. Effects are executed here in the shell: REST calls through
17+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18+ * server-sent event is fed back in and applied incrementally, so assistant
19+ * replies stream in token-by-token.
20+ *
21+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22+ * network I/O hops to a background thread inside the clients.
23+ */
24+class OpencodeCore(
25+ private val scope: CoroutineScope,
26+ private val http: HttpClient = HttpClient(),
27+) {
28+ // ── Model ────────────────────────────────────────────────────────────────
29+ private var serverUrl = ""
30+ private var username = ""
31+ private var password = ""
32+ private var authRequired = false
33+ private var connected = false
34+ private var loading = false
35+ private var error: String? = null
36+ private var screen = Screen.Connect
37+ private val sessions = mutableListOf<SessionView>()
38+ private var currentSessionId: String? = null
39+ private var currentSessionTitle = ""
40+ private val messages = mutableListOf<MsgState>()
41+ private var draft = ""
42+ private var generating = false
43+ private var sseConnected = true
44+ private var localSeq = 0L
45+
46+ private val _view = MutableStateFlow(snapshot())
47+ val view: StateFlow<UiState> = _view.asStateFlow()
48+
49+ private var sse: SseClient? = null
50+ private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
53+
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
77+
78+ // ── Event entry point ──────────────────────────────────────────────────────
79+ fun dispatch(event: Event) {
80+ when (event) {
81+ // Store the field verbatim while typing; normalizing here would fight
82+ // the user's edits (e.g. eat the "://" as they type it). The URL is
83+ // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89+
90+ Event.Connect -> {
91+ serverUrl = normalizeUrl(serverUrl)
92+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
98+ loading = true; error = null; emit()
99+ connectJob?.cancel()
100+ connectJob = scope.launch { probeHealth() }
101+ }
102+
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
107+ loadSessionsJob = scope.launch { loadSessions() }
108+ }
109+
110+ is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
116+ currentSessionId = event.id
117+ messages.clear()
118+ removedMessageIds.clear()
119+ screen = Screen.Chat
120+ loading = true
121+ generating = false
122+ error = null
123+ emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
130+ }
131+
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
136+ createSessionJob = scope.launch { createSession() }
137+ }
138+
139+ is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
141+ val id = event.id
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
150+ sessions.removeAll { it.id == id }
151+ if (isCurrent(id)) {
152+ currentSessionId = null
153+ messages.clear()
154+ screen = Screen.Sessions
155+ }
156+ } else {
157+ error = "Failed to delete session"
158+ }
159+ loading = false
160+ },
161+ onFailure = {
162+ error = "Failed to delete session"
163+ loading = false
164+ },
165+ )
166+ emit()
167+ }
168+ }
169+
170+ is Event.DraftChanged -> { draft = event.text; emit() }
171+
172+ is Event.SendMessage -> sendMessage(event.text)
173+
174+ Event.CancelGeneration -> {
175+ generating = false
176+ messages.forEach { it.streaming = false }
177+ emit()
178+ val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
181+ abortJob?.cancel()
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183+ }
184+
185+ Event.NavigateToSessions -> {
186+ screen = Screen.Sessions
187+ currentSessionId = null
188+ messages.clear()
189+ removedMessageIds.clear()
190+ draft = ""
191+ generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
195+ loading = true
196+ emit()
197+ connectJob?.cancel()
198+ abortJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
204+ loadSessionsJob = scope.launch { loadSessions() }
205+ }
206+
207+ Event.NavigateToConnect -> {
208+ screen = Screen.Connect
209+ connected = false
210+ loading = false
211+ generating = false
212+ sessions.clear()
213+ messages.clear()
214+ removedMessageIds.clear()
215+ currentSessionId = null
216+ draft = ""
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
219+ sseConnected = true
220+ connectJob?.cancel()
221+ abortJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
226+ closeSse()
227+ emit()
228+ }
229+
230+ Event.DismissError -> { error = null; emit() }
231+ }
232+ }
233+
234+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235+ fun resumeStreamingIfConnected() {
236+ if (connected && sse == null) startSse()
237+ }
238+
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
248+ abortJob?.cancel()
249+ closeSse()
250+ }
251+
252+ // ── Effects ────────────────────────────────────────────────────────────────
253+ private fun sendMessage(raw: String) {
254+ val text = raw.trim()
255+ val session = currentSessionId ?: return
256+ if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
260+ localSeq += 1
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262+ status = MessageStatus.Pending
263+ textParts["local"] = text
264+ }
265+ messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
269+ draft = ""
270+ generating = true
271+ error = null
272+ emit()
273+
274+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
276+ sendMessageJob = scope.launch {
277+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278+ result.fold(
279+ onSuccess = { resp ->
280+ if (resp.code in 200..299) {
281+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282+ } else {
283+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284+ generating = false
285+ messages.forEach { it.streaming = false }
286+ draft = savedDraft
287+ error = "Server returned status ${resp.code}"
288+ }
289+ },
290+ onFailure = { e ->
291+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292+ generating = false
293+ messages.forEach { it.streaming = false }
294+ draft = savedDraft
295+ error = "Request failed: ${e.message}"
296+ },
297+ )
298+ emit()
299+ }
300+ }
301+
302+ private suspend fun probeHealth() {
303+ val hasCreds = hasCredentials()
304+ http.get("$serverUrl/global/health", authHeader()).fold(
305+ onSuccess = { resp ->
306+ when {
307+ resp.code == 401 -> {
308+ loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
312+ connected = false
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
315+ authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
317+ emit()
318+ }
319+ resp.code in 200..299 -> {
320+ connected = true
321+ authRequired = false
322+ error = null
323+ screen = Screen.Sessions
324+ loading = true
325+ emit()
326+ startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
330+ loadSessionsJob = scope.launch { loadSessions() }
331+ }
332+ else -> {
333+ loading = false
334+ connected = false
335+ error = "Server returned status ${resp.code}"
336+ emit()
337+ }
338+ }
339+ },
340+ onFailure = { e ->
341+ loading = false
342+ connected = false
343+ error = "Connection failed: ${e.message}"
344+ emit()
345+ },
346+ )
347+ }
348+
349+ private suspend fun loadSessions() {
350+ http.get("$serverUrl/session", authHeader()).fold(
351+ onSuccess = { resp ->
352+ if (resp.code in 200..299) {
353+ sessions.clear()
354+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
358+ } else {
359+ error = "Server returned status ${resp.code}"
360+ }
361+ },
362+ onFailure = { e -> error = "Request failed: ${e.message}" },
363+ )
364+ loading = false
365+ emit()
366+ }
367+
368+ private suspend fun createSession() {
369+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370+ onSuccess = { resp ->
371+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372+ if (session != null) {
373+ if (sessions.none { it.id == session.id }) {
374+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
379+ }
380+ emit()
381+ // SelectSession takes over the loading state (loads messages).
382+ dispatch(Event.SelectSession(session.id))
383+ return
384+ }
385+ error = "Server returned status ${resp.code}"
386+ },
387+ onFailure = { e -> error = "Request failed: ${e.message}" },
388+ )
389+ loading = false
390+ emit()
391+ }
392+
393+ private suspend fun loadMessages(sessionId: String) {
394+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395+ onSuccess = { resp ->
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397+ loading = false
398+ if (resp.code in 200..299) {
399+ messages.clear()
400+ removedMessageIds.clear()
401+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402+ generating = messages.any { it.streaming }
403+ } else {
404+ error = "Server returned status ${resp.code}"
405+ }
406+ emit()
407+ },
408+ onFailure = { e ->
409+ if (!isCurrent(sessionId)) { loading = false; return }
410+ loading = false
411+ generating = false
412+ error = "Request failed: ${e.message}"
413+ emit()
414+ },
415+ )
416+ }
417+
418+ // ── SSE ─────────────────────────────────────────────────────────────────────
419+ private fun startSse() {
420+ closeSse()
421+ sseClosedIntentionally = false
422+ val client = SseClient(
423+ url = "$serverUrl/event",
424+ auth = authHeader(),
425+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426+ onStateChange = { open ->
427+ scope.launch {
428+ sseConnected = open
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
430+ emit()
431+ }
432+ },
433+ )
434+ sse = client
435+ client.connect()
436+ }
437+
438+ private fun closeSse() {
439+ sseClosedIntentionally = true
440+ reconnectJob?.cancel()
441+ reconnectJob = null
442+ sse?.close()
443+ sse = null
444+ }
445+
446+ private fun scheduleReconnect() {
447+ if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
455+ reconnectJob = scope.launch {
456+ delay(delayMs)
457+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458+ }
459+ }
460+
461+ private fun handleServerEvent(json: String) {
462+ val event = Protocol.parseEvent(json) ?: return
463+ // Only re-render if the event actually changed our state; applyEvent returns
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
466+ }
467+
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
469+ private fun applyEvent(event: Protocol.Event): Boolean {
470+ when (event) {
471+ is Protocol.Event.MessageUpdated -> {
472+ val info = event.info
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
477+ val streaming = info.time.completed == null && info.error == null
478+ val existing = messages.firstOrNull { it.id == info.id }
479+ if (existing != null) {
480+ existing.streaming = streaming
481+ if (existing.time == 0L) existing.time = info.time.created
482+ } else {
483+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484+ }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486+ return true
487+ }
488+
489+ is Protocol.Event.PartUpdated -> {
490+ val part = event.part
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
494+ return when (part) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
496+ false
497+ } else {
498+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499+ true
500+ }
501+ is Protocol.Part.Reasoning -> {
502+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503+ true
504+ }
505+ is Protocol.Part.Tool -> {
506+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507+ true
508+ }
509+ }
510+ }
511+
512+ is Protocol.Event.PartRemoved -> {
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
515+ return true
516+ }
517+
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
521+ return messages.removeAll { it.id == event.messageId }
522+ }
523+
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
526+ generating = false
527+ messages.forEach { it.streaming = false }
528+ return true
529+ }
530+
531+ is Protocol.Event.SessionError -> {
532+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533+ if (!scoped) return false
534+ event.error?.let { error = it.message() }
535+ generating = false
536+ messages.forEach { it.streaming = false }
537+ return true
538+ }
539+
540+ is Protocol.Event.SessionUpserted -> {
541+ val title = displayTitle(event.info.title)
542+ val existing = sessions.firstOrNull { it.id == event.info.id }
543+ if (existing != null) {
544+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545+ } else {
546+ sessions.add(0, SessionView(event.info.id, title))
547+ }
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
551+ if (isCurrent(event.info.id)) currentSessionTitle = title
552+ return true
553+ }
554+
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
557+ val removed = sessions.removeAll { it.id == id }
558+ if (isCurrent(id)) {
559+ currentSessionId = null
560+ messages.clear()
561+ screen = Screen.Sessions
562+ return true
563+ }
564+ return removed
565+ }
566+
567+ Protocol.Event.Other -> return false
568+ }
569+ }
570+
571+ // ── View / helpers ──────────────────────────────────────────────────────────
572+ private fun snapshot() = UiState(
573+ screen = screen,
574+ serverUrl = serverUrl,
575+ username = username,
576+ password = password,
577+ authRequired = authRequired,
578+ connected = connected,
579+ loading = loading,
580+ error = error,
581+ sessions = sessions.toList(),
582+ currentSessionId = currentSessionId,
583+ currentSessionTitle = currentSessionTitle,
584+ messages = messages.map { it.toView() },
585+ draft = draft,
586+ generating = generating,
587+ sseConnected = sseConnected,
588+ )
589+
590+ private fun emit() { _view.value = snapshot() }
591+
592+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594+
595+ private fun authHeader(): String? {
596+ if (!hasCredentials()) return null
597+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598+ return "Basic $encoded"
599+ }
600+}
601+
602+// ── Internal message state (parts tracked by id for streaming) ─────────────────
603+private class MsgState(val id: String, val role: String, var time: Long) {
604+ var status = MessageStatus.Sent
605+ var streaming = false
606+ val textParts = LinkedHashMap<String, String>()
607+ val reasoningParts = LinkedHashMap<String, String>()
608+ val toolParts = LinkedHashMap<String, ToolView>()
609+
610+ fun remove(partId: String) {
611+ textParts.remove(partId)
612+ reasoningParts.remove(partId)
613+ toolParts.remove(partId)
614+ }
615+
616+ fun toView(): MessageView {
617+ val reasoning = reasoningParts.values.joinToString("")
618+ return MessageView(
619+ id = id,
620+ role = role,
621+ text = textParts.values.joinToString(""),
622+ reasoning = reasoning.ifEmpty { null },
623+ tools = toolParts.values.toList(),
624+ time = time,
625+ status = status,
626+ streaming = streaming,
627+ )
628+ }
629+}
630+
631+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632+ val info = env.info
633+ val msg = MsgState(info.id, info.role, info.time.created)
634+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635+ for (raw in env.parts) {
636+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637+ when (val part = Protocol.parsePart(obj)) {
638+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641+ null -> Unit
642+ }
643+ }
644+ return msg
645+}
646+
647+private fun pickText(text: String, delta: String?, prior: String?): String =
648+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649+
650+private fun normalizeUrl(url: String): String {
651+ val trimmed = url.trim().trimEnd('/')
652+ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653+ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654+ return when {
655+ trimmed.isEmpty() -> ""
656+ "://" in trimmed -> trimmed
657+ else -> "http://$trimmed"
658+ }
659+}
660+
661+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662+
663+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664+@kotlinx.serialization.Serializable
665+private data class PromptBody(val parts: List<TextInput>)
666+
667+@kotlinx.serialization.Serializable
668+private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktnew file+148
@@ -0,0 +1,148 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.Json
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+import kotlinx.serialization.json.booleanOrNull
10+import kotlinx.serialization.json.contentOrNull
11+import kotlinx.serialization.json.decodeFromJsonElement
12+import kotlinx.serialization.json.jsonObject
13+
14+/**
15+ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16+ *
17+ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18+ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19+ * catch-all so new server versions never break the stream parser.
20+ */
21+object Protocol {
22+ val json = Json {
23+ ignoreUnknownKeys = true
24+ isLenient = true
25+ coerceInputValues = true
26+ }
27+
28+ // ─── Sessions ────────────────────────────────────────────────────────────
29+
30+ @Serializable
31+ data class WireSession(val id: String, val title: String = "")
32+
33+ fun parseSessions(body: String): List<WireSession> =
34+ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35+
36+ fun parseSession(body: String): WireSession? =
37+ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38+
39+ // ─── Messages ────────────────────────────────────────────────────────────
40+
41+ @Serializable
42+ data class WireTime(val created: Long = 0, val completed: Long? = null)
43+
44+ @Serializable
45+ data class WireError(val name: String = "", val data: JsonElement? = null) {
46+ fun message(): String {
47+ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48+ return when {
49+ !msg.isNullOrEmpty() -> msg
50+ name.isNotEmpty() -> name
51+ else -> "Unknown error"
52+ }
53+ }
54+ }
55+
56+ @Serializable
57+ data class WireMessageInfo(
58+ val id: String,
59+ val role: String = "",
60+ @SerialName("sessionID") val sessionId: String = "",
61+ val time: WireTime = WireTime(),
62+ val error: WireError? = null,
63+ )
64+
65+ @Serializable
66+ data class WireMessageEnvelope(
67+ val info: WireMessageInfo,
68+ val parts: List<JsonElement> = emptyList(),
69+ )
70+
71+ fun parseMessages(body: String): List<WireMessageEnvelope> =
72+ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73+
74+ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75+
76+ sealed interface Part {
77+ val id: String
78+ val messageId: String
79+
80+ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81+ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82+ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83+ }
84+
85+ /** Parse a single part JSON object; returns null for kinds we don't render. */
86+ fun parsePart(obj: JsonObject): Part? {
87+ val id = obj.str("id")
88+ val messageId = obj.str("messageID")
89+ return when (obj.str("type")) {
90+ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91+ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92+ "tool" -> {
93+ val state = obj["state"] as? JsonObject
94+ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95+ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96+ Part.Tool(id, messageId, obj.str("tool"), status, title)
97+ }
98+ else -> null
99+ }
100+ }
101+
102+ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103+
104+ sealed interface Event {
105+ data class MessageUpdated(val info: WireMessageInfo) : Event
106+ data class PartUpdated(val part: Part, val delta: String?) : Event
107+ data class PartRemoved(val messageId: String, val partId: String) : Event
108+ data class MessageRemoved(val messageId: String) : Event
109+ data class SessionIdle(val sessionId: String) : Event
110+ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111+ data class SessionUpserted(val info: WireSession) : Event
112+ data class SessionDeleted(val sessionId: String?) : Event
113+ data object Other : Event
114+ }
115+
116+ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117+ fun parseEvent(raw: String): Event? {
118+ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119+ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120+ return when (root.str("type")) {
121+ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122+ "message.part.updated" -> {
123+ val partObj = props["part"] as? JsonObject ?: return Event.Other
124+ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125+ }
126+ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127+ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128+ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129+ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130+ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131+ "session.deleted" -> Event.SessionDeleted(
132+ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133+ )
134+ else -> Event.Other
135+ }
136+ }
137+
138+ private inline fun <reified T> decode(el: JsonElement?): T? =
139+ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140+
141+ // ─── Small JSON helpers ───────────────────────────────────────────────────
142+
143+ private fun JsonObject.str(key: String): String =
144+ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145+
146+ private fun JsonObject.bool(key: String): Boolean =
147+ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+63
@@ -0,0 +1,63 @@
1+package soy.iko.opencode.core
2+
3+import okhttp3.OkHttpClient
4+import okhttp3.Request
5+import okhttp3.Response
6+import okhttp3.sse.EventSource
7+import okhttp3.sse.EventSourceListener
8+import okhttp3.sse.EventSources
9+import java.util.concurrent.TimeUnit
10+
11+/**
12+ * Long-lived subscription to the opencode `/event` SSE stream.
13+ *
14+ * Crux only models request/response HTTP, so the streaming connection lives here
15+ * in the shell: each decoded event's `data` payload (a JSON object whose own
16+ * `type` field is the discriminant) is handed to [onEvent], which the core then
17+ * parses via [Protocol.parseEvent].
18+ */
19+class SseClient(
20+ private val url: String,
21+ private val auth: String?,
22+ private val onEvent: (String) -> Unit,
23+ private val onStateChange: (Boolean) -> Unit,
24+) {
25+ // A dedicated client with no read timeout — the stream is meant to stay open.
26+ private val client = OkHttpClient.Builder()
27+ .connectTimeout(15, TimeUnit.SECONDS)
28+ .readTimeout(0, TimeUnit.SECONDS)
29+ .retryOnConnectionFailure(true)
30+ .build()
31+
32+ private var source: EventSource? = null
33+
34+ fun connect() {
35+ val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ builder.header("User-Agent", "opencode-android/1.0")
37+ if (auth != null) builder.header("Authorization", auth)
38+ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39+ }
40+
41+ fun close() {
42+ source?.cancel()
43+ source = null
44+ }
45+
46+ private val listener = object : EventSourceListener() {
47+ override fun onOpen(eventSource: EventSource, response: Response) {
48+ onStateChange(true)
49+ }
50+
51+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52+ if (data.isNotBlank()) onEvent(data)
53+ }
54+
55+ override fun onClosed(eventSource: EventSource) {
56+ onStateChange(false)
57+ }
58+
59+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60+ onStateChange(false)
61+ }
62+ }
63+}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5+ * the two implementations stay behaviourally identical.
6+ */
7+
8+enum class Screen { Connect, Sessions, Chat }
9+
10+enum class MessageStatus { Sent, Pending, Failed }
11+
12+data class SessionView(
13+ val id: String,
14+ val title: String,
15+)
16+
17+data class ToolView(
18+ val name: String,
19+ val status: String,
20+ val title: String?,
21+)
22+
23+data class MessageView(
24+ val id: String,
25+ val role: String,
26+ val text: String,
27+ val reasoning: String?,
28+ val tools: List<ToolView>,
29+ val time: Long,
30+ val status: MessageStatus,
31+ val streaming: Boolean,
32+) {
33+ val isUser: Boolean get() = role == "user"
34+}
35+
36+data class UiState(
37+ val screen: Screen = Screen.Connect,
38+ val serverUrl: String = "",
39+ val username: String = "",
40+ val password: String = "",
41+ val authRequired: Boolean = false,
42+ val connected: Boolean = false,
43+ val loading: Boolean = false,
44+ val error: String? = null,
45+ val sessions: List<SessionView> = emptyList(),
46+ val currentSessionId: String? = null,
47+ val currentSessionTitle: String = "",
48+ val messages: List<MessageView> = emptyList(),
49+ val draft: String = "",
50+ val generating: Boolean = false,
51+ val sseConnected: Boolean = true,
52+)
53+
54+/**
55+ * Everything the shell can ask the core to do. Internal HTTP results are not
56+ * events here (unlike the Rust core, which routes them through `then_send`);
57+ * the Kotlin driver awaits them inline in coroutines instead.
58+ */
59+sealed interface Event {
60+ data class ServerUrlChanged(val url: String) : Event
61+ data class UsernameChanged(val value: String) : Event
62+ data class PasswordChanged(val value: String) : Event
63+ data object Connect : Event
64+
65+ data object LoadSessions : Event
66+ data class SelectSession(val id: String) : Event
67+ data object CreateSession : Event
68+ data class DeleteSession(val id: String) : Event
69+
70+ data class DraftChanged(val text: String) : Event
71+ data class SendMessage(val text: String) : Event
72+ data object CancelGeneration : Event
73+
74+ data object NavigateToSessions : Event
75+ data object NavigateToConnect : Event
76+ data object DismissError : Event
77+}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktdeleted−32
@@ -1,32 +0,0 @@
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.ktdeleted−109
@@ -1,109 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8− * Discriminated on `type` (the global discriminator). Unknown events decode to
9− * [UnknownEvent] so the stream never dies on a new event type.
10− *
11− * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12− * drift across opencode versions; the reducer only reads the fields it needs.
13− */
14−@Serializable
15−sealed interface BusEvent
16−
17−@Serializable
18−@SerialName("server.connected")
19−data object ServerConnected : BusEvent
20−
21−@Serializable
22−@SerialName("message.updated")
23−data class MessageUpdated(val properties: Props) : BusEvent {
24− @Serializable
25− data class Props(val info: MessageInfo)
26−}
27−
28−@Serializable
29−@SerialName("message.part.updated")
30−data class MessagePartUpdated(val properties: Props) : BusEvent {
31− @Serializable
32− data class Props(
33− val part: Part,
34− val sessionID: String? = null,
35− val messageID: String? = null,
36− )
37−}
38−
39−@Serializable
40−@SerialName("message.part.removed")
41−data class MessagePartRemoved(val properties: Props) : BusEvent {
42− @Serializable
43− data class Props(
44− val sessionID: String? = null,
45− val messageID: String? = null,
46− val partID: String? = null,
47− )
48−}
49−
50−@Serializable
51−@SerialName("message.removed")
52−data class MessageRemoved(val properties: Props) : BusEvent {
53− @Serializable
54− data class Props(
55− val sessionID: String? = null,
56− val messageID: String? = null,
57− )
58−}
59−
60−@Serializable
61−@SerialName("session.idle")
62−data class SessionIdle(val properties: Props) : BusEvent {
63− @Serializable
64− data class Props(val sessionID: String? = null)
65−}
66−
67−@Serializable
68−@SerialName("session.error")
69−data class SessionError(val properties: Props) : BusEvent {
70− @Serializable
71− data class Props(
72− val sessionID: String? = null,
73− val error: kotlinx.serialization.json.JsonElement? = null,
74− )
75−}
76−
77−@Serializable
78−@SerialName("session.updated")
79−data class SessionUpdated(val properties: Props) : BusEvent {
80− @Serializable
81− data class Props(val info: Session)
82−}
83−
84−@Serializable
85−@SerialName("session.deleted")
86−data class SessionDeleted(val properties: Props) : BusEvent {
87− @Serializable
88− data class Props(val info: Session? = null, val sessionID: String? = null)
89−}
90−
91−@Serializable
92−@SerialName("permission.updated")
93−data class PermissionUpdated(val properties: Permission) : BusEvent
94−
95−@Serializable
96−@SerialName("permission.replied")
97−data class PermissionReplied(val properties: Props) : BusEvent {
98− @Serializable
99− data class Props(
100− val sessionID: String? = null,
101− val permissionID: String? = null,
102− val response: String? = null,
103− )
104−}
105−
106−/** Fallback for any event type we don't model. */
107−@Serializable
108−@SerialName("__unknown")
109−data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktdeleted−28
@@ -1,28 +0,0 @@
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.ktdeleted−38
@@ -1,38 +0,0 @@
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.ktdeleted−65
@@ -1,65 +0,0 @@
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.ktdeleted−99
@@ -1,99 +0,0 @@
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.ktdeleted−49
@@ -1,49 +0,0 @@
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.ktdeleted−32
@@ -1,32 +0,0 @@
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.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/** Body for `POST /session/:id/message`. */
7−@Serializable
8−data class PromptRequest(
9− val parts: List<PromptPart>,
10− val model: ModelRef? = null,
11− val agent: String? = null,
12−)
13−
14−/**
15− * An input part for a prompt — a discriminated union on `type` (the global class
16− * discriminator). We compose text and file (image/document attachment) parts; the server
17− * models more (agent, subtask) that this client doesn't send.
18− */
19−@Serializable
20−sealed interface PromptPart
21−
22−@Serializable
23−@SerialName("text")
24−data class TextPromptPart(val text: String) : PromptPart
25−
26−/**
27− * A file/image attachment. [url] must be a data URL carrying the base64 payload —
28− * `data:<mime>;base64,<bytes>` — which is how the server embeds media for the model to
29− * see (see opencode's message-v2.ts). [mime] must match the payload; the server forwards
30− * it as the model's mediaType.
31− */
32−@Serializable
33−@SerialName("file")
34−data class FilePromptPart(
35− val mime: String,
36− val url: String,
37− val filename: String? = null,
38−) : PromptPart
39−
40−/** Body for `POST /session/:id/command`. */
41−@Serializable
42−data class CommandRequest(
43− val command: String,
44− val arguments: String = "",
45− val agent: String? = null,
46−)
47−
48−/** Body for `POST /session/:id/revert`. */
49−@Serializable
50−data class RevertRequest(
51− val messageID: String,
52− val partID: String? = null,
53−)
54−
55−/** Body for `POST /session/:id/summarize`. */
56−@Serializable
57−data class SummarizeRequest(
58− val providerID: String,
59− val modelID: String,
60−)
61−
62−/** Body for `POST /session/:id/shell`. */
63−@Serializable
64−data class ShellRequest(
65− val agent: String,
66− val command: String,
67− val model: ModelRef? = null,
68−)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktdeleted−77
@@ -1,77 +0,0 @@
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/Search.ktdeleted−90
@@ -1,90 +0,0 @@
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− * One ripgrep match from `GET /find?pattern=` (content search). [path] and [lines] are
9− * wrapped `{ text }` objects on the wire; [submatches] carry the column offsets of each
10− * hit within the line so the UI can highlight them.
11− */
12−@Immutable
13−@Serializable
14−data class FindMatch(
15− val path: TextField = TextField(),
16− val lines: TextField = TextField(),
17− @SerialName("line_number") val lineNumber: Int = 0,
18− @SerialName("absolute_offset") val absoluteOffset: Long = 0,
19− val submatches: List<SubMatch> = emptyList(),
20−) {
21− @Immutable
22− @Serializable
23− data class TextField(val text: String = "")
24−
25− @Immutable
26− @Serializable
27− data class SubMatch(
28− val start: Int = 0,
29− val end: Int = 0,
30− )
31−
32− /** The matched file's path (relative to the search root). */
33− val filePath: String get() = path.text
34−
35− /** The matching line's text, trimmed of a trailing newline for display. */
36− val lineText: String get() = lines.text.trimEnd('\n', '\r')
37−}
38−
39−/**
40− * One workspace symbol from `GET /find/symbol?query=`. [kind] is the LSP `SymbolKind`
41− * integer; [location.uri] is a file URI and [location.range] the symbol's span.
42− */
43−@Immutable
44−@Serializable
45−data class SymbolResult(
46− val name: String = "",
47− val kind: Int = 0,
48− val location: Location = Location(),
49−) {
50− @Immutable
51− @Serializable
52− data class Location(
53− val uri: String = "",
54− val range: Range = Range(),
55− )
56−
57− @Immutable
58− @Serializable
59− data class Range(
60− val start: Pos = Pos(),
61− val end: Pos = Pos(),
62− )
63−
64− @Immutable
65− @Serializable
66− data class Pos(
67− val line: Int = 0,
68− val character: Int = 0,
69− )
70−
71− /** The file path portion of [Location.uri], stripping a `file://` scheme when present. */
72− val filePath: String
73− get() = location.uri.removePrefix("file://").ifBlank { location.uri }
74−
75− /** 1-based line for display; the wire range is 0-based. */
76− val displayLine: Int get() = location.range.start.line + 1
77−}
78−
79−// LSP SymbolKind → label. A map lookup (rather than a large `when`) keeps the accessor
80−// trivial. Indices follow the LSP SymbolKind enum.
81−private val symbolKindLabels = mapOf(
82− 1 to "file", 2 to "module", 3 to "namespace", 4 to "package", 5 to "class",
83− 6 to "method", 7 to "property", 8 to "field", 9 to "constructor", 10 to "enum",
84− 11 to "interface", 12 to "function", 13 to "variable", 14 to "constant", 15 to "string",
85− 16 to "number", 17 to "boolean", 18 to "array", 19 to "object", 20 to "key",
86− 22 to "enum member", 23 to "struct", 26 to "type",
87−)
88−
89−/** Human-readable label for an LSP [SymbolResult.kind]. */
90−fun symbolKindLabel(kind: Int): String = symbolKindLabels[kind] ?: "symbol"
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktdeleted−27
@@ -1,27 +0,0 @@
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− // When true, the client upgrades a cleartext http:// URL to https:// so credentials and
18− // traffic are never sent over the wire in the clear for this server.
19− val requireHttps: Boolean = false,
20− // Optional OkHttp certificate pin(s) ("sha256/<base64>", whitespace/comma-separated for
21− // multiple) applied to this server's TLS connection for defense against a rogue CA.
22− val certPin: String? = null,
23−) {
24− val hasAuth: Boolean get() = !username.isNullOrBlank()
25−
26− val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
27−}
app/src/main/java/soy/iko/opencode/data/model/Session.ktdeleted−65
@@ -1,65 +0,0 @@
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− // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
21− val share: Share? = null,
22− // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
23− val revert: Revert? = null,
24−) {
25− val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
26−
27− /** A compact form of [directory] for list/subtitle display: the last path segment,
28− * or the full path when it has no separators. Null/blank when no directory is set. */
29− val displayDirectory: String?
30− get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
31−
32− /** True while the session has an active public share link. */
33− val isShared: Boolean get() = !share?.url.isNullOrBlank()
34−
35− /** True while a revert checkpoint is active (messages after it are hidden server-side). */
36− val isReverted: Boolean get() = revert != null
37−
38− /** Public share link for the session, from `POST /session/:id/share`. */
39− @Immutable
40− @Serializable
41− data class Share(val url: String = "")
42−
43− /** The active revert checkpoint, from `POST /session/:id/revert`. */
44− @Immutable
45− @Serializable
46− data class Revert(
47− val messageID: String = "",
48− val partID: String? = null,
49− val snapshot: String? = null,
50− val diff: String? = null,
51− )
52−}
53−
54−/** Body for `POST /session`. */
55−@Serializable
56−data class CreateSessionRequest(
57− val parentID: String? = null,
58− val title: String? = null,
59−)
60−
61−/** Body for `PATCH /session/:id`. */
62−@Serializable
63−data class UpdateSessionRequest(
64− val title: String? = null,
65−)
app/src/main/java/soy/iko/opencode/data/model/Todo.ktdeleted−84
@@ -1,84 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.builtins.ListSerializer
6−import kotlinx.serialization.json.Json
7−import kotlinx.serialization.json.JsonElement
8−
9−/** The tool name opencode uses to write the agent's task plan. */
10−const val TODO_WRITE_TOOL = "todowrite"
11−
12−/**
13− * A single entry in an agent's task plan, as written by opencode's `todowrite` tool.
14− * Every field is defaulted and decoding ignores unknown keys (see [parseTodos]) so a
15− * change to the tool's payload shape degrades to a best-effort render instead of
16− * dropping the whole plan.
17− */
18−@Immutable
19−@Serializable
20−data class TodoItem(
21− val id: String = "",
22− val content: String = "",
23− val status: String = "pending",
24− val priority: String? = null,
25−)
26−
27−/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28−enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29−
30−fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
31− "pending", "todo", "" -> TodoStatus.PENDING
32− "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
33− "completed", "complete", "done" -> TodoStatus.COMPLETED
34− "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
35− else -> TodoStatus.UNKNOWN
36−}
37−
38−@Serializable
39−private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
40−
41−// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
42−// coerced to defaults) without pulling the network layer into data/model.
43−private val todoJson = Json {
44− ignoreUnknownKeys = true
45− isLenient = true
46− coerceInputValues = true
47− explicitNulls = false
48−}
49−
50−/**
51− * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
52− * full list on every write, so a single call's input is a complete plan snapshot.
53− * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
54− * an empty list on anything unparseable.
55− */
56−fun parseTodos(input: JsonElement?): List<TodoItem> {
57− if (input == null) return emptyList()
58− return runCatching {
59− todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
60− }.recoverCatching {
61− todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
62− }.getOrDefault(emptyList())
63−}
64−
65−/**
66− * The current task plan for a conversation: the todos of the most recent `todowrite`
67− * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
68− * on every write, so a single call is a complete snapshot; scanning newest-first and
69− * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
70− * plan that's still valid.
71− */
72−fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
73− for (mi in messages.indices.reversed()) {
74− val parts = messages[mi].parts
75− for (pi in parts.indices.reversed()) {
76− val part = parts[pi]
77− if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
78− val todos = parseTodos(part.state.inputElement())
79− if (todos.isNotEmpty()) return todos
80− }
81− }
82− }
83− return emptyList()
84−}
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktdeleted−62
@@ -1,62 +0,0 @@
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
54−
55−/** The raw input arguments carried by a tool call, present on the running/completed/error
56− * states (pending/unknown carry none). */
57−fun ToolState.inputElement(): JsonElement? = when (this) {
58− is ToolRunning -> input
59− is ToolCompleted -> input
60− is ToolError -> input
61− else -> null
62−}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktdeleted−259
@@ -1,259 +0,0 @@
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.ktdeleted−146
@@ -1,146 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import io.ktor.client.HttpClient
5−import io.ktor.client.engine.okhttp.OkHttp
6−import io.ktor.client.plugins.HttpTimeout
7−import io.ktor.client.plugins.auth.Auth
8−import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9−import io.ktor.client.plugins.auth.providers.basic
10−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11−import io.ktor.client.plugins.defaultRequest
12−import io.ktor.client.plugins.sse.SSE
13−import io.ktor.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 okhttp3.CertificatePinner
18−import java.util.concurrent.TimeUnit
19−
20−/**
21− * Builds a Ktor client bound to a single [ServerProfile]. One client per active
22− * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
23− * SSE plugin used for `GET /event`.
24− */
25−object HttpClientFactory {
26−
27− fun create(profile: ServerProfile): HttpClient {
28− // Resolve per-profile TLS options up front so they're available inside the engine
29− // config: optionally upgrade a cleartext http:// URL to https:// ("Require HTTPS"),
30− // and pin the server certificate(s) for HTTPS connections.
31− val normalizedUrl = effectiveBaseUrl(profile)
32− val isHttps = normalizedUrl.lowercase().startsWith("https://")
33− val pinHost = runCatching { java.net.URI(normalizedUrl).host }.getOrNull()
34− val pins = parsePins(profile.certPin)
35−
36− return HttpClient(OkHttp) {
37− expectSuccess = true
38−
39− engine {
40− config {
41− // A normal read timeout for REST calls. The SSE stream overrides this
42− // to infinite inside the sse {} request block (see EventStreamClient),
43− // so the long-lived event connection is never killed mid-stream.
44− readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
45− connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
46− retryOnConnectionFailure(true)
47− pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
48− // Pin the server certificate(s) when configured and connecting over TLS.
49− // A malformed pin throws here (surfaced as a connection failure), which is
50− // the right fail-closed behavior for a security control the user opted into.
51− if (pins.isNotEmpty() && pinHost != null && isHttps) {
52− certificatePinner(
53− CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
54− )
55− }
56− }
57− }
58−
59− install(ContentNegotiation) {
60− json(OpencodeJson)
61− }
62−
63− install(SSE)
64−
65− // Request-level timeout for REST calls. The SSE stream overrides this to
66− // infinite inside the sse {} request block (see EventStreamClient) so the
67− // long-lived /event connection isn't killed after 60s. The engine's connect
68− // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
69− install(HttpTimeout) {
70− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
71− }
72−
73− if (profile.hasAuth) {
74− if (isHttps) {
75− install(Auth) {
76− basic {
77− credentials {
78− BasicAuthCredentials(
79− username = profile.username.orEmpty(),
80− password = profile.password.orEmpty(),
81− )
82− }
83− // Send eagerly so opencode doesn't need a 401 challenge round-trip.
84− // The Auth plugin is only installed for HTTPS profiles — over HTTP,
85− // even reactive (401-challenge) credential sending would leak
86− // passwords in cleartext, so we don't install it at all.
87− sendWithoutRequest { true }
88− }
89− }
90− } else {
91− // For HTTP profiles with auth, send credentials proactively in the
92− // Authorization header via defaultRequest (below) so the user's intent
93− // to connect over cleartext is honored, but we never install the Auth
94− // plugin's reactive challenge-response which would silently re-send
95− // credentials on any 401 without checking the protocol.
96− }
97− }
98−
99− defaultRequest {
100− url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
101− // For HTTP profiles with auth, attach the Basic auth header proactively on
102− // every request (the Auth plugin is skipped for non-HTTPS). The user chose
103− // cleartext explicitly, so we honor that — but without the reactive challenge
104− // logic that could re-send credentials silently.
105− if (profile.hasAuth && !isHttps) {
106− val credentials = java.util.Base64.getEncoder().encodeToString(
107− "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
108− )
109− header("Authorization", "Basic $credentials")
110− }
111− }
112− }
113− }
114−
115− /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
116− * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer. */
117− private fun effectiveBaseUrl(profile: ServerProfile): String {
118− val normalized = normalizeBaseUrl(profile.baseUrl)
119− return if (profile.requireHttps && normalized.lowercase().startsWith("http://")) {
120− "https://" + normalized.substring("http://".length)
121− } else {
122− normalized
123− }
124− }
125−
126− /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
127− * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
128− private fun parsePins(raw: String?): List<String> =
129− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
130−
131− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
132− * Defaults to [https] when no scheme is given so credentials aren't accidentally
133− * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
134− fun normalizeBaseUrl(raw: String): String {
135− val trimmed = raw.trim()
136− // Compare the scheme prefix case-insensitively (a lowercased copy) while
137− // preserving the original casing of the rest of the URL in the output.
138− val lowerTrimmed = trimmed.lowercase()
139− val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
140− trimmed
141− } else {
142− "https://$trimmed"
143− }
144− return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
145− }
146−}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktdeleted−181
@@ -1,181 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−/**
4− * Centralized tuning parameters for the network and presentation layers. Keeping these
5− * in one place makes them easy to find and adjust without hunting through source files,
6− * and makes the intent of each value explicit rather than a bare magic number.
7− */
8−object NetworkConfig {
9−
10− // --- OkHttp engine timeouts (HttpClientFactory) ---
11−
12− /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13− const val connectTimeoutSeconds = 30L
14− /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15− const val readTimeoutSeconds = 60L
16− /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17− const val pingIntervalSeconds = 20L
18− /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19− const val restRequestTimeoutMs = 60_000L
20−
21− // --- REST retry (OpencodeApiClient.withRetry) ---
22−
23− /** Maximum number of attempts for a retriable REST call. */
24− const val retryMaxAttempts = 3
25− /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26− const val retryInitialDelayMs = 500L
27− /** 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− // --- Global search (GlobalSearchViewModel) ---
66−
67− /** Max sessions whose message history is scanned in one global-search pass. Global
68− * search downloads each session's messages, so this bounds the work; if there are more
69− * sessions, the result set notes that the search was truncated. */
70− const val maxSearchSessions = 50
71− /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
72− const val searchDebounceMs = 350L
73− /** Minimum query length before a global search runs (shorter queries match too much). */
74− const val minSearchQueryLength = 2
75− /** Characters of context to show around a global-search match. */
76− const val searchSnippetLength = 160
77−
78− // --- Message cache (MessageCacheStore) ---
79−
80− /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
81− * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
82− * instant/offline first paint; the network corrects it on the next open. */
83− const val messageCacheWriteThrottleMs = 2_000L
84−
85− /** Grace period before a deferred session delete actually fires, during which an
86− * Undo snackbar lets the user cancel it. */
87− const val undoDeleteDelayMs = 5_000L
88−
89− /** Grace period before a deferred server-profile delete actually fires, during which
90− * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
91− const val undoServerDeleteDelayMs = 5_000L
92−
93− /** Grace period before a deferred crash-report delete actually fires, during which
94− * an Undo snackbar lets the user cancel it. */
95− const val undoReportDeleteDelayMs = 5_000L
96−
97− // --- In-memory message store (SessionRepository.MessageStore) ---
98−
99− /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
100− const val maxInMemoryMessages = 500
101−
102− // --- File browser (FileBrowserViewModel) ---
103−
104− /** Debounce delay before firing a file-search request after the user stops typing. */
105− const val fileSearchDebounceMs = 250L
106−
107− // --- Attachments (chat composer) ---
108−
109− /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
110− * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
111− * finitely to avoid OOM / oversized requests. */
112− const val maxAttachmentBytes = 10L * 1024 * 1024
113−
114− /** Max number of attachments staged for one prompt, to bound memory and request size. */
115− const val maxAttachments = 8
116−
117− // --- StateFlow sharing (WhileSubscribed) ---
118−
119− /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
120− const val stateFlowSubscriptionTimeoutMs = 5_000L
121−
122− // --- Draft persistence (ChatViewModel) ---
123−
124− /** Debounce delay before persisting a draft to disk after the user stops typing. */
125− const val draftDebounceMs = 500L
126−
127− /** Maximum characters allowed in the chat input field. A generous cap that prevents
128− * a huge paste from stalling the UI (the field would otherwise buffer and lay out
129− * an unbounded string) while leaving plenty of room for long prompts. */
130− const val maxDraftLengthChars = 32_000
131−
132− /** Show the "remaining characters" supportingText under the chat input once the
133− * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
134− * visible before it silently kicks in but doesn't clutter a normal short prompt. */
135− const val draftCountdownThresholdFraction = 0.8f
136−
137− /** Maximum characters allowed in a session title (rename dialog). Prevents the
138− * server from rejecting an overly long title and keeps list rows readable. */
139− const val maxSessionTitleChars = 200
140−
141− // --- Profile store (ServerEditViewModel) ---
142−
143− /** Timeout for loading a profile from DataStore before giving up. */
144− const val profileLoadTimeoutMs = 5_000L
145−
146− // --- SSE stream (EventStreamClient, AppContainer) ---
147−
148− /** Delay before retrying the message-activity observer after a failure. */
149− const val observerRetryDelayMs = 5_000L
150− /** Max concurrent assistant runs to track for completion notifications. */
151− const val activeRunsLimit = 200
152−
153− // --- Markdown rendering (MarkdownText) ---
154−
155− /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
156− * library re-parses the full AST on every content change, so during a long streaming
157− * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
158− * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
159− const val streamingThrottleMs = 50L
160−
161− // --- Snackbar one-shot events (ViewModels) ---
162−
163− /** Buffer capacity for transient error SharedFlows that drive snackbars. */
164− const val snackbarEventBufferCapacity = 16
165−
166− /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
167− * after a tap, giving immediate feedback that the refresh was triggered even when
168− * the connection state is already Connected (so the ConnectionBanner doesn't change). */
169− const val refreshFeedbackMs = 1_200L
170−
171− // --- UI layout constants ---
172−
173− /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
174− const val twoPaneWidthThresholdDp = 840
175− /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
176− const val userBubbleWidthFraction = 0.85f
177− /** Left pane weight in the two-pane layout (session list). */
178− const val twoPaneLeftWeight = 0.38f
179− /** Right pane weight in the two-pane layout (chat detail). */
180− const val twoPaneRightWeight = 0.62f
181−}
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.ktdeleted−109
@@ -1,109 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import android.content.Context
4−import android.net.nsd.NsdManager
5−import android.net.nsd.NsdServiceInfo
6−import android.util.Log
7−import kotlinx.coroutines.channels.Channel
8−import kotlinx.coroutines.flow.Flow
9−import kotlinx.coroutines.channels.awaitClose
10−import kotlinx.coroutines.flow.callbackFlow
11−import kotlinx.coroutines.launch
12−import kotlinx.coroutines.suspendCancellableCoroutine
13−import kotlin.coroutines.resume
14−
15−/** A server discovered on the local network via mDNS/DNS-SD. */
16−data class DiscoveredServer(val name: String, val host: String, val port: Int) {
17− val baseUrl: String get() = "http://$host:$port"
18−}
19−
20−/**
21− * Discovers opencode servers advertised on the LAN. opencode's `--mdns` publishes a DNS-SD
22− * `_http._tcp` service named `opencode-<port>` (with a `path=/` TXT record), so we browse
23− * that service type and keep only names that look like opencode. Requires no extra
24− * permission — [NsdManager] manages the multicast lock internally.
25− */
26−open class NsdDiscovery(context: Context?) {
27−
28− protected constructor() : this(null)
29−
30− private val appContext = context?.applicationContext
31−
32− /**
33− * A cold [Flow] that starts a discovery when collected and stops it when the collector
34− * cancels. Emits the growing/shrinking set of discovered servers. Resolutions are
35− * serialized through a channel because [NsdManager.resolveService] can only process one
36− * at a time on older platforms (a concurrent resolve fails with ALREADY_ACTIVE).
37− */
38− open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
39− val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
40− if (nsd == null) {
41− trySend(emptyList())
42− close()
43− return@callbackFlow
44− }
45− // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
46− val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
47− val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
48−
49− val resolver = launch {
50− for (info in toResolve) {
51− val resolved = resolveService(nsd, info) ?: continue
52− val name = resolved.serviceName.orEmpty()
53− // Keep only opencode's own advertisements, not every _http._tcp service
54− // (printers, routers, other web servers) on the network.
55− if (!name.startsWith("opencode", ignoreCase = true)) continue
56− @Suppress("DEPRECATION")
57− val host = resolved.host?.hostAddress ?: continue
58− found[name] = DiscoveredServer(name = name, host = host, port = resolved.port)
59− trySend(found.values.sortedBy { it.name })
60− }
61− }
62−
63− val listener = object : NsdManager.DiscoveryListener {
64− override fun onDiscoveryStarted(serviceType: String?) {}
65− override fun onServiceFound(serviceInfo: NsdServiceInfo) {
66− toResolve.trySend(serviceInfo)
67− }
68− override fun onServiceLost(serviceInfo: NsdServiceInfo) {
69− val name = serviceInfo.serviceName ?: return
70− if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
71− }
72− override fun onDiscoveryStopped(serviceType: String?) {}
73− override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
74− Log.w(TAG, "mDNS discovery start failed: $errorCode")
75− close()
76− }
77− override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
78− }
79−
80− runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
81− .onFailure { close(it) }
82−
83− awaitClose {
84− resolver.cancel()
85− toResolve.close()
86− runCatching { nsd.stopServiceDiscovery(listener) }
87− }
88− }
89−
90− private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
91− suspendCancellableCoroutine { cont ->
92− val listener = object : NsdManager.ResolveListener {
93− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
94− if (cont.isActive) cont.resume(null)
95− }
96− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
97− if (cont.isActive) cont.resume(serviceInfo)
98− }
99− }
100− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
101− .onFailure { if (cont.isActive) cont.resume(null) }
102− }
103−
104− private companion object {
105− const val TAG = "NsdDiscovery"
106− // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
107− const val SERVICE_TYPE = "_http._tcp."
108− }
109−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktdeleted−440
@@ -1,440 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.Agent
4−import soy.iko.opencode.data.model.Command
5−import soy.iko.opencode.data.model.CommandRequest
6−import soy.iko.opencode.data.model.CreateSessionRequest
7−import soy.iko.opencode.data.model.FileContent
8−import soy.iko.opencode.data.model.FileNode
9−import soy.iko.opencode.data.model.FileStatusEntry
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.model.ModelRef
12−import soy.iko.opencode.data.model.PathInfo
13−import soy.iko.opencode.data.model.FilePromptPart
14−import soy.iko.opencode.data.model.FindMatch
15−import soy.iko.opencode.data.model.PermissionReplyBody
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.Project
18−import soy.iko.opencode.data.model.PromptPart
19−import soy.iko.opencode.data.model.PromptRequest
20−import soy.iko.opencode.data.model.ProvidersResponse
21−import soy.iko.opencode.data.model.RevertRequest
22−import soy.iko.opencode.data.model.Session
23−import soy.iko.opencode.data.model.ShellRequest
24−import soy.iko.opencode.data.model.SummarizeRequest
25−import soy.iko.opencode.data.model.SymbolResult
26−import soy.iko.opencode.data.model.TextPromptPart
27−import soy.iko.opencode.data.model.UpdateSessionRequest
28−import io.ktor.client.HttpClient
29−import io.ktor.client.call.body
30−import io.ktor.client.plugins.ClientRequestException
31−import io.ktor.client.plugins.ResponseException
32−import io.ktor.client.plugins.ServerResponseException
33−import io.ktor.client.request.delete
34−import io.ktor.client.request.get
35−import io.ktor.client.request.header
36−import io.ktor.client.request.parameter
37−import io.ktor.client.request.patch
38−import io.ktor.client.request.post
39−import io.ktor.client.request.setBody
40−import io.ktor.http.ContentType
41−import io.ktor.http.contentType
42−import kotlinx.coroutines.delay
43−import kotlinx.coroutines.sync.Mutex
44−import kotlinx.coroutines.sync.withLock
45−import kotlin.random.Random
46−import java.net.URLEncoder
47−
48−/**
49− * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
50− * SSE stream lives in [EventStreamClient]; everything else is here.
51− */
52−open class OpencodeApiClient private constructor(
53− private val client: HttpClient?,
54− @Suppress("unused") private val testMode: Boolean,
55−) {
56− constructor(client: HttpClient) : this(client, false)
57− protected constructor() : this(null, true)
58−
59− // --- Catalog cache (providers/agents/commands) ---
60− // These change rarely, but the ChatViewModel re-fetches them on every creation.
61− // A short-lived in-memory cache avoids redundant network calls when navigating
62− // between sessions on the same server.
63− private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
64− // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
65− // The lock is held during the fetch to prevent a thundering-herd stampede when
66− // multiple coroutines miss the cache simultaneously.
67− private val providersMutex = Mutex()
68− private val agentsMutex = Mutex()
69− private val commandsMutex = Mutex()
70− private var cachedProviders: CachedEntry<ProvidersResponse>? = null
71− private var cachedAgents: CachedEntry<List<Agent>>? = null
72− private var cachedCommands: CachedEntry<List<Command>>? = null
73−
74− /** Lightweight connectivity check. Throws on non-2xx / network failure. */
75− open suspend fun ping() {
76− withRetry { client!!.get("global/health").body<String>() }
77− }
78−
79− open suspend fun listSessions(): List<Session> = withRetry {
80− client!!.get("session").body()
81− }
82−
83− /**
84− * Create a session, optionally in a specific worktree [directory]. The directory is a
85− * query parameter (not a body field): the server records it on the session and resolves
86− * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
87− * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
88− * its own launch cwd, preserving the pre-directory behavior.
89− */
90− open suspend fun createSession(title: String? = null, directory: String? = null): Session {
91− // Generate the idempotency key before entering withRetry so every retry attempt
92− // shares it. Without this, a POST that reached the server but whose response was
93− // lost (reset/timeout) would be retried and create a second orphan session.
94− val idempotencyKey = java.util.UUID.randomUUID().toString()
95− val dir = directory?.takeIf { it.isNotBlank() }
96− return withRetry {
97− client!!.post("session") {
98− contentType(ContentType.Application.Json)
99− header("Idempotency-Key", idempotencyKey)
100− if (dir != null) parameter("directory", dir)
101− setBody(CreateSessionRequest(title = title))
102− }.body()
103− }
104− }
105−
106− /** List the projects (worktree roots) the server knows about, for the directory picker. */
107− open suspend fun listProjects(): List<Project> = withRetry {
108− client!!.get("project").body()
109− }
110−
111− /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
112− * session with no directory override runs in. */
113− open suspend fun currentPath(): PathInfo = withRetry {
114− client!!.get("path").body()
115− }
116−
117− open suspend fun updateSession(id: String, title: String): Session = withRetry {
118− client!!.patch("session/${encode(id)}") {
119− contentType(ContentType.Application.Json)
120− setBody(UpdateSessionRequest(title = title))
121− }.body()
122− }
123−
124− open suspend fun deleteSession(id: String) {
125− try {
126− withRetry { client!!.delete("session/${encode(id)}").body<String>() }
127− } catch (t: ClientRequestException) {
128− // A DELETE that succeeded but whose response was lost gets retried; the retry
129− // hits an already-gone session and returns 404. Deleting a non-existent session
130− // is effectively success, so swallow 404 rather than surfacing a spurious failure.
131− if (t.response.status.value != 404) throw t
132− }
133− }
134−
135− open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
136− client!!.get("session/${encode(sessionId)}/message").body()
137− }
138−
139− open suspend fun sendPrompt(
140− sessionId: String,
141− text: String,
142− attachments: List<FilePromptPart> = emptyList(),
143− model: ModelRef? = null,
144− agent: String? = null,
145− ): MessageWithParts {
146− // Generate the idempotency key *before* entering withRetry so all retry
147− // attempts share the same key. If the key were generated inside withRetry,
148− // each attempt would get a fresh UUID and the server couldn't deduplicate a
149− // request that reached it but whose response was lost (e.g. timeout).
150− val idempotencyKey = java.util.UUID.randomUUID().toString()
151− // A text part is only included when there's actual text, so an image-only prompt
152− // (attachments, empty text) doesn't send an empty text part. Attachments follow
153− // the text so the model reads the instruction first.
154− val parts = buildList<PromptPart> {
155− if (text.isNotEmpty()) add(TextPromptPart(text))
156− addAll(attachments)
157− }
158− return withRetry {
159− client!!.post("session/${encode(sessionId)}/message") {
160− contentType(ContentType.Application.Json)
161− header("Idempotency-Key", idempotencyKey)
162− setBody(
163− PromptRequest(
164− parts = parts,
165− model = model,
166− agent = agent,
167− ),
168− )
169− }.body()
170− }
171− }
172−
173− open suspend fun abort(sessionId: String) {
174− withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
175− }
176−
177− /** Invoke a slash-command by name via `POST /session/:id/command`. */
178− open suspend fun runCommand(
179− sessionId: String,
180− command: String,
181− arguments: String = "",
182− agent: String? = null,
183− ): MessageWithParts {
184− // Generate the idempotency key before entering withRetry so all retry
185− // attempts share the same key, preventing duplicate command execution if
186− // the server processed the request but the response was lost.
187− val idempotencyKey = java.util.UUID.randomUUID().toString()
188− return withRetry {
189− client!!.post("session/${encode(sessionId)}/command") {
190− contentType(ContentType.Application.Json)
191− header("Idempotency-Key", idempotencyKey)
192− setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
193− }.body()
194− }
195− }
196−
197− /** Revert the session to just before [messageId] (optionally part [partId]); the
198− * server hides everything after the checkpoint. Returns the updated session. Naturally
199− * idempotent (reverting to the same point twice yields the same state). */
200− open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
201− client!!.post("session/${encode(sessionId)}/revert") {
202− contentType(ContentType.Application.Json)
203− setBody(RevertRequest(messageID = messageId, partID = partId))
204− }.body()
205− }
206−
207− /** Undo the active revert checkpoint, restoring the hidden messages. */
208− open suspend fun unrevert(sessionId: String): Session = withRetry {
209− client!!.post("session/${encode(sessionId)}/unrevert").body()
210− }
211−
212− /** Create a public share link for the session. Returns the session with [Session.share] set. */
213− open suspend fun shareSession(sessionId: String): Session = withRetry {
214− client!!.post("session/${encode(sessionId)}/share").body()
215− }
216−
217− /** Revoke the session's public share link. Returns the session with share cleared. */
218− open suspend fun unshareSession(sessionId: String): Session = withRetry {
219− client!!.delete("session/${encode(sessionId)}/share").body()
220− }
221−
222− /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
223− * summary streams back via SSE like any run; this just triggers it. */
224− open suspend fun summarize(sessionId: String, model: ModelRef) {
225− val idempotencyKey = java.util.UUID.randomUUID().toString()
226− withRetry {
227− client!!.post("session/${encode(sessionId)}/summarize") {
228− contentType(ContentType.Application.Json)
229− header("Idempotency-Key", idempotencyKey)
230− setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
231− }.body<String>()
232− }
233− }
234−
235− /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
236− * assigns the message id and uses its default model; the run streams back via SSE. */
237− open suspend fun initSession(sessionId: String) {
238− val idempotencyKey = java.util.UUID.randomUUID().toString()
239− withRetry {
240− client!!.post("session/${encode(sessionId)}/init") {
241− header("Idempotency-Key", idempotencyKey)
242− }.body<String>()
243− }
244− }
245−
246− /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
247− * [agent] scopes the run (the server requires it). */
248− open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
249− val idempotencyKey = java.util.UUID.randomUUID().toString()
250− withRetry {
251− client!!.post("session/${encode(sessionId)}/shell") {
252− contentType(ContentType.Application.Json)
253− header("Idempotency-Key", idempotencyKey)
254− setBody(ShellRequest(agent = agent, command = command, model = model))
255− }.body<String>()
256− }
257− }
258−
259− open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
260− val now = System.currentTimeMillis()
261− val cached = cachedProviders
262− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
263− return@withLock cached.value
264− }
265− val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
266− cachedProviders = CachedEntry(value, System.currentTimeMillis())
267− value
268− }
269−
270− /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
271− * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
272− open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
273− client!!.get("config").body()
274− }
275−
276− open suspend fun agents(): List<Agent> = agentsMutex.withLock {
277− val now = System.currentTimeMillis()
278− val cached = cachedAgents
279− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
280− return@withLock cached.value
281− }
282− val value = withRetry { client!!.get("agent").body<List<Agent>>() }
283− cachedAgents = CachedEntry(value, System.currentTimeMillis())
284− value
285− }
286−
287− open suspend fun commands(): List<Command> = commandsMutex.withLock {
288− val now = System.currentTimeMillis()
289− val cached = cachedCommands
290− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
291− return@withLock cached.value
292− }
293− val value = withRetry { client!!.get("command").body<List<Command>>() }
294− cachedCommands = CachedEntry(value, System.currentTimeMillis())
295− value
296− }
297−
298− /** Invalidate the catalog cache (e.g. on server switch). */
299− open suspend fun invalidateCache() {
300− providersMutex.withLock { cachedProviders = null }
301− agentsMutex.withLock { cachedAgents = null }
302− commandsMutex.withLock { cachedCommands = null }
303− }
304−
305− open suspend fun invalidateProvidersCache() {
306− providersMutex.withLock { cachedProviders = null }
307− }
308−
309− open suspend fun invalidateAgentsCache() {
310− agentsMutex.withLock { cachedAgents = null }
311− }
312−
313− open suspend fun invalidateCommandsCache() {
314− commandsMutex.withLock { cachedCommands = null }
315− }
316−
317− /** Respond to a permission request so a paused tool run can proceed. */
318− open suspend fun respondPermission(
319− sessionId: String,
320− permissionId: String,
321− response: PermissionResponse,
322− ): String = try {
323− withRetry {
324− client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
325− contentType(ContentType.Application.Json)
326− setBody(PermissionReplyBody(response.wire))
327− }.body<String>()
328− }
329− } catch (t: ClientRequestException) {
330− // A response that reached the server but whose reply was lost gets retried; the
331− // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
332− // the answer landed, so treat them as success instead of a spurious failure.
333− val code = t.response.status.value
334− if (code != 404 && code != 409) throw t
335− ""
336− }
337−
338− // --- Files ---
339−
340− open suspend fun findFiles(query: String): List<String> = withRetry {
341− client!!.get("find/file") { parameter("query", query) }.body()
342− }
343−
344− /** Ripgrep content search across the project (`GET /find?pattern=`). */
345− open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
346− client!!.get("find") { parameter("pattern", pattern) }.body()
347− }
348−
349− /** LSP workspace symbol search (`GET /find/symbol?query=`). */
350− open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
351− client!!.get("find/symbol") { parameter("query", query) }.body()
352− }
353−
354− open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
355− client!!.get("file") { parameter("path", path) }.body()
356− }
357−
358− open suspend fun readFile(path: String): FileContent = withRetry {
359− client!!.get("file/content") { parameter("path", path) }.body()
360− }
361−
362− open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
363− client!!.get("file/status").body()
364− }
365−}
366−
367−private suspend fun <T> withRetry(
368− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
369− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
370− block: suspend () -> T,
371−): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
372−
373−/**
374− * Retry a suspending block up to [maxAttempts] times with exponential backoff.
375− * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
376− * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
377− * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
378− * server errors are retried. Exposed as `internal` so the retry/skip rules are
379− * unit-testable without an HTTP server.
380− */
381−internal suspend fun <T> withRetryInternal(
382− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
383− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
384− jitterFactor: Double = NetworkConfig.retryJitterFactor,
385− block: suspend () -> T,
386−): T {
387− var lastError: Throwable? = null
388− for (attempt in 1..maxAttempts) {
389− try {
390− return block()
391− } catch (c: kotlinx.coroutines.CancellationException) {
392− throw c
393− } catch (t: ClientRequestException) {
394− // 429 (Too Many Requests) is transient — retry with backoff.
395− // All other 4xx are non-retryable.
396− if (t.response.status.value != 429) throw t
397− lastError = t
398− if (attempt < maxAttempts) {
399− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
400− }
401− } catch (t: ServerResponseException) {
402− // 5xx server errors are retryable with backoff.
403− lastError = t
404− if (attempt < maxAttempts) {
405− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
406− }
407− } catch (t: ResponseException) {
408− // 3xx (redirect limit exceeded) is non-retryable.
409− throw t
410− } catch (t: Exception) {
411− lastError = t
412− if (attempt < maxAttempts) {
413− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
414− }
415− }
416− }
417− throw lastError ?: IllegalStateException("withRetry failed without error")
418−}
419−
420−/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
421− * so the backoff formula is unit-testable without an HTTP server. */
422−internal fun jitteredBackoff(
423− attempt: Int,
424− initialDelayMs: Long,
425− jitterFactor: Double,
426−): Long {
427− // Guard against Int overflow from the shift and Long overflow from
428− // the multiplication at high attempt counts.
429− val shift = (attempt - 1).coerceAtMost(30)
430− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
431− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
432− // shorter, sometimes longer — spreads concurrent client retries and
433− // prevents thundering-herd reconnection storms.
434− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
435− return (baseDelay + jitter).coerceAtLeast(0)
436−}
437−
438−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
439−private fun encode(segment: String): String =
440− URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessageInfo
5−import soy.iko.opencode.data.model.Part
6−import soy.iko.opencode.data.model.ToolState
7−import soy.iko.opencode.data.model.UnknownEvent
8−import soy.iko.opencode.data.model.UnknownMessage
9−import soy.iko.opencode.data.model.UnknownPart
10−import soy.iko.opencode.data.model.ToolUnknown
11−import kotlinx.serialization.ExperimentalSerializationApi
12−import kotlinx.serialization.json.Json
13−import kotlinx.serialization.modules.SerializersModule
14−
15−/**
16− * Single shared Json for the whole app.
17− *
18− * The polymorphic default deserializers are the resilience layer: when the server
19− * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20− * Unknown variant instead of throwing — so a new opencode release can't break the
21− * event stream or message decoding.
22− */
23−@OptIn(ExperimentalSerializationApi::class)
24−val OpencodeJson: Json = Json {
25− ignoreUnknownKeys = true
26− isLenient = true
27− coerceInputValues = true
28− 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/AttachmentDraftStore.ktdeleted−80
@@ -1,80 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.withContext
7−import kotlinx.serialization.Serializable
8−import kotlinx.serialization.decodeFromString
9−import kotlinx.serialization.encodeToString
10−import kotlinx.serialization.json.Json
11−import soy.iko.opencode.util.runCatchingCancellable
12−import java.io.File
13−
14−/**
15− * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
16− * it after a process restart. Only the self-contained wire payload is kept — the base64
17− * `data:` [url] — because the original content Uri's read permission does NOT survive a
18− * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
19− * thumbnail for images).
20− */
21−@Serializable
22−data class PersistedAttachment(
23− val id: String,
24− val name: String,
25− val mime: String,
26− val url: String,
27− val filename: String? = null,
28−)
29−
30−/**
31− * Persists attachments staged for each session so an interrupted compose survives process
32− * death, mirroring how [DraftStore] persists the text draft.
33− *
34− * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
35− * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
36− * SharedPreferences loads its whole file into memory on first access.
37− */
38−open class AttachmentDraftStore private constructor(
39− private val appContext: Context?,
40− @Suppress("unused") private val testMode: Boolean,
41−) {
42− constructor(context: Context) : this(context.applicationContext, false)
43− protected constructor() : this(null, true)
44−
45− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
46−
47− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
48− * escape the directory. */
49− private val idRegex = Regex("[^A-Za-z0-9_-]")
50−
51− private val dir: File? by lazy {
52− appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
53− }
54−
55− private fun fileFor(sessionId: String): File? =
56− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
57−
58− /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
59− open suspend fun load(sessionId: String): List<PersistedAttachment> = withContext(Dispatchers.IO) {
60− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
61− runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
62− .getOrDefault(emptyList())
63− }
64−
65− /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
66− open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = withContext(Dispatchers.IO) {
67− val file = fileFor(sessionId) ?: return@withContext
68− runCatchingCancellable {
69− if (attachments.isEmpty()) file.delete()
70− else file.writeText(json.encodeToString(attachments))
71− }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
72− Unit
73− }
74−
75− /** Remove staged attachments for a session (call on session deletion). */
76− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
77− runCatchingCancellable { fileFor(sessionId)?.delete() }
78− Unit
79− }
80−}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.ktdeleted−115
@@ -1,115 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import kotlinx.coroutines.flow.first
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.decodeFromString
6−import kotlinx.serialization.encodeToString
7−import kotlinx.serialization.json.Json
8−import soy.iko.opencode.data.model.ServerProfile
9−
10−/** A server entry in a backup. [password] is present only when the user opted to include it. */
11−@Serializable
12−data class BackupServer(
13− val id: String,
14− val label: String,
15− val baseUrl: String,
16− val username: String? = null,
17− val password: String? = null,
18− val requireHttps: Boolean = false,
19− val certPin: String? = null,
20−)
21−
22−/** App preferences in a backup. */
23−@Serializable
24−data class BackupSettings(
25− val themeMode: String,
26− val dynamicColor: Boolean,
27− val sendOnEnter: Boolean,
28− val appLock: Boolean,
29− val chatTextScale: Float,
30− val codeWrap: Boolean,
31−)
32−
33−/** The full backup document. [version] guards against future format changes. */
34−@Serializable
35−data class BackupData(
36− val version: Int = 1,
37− val servers: List<BackupServer> = emptyList(),
38− val settings: BackupSettings? = null,
39− val pinned: List<String> = emptyList(),
40− val archived: List<String> = emptyList(),
41−)
42−
43−/**
44− * Exports/imports the app's servers, settings, and per-session flags as a single JSON
45− * document (via the Storage Access Framework in the UI). Passwords are only written when the
46− * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
47− * store.
48− */
49−class BackupManager(
50− private val profileStore: ProfileStore,
51− private val settingsStore: SettingsStore,
52− private val sessionPrefsStore: SessionPrefsStore,
53−) {
54− private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
55−
56− /** Build the backup JSON. Reads the current profiles/settings/flags. */
57− suspend fun export(includePasswords: Boolean): String {
58− val servers = profileStore.profiles.first().map {
59− BackupServer(
60− id = it.id,
61− label = it.label,
62− baseUrl = it.baseUrl,
63− username = it.username,
64− password = if (includePasswords) it.password else null,
65− requireHttps = it.requireHttps,
66− certPin = it.certPin,
67− )
68− }
69− val settings = BackupSettings(
70− themeMode = settingsStore.themeMode.first().name,
71− dynamicColor = settingsStore.dynamicColor.first(),
72− sendOnEnter = settingsStore.sendOnEnter.first(),
73− appLock = settingsStore.appLock.first(),
74− chatTextScale = settingsStore.chatTextScale.first(),
75− codeWrap = settingsStore.codeWrap.first(),
76− )
77− val data = BackupData(
78− servers = servers,
79− settings = settings,
80− pinned = sessionPrefsStore.pinned.first().toList(),
81− archived = sessionPrefsStore.archived.first().toList(),
82− )
83− return json.encodeToString(data)
84− }
85−
86− /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
87− * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
88− suspend fun import(text: String) {
89− val data = json.decodeFromString<BackupData>(text)
90− for (s in data.servers) {
91− profileStore.save(
92− ServerProfile(
93− id = s.id,
94− label = s.label,
95− baseUrl = s.baseUrl,
96− username = s.username?.takeIf { it.isNotBlank() },
97− password = s.password?.takeIf { it.isNotEmpty() },
98− lastUsed = 0,
99− requireHttps = s.requireHttps,
100− certPin = s.certPin?.takeIf { it.isNotBlank() },
101− ),
102− )
103− }
104− data.settings?.let { settings ->
105− runCatching { settingsStore.setThemeMode(ThemeMode.valueOf(settings.themeMode)) }
106− settingsStore.setDynamicColor(settings.dynamicColor)
107− settingsStore.setSendOnEnter(settings.sendOnEnter)
108− settingsStore.setAppLock(settings.appLock)
109− settingsStore.setChatTextScale(settings.chatTextScale)
110− settingsStore.setCodeWrap(settings.codeWrap)
111− }
112− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
113− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
114− }
115−}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktdeleted−216
@@ -1,216 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.pm.PackageManager
5−import android.os.Build
6−import android.util.Log
7−import androidx.core.content.pm.PackageInfoCompat
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.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.ktdeleted−266
@@ -1,266 +0,0 @@
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.ktdeleted−78
@@ -1,78 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.ClientRequestException
4−import io.ktor.client.plugins.HttpRequestTimeoutException
5−import io.ktor.client.plugins.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/MessageCacheStore.ktdeleted−66
@@ -1,66 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.withContext
7−import kotlinx.serialization.builtins.ListSerializer
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.network.OpencodeJson
10−import soy.iko.opencode.util.runCatchingCancellable
11−import java.io.File
12−
13−/**
14− * On-disk cache of a session's reduced message list, so a conversation shows instantly on
15− * open (and remains readable while offline / before the network responds) instead of a blank
16− * screen until `GET /session/:id/message` returns.
17− *
18− * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
19− * polymorphism used on the wire round-trips (unknown parts/messages are concrete
20− * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
21− * miss or a write failure just falls back to the network — so problems never surface to
22− * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
23− */
24−open class MessageCacheStore private constructor(
25− private val appContext: Context?,
26− @Suppress("unused") private val testMode: Boolean,
27−) {
28− constructor(context: Context) : this(context.applicationContext, false)
29− protected constructor() : this(null, true)
30−
31− private val serializer = ListSerializer(MessageWithParts.serializer())
32−
33− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
34− * escape the directory. */
35− private val idRegex = Regex("[^A-Za-z0-9_-]")
36−
37− private val dir: File? by lazy {
38− appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
39− }
40−
41− private fun fileFor(sessionId: String): File? =
42− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
43−
44− /** Load the cached messages for [sessionId], or empty if none / unreadable. */
45− open suspend fun load(sessionId: String): List<MessageWithParts> = withContext(Dispatchers.IO) {
46− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
47− runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
48− .getOrDefault(emptyList())
49− }
50−
51− /** Persist [messages] for [sessionId] (an empty list deletes the file). */
52− open suspend fun save(sessionId: String, messages: List<MessageWithParts>): Unit = withContext(Dispatchers.IO) {
53− val file = fileFor(sessionId) ?: return@withContext
54− runCatchingCancellable {
55− if (messages.isEmpty()) file.delete()
56− else file.writeText(OpencodeJson.encodeToString(serializer, messages))
57− }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
58− Unit
59− }
60−
61− /** Remove a session's cached messages (call on deletion). */
62− open suspend fun remove(sessionId: String): Unit = withContext(Dispatchers.IO) {
63− runCatchingCancellable { fileFor(sessionId)?.delete() }
64− Unit
65− }
66−}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktdeleted−111
@@ -1,111 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
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.sync.Mutex
10−import kotlinx.coroutines.sync.withLock
11−import kotlinx.coroutines.withContext
12−import kotlinx.serialization.Serializable
13−import kotlinx.serialization.decodeFromString
14−import kotlinx.serialization.encodeToString
15−import kotlinx.serialization.json.Json
16−import soy.iko.opencode.data.model.ModelRef
17−import soy.iko.opencode.util.runCatchingCancellable
18−import java.io.File
19−
20−/**
21− * One prompt composed while offline (or with no active connection), queued to send when
22− * connectivity returns. Tagged with the [profileId] it was composed for so a reconnect to a
23− * *different* server doesn't misfire it against a session id that only exists on the
24− * original server. [attachments] carry their self-contained base64 data URLs, so a queued
25− * image survives a process restart just like a staged attachment.
26− */
27−@Serializable
28−data class OutboxMessage(
29− val id: String,
30− val profileId: String,
31− val sessionId: String,
32− val text: String,
33− val attachments: List<PersistedAttachment> = emptyList(),
34− val model: ModelRef? = null,
35− val agent: String? = null,
36− val createdAt: Long,
37−)
38−
39−/**
40− * A persisted send-queue: prompts the user composed while disconnected, flushed on
41− * reconnect (see [soy.iko.opencode.di.AppContainer.observeOutbox]). Backed by one JSON file
42− * under filesDir (not SharedPreferences) because a queued attachment's base64 payload can be
43− * several megabytes. The in-memory [messages] StateFlow is the source of truth the UI and the
44− * flusher observe; disk is written through under a mutex on every mutation.
45− */
46−open class OutboxStore private constructor(
47− private val appContext: Context?,
48− @Suppress("unused") private val testMode: Boolean,
49−) {
50− constructor(context: Context) : this(context.applicationContext, false)
51− protected constructor() : this(null, true)
52−
53− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
54− private val ioMutex = Mutex()
55−
56− private val _messages = MutableStateFlow<List<OutboxMessage>>(emptyList())
57− /** All queued messages across sessions/servers, in enqueue order. */
58− open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59−
60− private val file: File? by lazy {
61− appContext?.let { File(it.filesDir, "outbox.json") }
62− }
63−
64− /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
65− open suspend fun load() {
66− val f = file?.takeIf { it.exists() } ?: return
67− val loaded = withContext(Dispatchers.IO) {
68− runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
69− .getOrDefault(emptyList())
70− }
71− // Don't clobber items enqueued while the load was in flight: merge by id, keeping
72− // the in-memory version on conflict (it's newer).
73− ioMutex.withLock {
74− if (_messages.value.isEmpty()) {
75− _messages.value = loaded.sortedBy { it.createdAt }
76− } else {
77− val known = _messages.value.mapTo(mutableSetOf()) { it.id }
78− _messages.value = (loaded.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
79− }
80− }
81− }
82−
83− open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
84− current.filterNot { it.id == message.id } + message
85− }
86−
87− open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
88−
89− /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
90− * session was deleted). */
91− open suspend fun removeForSession(sessionId: String) =
92− mutate { current -> current.filterNot { it.sessionId == sessionId } }
93−
94− private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
95− val snapshot = ioMutex.withLock {
96− val updated = transform(_messages.value).sortedBy { it.createdAt }
97− _messages.value = updated
98− updated
99− }
100− persist(snapshot)
101− }
102−
103− private suspend fun persist(list: List<OutboxMessage>) {
104− val f = file ?: return
105− withContext(Dispatchers.IO) {
106− runCatchingCancellable {
107− if (list.isEmpty()) f.delete() else f.writeText(json.encodeToString(list))
108− }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
109− }
110− }
111−}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktdeleted−251
@@ -1,251 +0,0 @@
1−// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2−// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3−// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4−// the expected deprecation warnings here so the build log stays clean until a future migration.
5−@file:Suppress("DEPRECATION")
6−
7−package soy.iko.opencode.data.repo
8−
9−import android.content.Context
10−import android.content.SharedPreferences
11−import android.util.Log
12−import androidx.datastore.preferences.core.edit
13−import androidx.datastore.preferences.core.stringPreferencesKey
14−import androidx.datastore.preferences.preferencesDataStore
15−import androidx.security.crypto.EncryptedSharedPreferences
16−import androidx.security.crypto.MasterKey
17−import soy.iko.opencode.data.model.ServerProfile
18−import soy.iko.opencode.data.network.OpencodeJson
19−import kotlinx.coroutines.Dispatchers
20−import kotlinx.coroutines.sync.Mutex
21−import kotlinx.coroutines.sync.withLock
22−import kotlinx.coroutines.flow.Flow
23−import kotlinx.coroutines.flow.flowOn
24−import kotlinx.coroutines.flow.map
25−import kotlinx.coroutines.withContext
26−import kotlinx.serialization.Serializable
27−import kotlinx.serialization.builtins.ListSerializer
28−
29−private val Context.dataStore by preferencesDataStore(name = "server_profiles")
30−
31−/**
32− * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
33− * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
34− * by profile id, and merged back in on read.
35− */
36−open class ProfileStore private constructor(
37− private val appContext: Context?,
38− @Suppress("unused") private val testMode: Boolean,
39−) {
40− constructor(context: Context) : this(context.applicationContext, false)
41− protected constructor() : this(null, true)
42−
43− private val profilesKey = stringPreferencesKey("profiles_json")
44−
45− /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
46− * DataStore emission. The mutex serializes concurrent callers so a second collector waits
47− * for the migration's writes to land (rather than skipping ahead and reading empty secure
48− * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
49− private val migrationMutex = Mutex()
50− @Volatile private var migrationDone = false
51−
52− private val securePrefs: SharedPreferences? by lazy {
53− val ctx = appContext ?: return@lazy null
54− runCatching {
55− val masterKey = MasterKey.Builder(ctx)
56− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
57− .build()
58− EncryptedSharedPreferences.create(
59− ctx,
60− "server_secrets",
61− masterKey,
62− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
63− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
64− )
65− }.getOrElse {
66− Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
67− null
68− }
69− }
70−
71− /** True when EncryptedSharedPreferences could not be initialized. */
72− open val securePrefsUnavailable: Boolean get() = securePrefs == null
73−
74− /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
75− * can still connect — but passwords won't survive an app reinstall. */
76− private val fallbackPrefs: SharedPreferences by lazy {
77− appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
78− }
79−
80− private fun prefsForPasswords(): SharedPreferences =
81− securePrefs ?: fallbackPrefs
82−
83− /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84− * then clear the fallback. Runs at most once per process — subsequent calls
85− * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
86− private suspend fun migrateFallbackPasswords() {
87− if (migrationDone) return
88− migrationMutex.withLock {
89− // Re-check inside the lock: a concurrent caller may have completed the migration
90− // while we were waiting. Serializing here (rather than a bare compareAndSet that
91− // marks done before doing the work) ensures a second collector doesn't read the
92− // secure store before the first caller's write has populated it.
93− if (migrationDone) return
94− val secure = securePrefs
95− if (secure == null) {
96− // No secure store to migrate into; nothing to do, and no point retrying.
97− migrationDone = true
98− return
99− }
100− withContext(Dispatchers.IO) {
101− val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
102− if (fallbackKeys.isNotEmpty()) {
103− // Write passwords to the secure store FIRST, then remove them from the
104− // plaintext fallback. SharedPreferences.apply() persists to disk
105− // asynchronously, so removing from fallback before the secure batch lands
106− // could lose the password if the process dies between the two writes.
107− // Writing secure first makes the worst case a harmless duplicate (both
108− // stores hold it; the migration is idempotent) rather than data loss.
109− secure.edit().apply {
110− for (key in fallbackKeys) {
111− val pw = fallbackPrefs.getString(key, null) ?: continue
112− putString(key, pw)
113− }
114− }.apply()
115− fallbackPrefs.edit().apply {
116− for (key in fallbackKeys) remove(key)
117− }.apply()
118− }
119− }
120− // Only mark done once the writes are in the secure store's in-memory cache (apply()
121− // updates it synchronously), so the next collector past the mutex reads real values.
122− migrationDone = true
123− }
124− }
125−
126− /** Stored shape on DataStore (everything except the secret password). */
127− @Serializable
128− private data class StoredProfile(
129− val id: String,
130− val label: String,
131− val baseUrl: String,
132− val username: String? = null,
133− val lastUsed: Long = 0,
134− // Non-secret TLS options; default to prior behavior for profiles saved before these
135− // fields existed (deserialization uses these defaults for missing keys).
136− val requireHttps: Boolean = false,
137− val certPin: String? = null,
138− )
139−
140− open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
141− // Migrate any orphaned plaintext passwords to secure prefs on first load.
142− migrateFallbackPasswords()
143− val json = prefs[profilesKey] ?: return@map emptyList()
144− val stored = runCatching {
145− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
146− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
147− .getOrDefault(emptyList())
148− .sortedByDescending { it.lastUsed }
149− // Batch all password lookups into a single IO dispatch instead of one
150− // withContext round-trip per profile (N dispatches → 1). DataStore already
151− // runs this map block on IO, but each passwordFor() call still pays the
152− // suspend + redispatch overhead.
153− val pwPrefs = prefsForPasswords()
154− stored.map { it.toProfile(pwPrefs) }
155− }
156− // The .map above runs in the *collector's* context, and every consumer collects on
157− // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
158− // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
159− ?.flowOn(Dispatchers.IO)
160− ?: kotlinx.coroutines.flow.flowOf(emptyList())
161−
162− private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
163− val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
164− return ServerProfile(
165− id = id,
166− label = label,
167− baseUrl = baseUrl,
168− username = username,
169− password = pw,
170− lastUsed = lastUsed,
171− requireHttps = requireHttps,
172− certPin = certPin,
173− )
174− }
175−
176− open suspend fun resolve(profile: ServerProfile): ServerProfile {
177− val pw = if (profile.hasAuth) {
178− withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
179− } else {
180− null
181− }
182− return profile.copy(password = pw)
183− }
184−
185− open suspend fun save(profile: ServerProfile) {
186− // Write the password first so that if the DataStore write fails, we're left
187− // with an orphaned password (harmless, cleaned up on next save/delete) rather
188− // than a profile with no password (which would break authentication).
189− withContext(Dispatchers.IO) {
190− prefsForPasswords().edit().apply {
191− val pw = profile.password
192− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
193− putString(passwordKey(profile.id), pw)
194− } else {
195− remove(passwordKey(profile.id))
196− }
197− }.apply()
198− }
199− appContext!!.dataStore.edit { prefs ->
200− val current = prefs[profilesKey]?.let {
201− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
202− .getOrDefault(emptyList())
203− } ?: emptyList()
204− // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
205− // couldn't read the existing value due to a DataStore timeout), preserve
206− // the existing nonzero lastUsed so saving an edit doesn't reset the
207− // profile's sort position in the server list.
208− val preservedLastUsed = if (profile.lastUsed == 0L) {
209− current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
210− } else {
211− profile.lastUsed
212− }
213− val stored = StoredProfile(
214− id = profile.id,
215− label = profile.label,
216− baseUrl = profile.baseUrl,
217− username = profile.username?.takeIf { it.isNotBlank() },
218− lastUsed = preservedLastUsed,
219− requireHttps = profile.requireHttps,
220− certPin = profile.certPin?.takeIf { it.isNotBlank() },
221− )
222− val updated = current.filterNot { it.id == profile.id } + stored
223− prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
224− }
225− }
226−
227− open suspend fun delete(id: String) {
228− // Delete the profile from DataStore first, then the password. If the process
229− // dies between the two, an orphaned password remains (harmless, cleaned up on
230− // next save/delete) rather than a profile with no password.
231− appContext!!.dataStore.edit { prefs ->
232− val current = prefs[profilesKey]?.let {
233− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
234− .getOrDefault(emptyList())
235− } ?: emptyList()
236− prefs[profilesKey] = OpencodeJson.encodeToString(
237− ListSerializer(StoredProfile.serializer()),
238− current.filterNot { it.id == id },
239− )
240− }
241− // Clean up the password in both secure and fallback prefs so no orphaned
242− // plaintext password lingers if secure prefs were temporarily unavailable.
243− withContext(Dispatchers.IO) {
244− val key = passwordKey(id)
245− securePrefs?.edit()?.remove(key)?.apply()
246− fallbackPrefs.edit().remove(key).apply()
247− }
248− }
249−
250− private fun passwordKey(id: String) = "pw_$id"
251−}
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.ktdeleted−43
@@ -1,43 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.Dispatchers
5−import kotlinx.coroutines.withContext
6−import kotlinx.serialization.Serializable
7−import kotlinx.serialization.decodeFromString
8−import kotlinx.serialization.encodeToString
9−import kotlinx.serialization.json.Json
10−import java.io.File
11−
12−/** A minimal session summary persisted for the home-screen widget and launcher shortcuts. */
13−@Serializable
14−data class RecentSession(val id: String, val title: String)
15−
16−/**
17− * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
18− * surfaces (the home-screen widget's [android.widget.RemoteViewsService] and the "Resume last"
19− * launcher shortcut) can read them without a live connection or in-memory state. Both the app
20− * and the widget's RemoteViews factory run in the same process, so a plain file is sufficient.
21− */
22−object RecentSessionsStore {
23−
24− private const val FILE = "recent_sessions.json"
25− const val MAX = 8
26−
27− private val json = Json { ignoreUnknownKeys = true }
28−
29− private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
30−
31− /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
32− fun read(context: Context): List<RecentSession> {
33− val f = file(context).takeIf { it.exists() } ?: return emptyList()
34− return runCatching { json.decodeFromString<List<RecentSession>>(f.readText()) }
35− .getOrDefault(emptyList())
36− }
37−
38− /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
39− suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
40− runCatching { file(context).writeText(json.encodeToString(sessions.take(MAX))) }
41− Unit
42− }
43−}
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.ktdeleted−64
@@ -1,64 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.edit
5−import androidx.datastore.preferences.core.stringSetPreferencesKey
6−import androidx.datastore.preferences.preferencesDataStore
7−import kotlinx.coroutines.flow.Flow
8−import kotlinx.coroutines.flow.flowOf
9−import kotlinx.coroutines.flow.map
10−
11−private val Context.sessionPrefsDataStore by preferencesDataStore(name = "session_prefs")
12−
13−/**
14− * Local, per-session organization flags the opencode server doesn't track: **pinned**
15− * (sorted to the top of the session list) and **archived** (hidden from the list unless the
16− * user opts to show them). Keyed by opencode session id.
17− *
18− * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
19− * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
20− * eager initializers) and null-guard the context so the test constructor never touches disk.
21− */
22−open class SessionPrefsStore private constructor(
23− private val appContext: Context?,
24− @Suppress("unused") private val testMode: Boolean,
25−) {
26− constructor(context: Context) : this(context.applicationContext, false)
27− protected constructor() : this(null, true)
28−
29− private val pinnedKey = stringSetPreferencesKey("pinned")
30− private val archivedKey = stringSetPreferencesKey("archived")
31−
32− open val pinned: Flow<Set<String>>
33− get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
34− ?: flowOf(emptySet())
35−
36− open val archived: Flow<Set<String>>
37− get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
38− ?: flowOf(emptySet())
39−
40− open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
41−
42− open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
43−
44− /** Drop a session from both sets — call on deletion so ids don't accumulate forever. */
45− open suspend fun forget(sessionId: String) {
46− val ctx = appContext ?: return
47− ctx.sessionPrefsDataStore.edit { prefs ->
48− prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
49− prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
50− }
51− }
52−
53− private suspend fun toggle(
54− key: androidx.datastore.preferences.core.Preferences.Key<Set<String>>,
55− sessionId: String,
56− on: Boolean,
57− ) {
58− val ctx = appContext ?: return
59− ctx.sessionPrefsDataStore.edit { prefs ->
60− val current = prefs[key] ?: emptySet()
61− prefs[key] = if (on) current + sessionId else current - sessionId
62− }
63− }
64−}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktdeleted−494
@@ -1,494 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.FilePromptPart
5−import soy.iko.opencode.data.model.MessageInfo
6−import soy.iko.opencode.data.model.MessagePartRemoved
7−import soy.iko.opencode.data.model.MessagePartUpdated
8−import soy.iko.opencode.data.model.MessageRemoved
9−import soy.iko.opencode.data.model.MessageUpdated
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.model.ModelRef
12−import soy.iko.opencode.data.model.Part
13−import soy.iko.opencode.data.model.SessionError
14−import soy.iko.opencode.data.model.SessionIdle
15−import soy.iko.opencode.data.model.UnknownMessage
16−import soy.iko.opencode.data.network.EventStreamClient
17−import soy.iko.opencode.data.network.NetworkConfig
18−import soy.iko.opencode.data.network.OpencodeApiClient
19−import soy.iko.opencode.util.runCatchingCancellable
20−import soy.iko.opencode.util.safeExceptionSummary
21−import android.util.Log
22−import kotlinx.coroutines.Dispatchers
23−import kotlinx.coroutines.channels.Channel
24−import kotlinx.coroutines.flow.Flow
25−import kotlinx.coroutines.flow.channelFlow
26−import kotlinx.coroutines.flow.consumeAsFlow
27−import kotlinx.coroutines.flow.conflate
28−import kotlinx.coroutines.flow.flowOn
29−import kotlinx.coroutines.launch
30−import kotlinx.coroutines.sync.Mutex
31−import kotlinx.coroutines.sync.withLock
32−
33−/**
34− * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
35− * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
36− * each keyed by message id with parts keyed by part id (idempotent upserts).
37− */
38−open class SessionRepository(
39− private val api: OpencodeApiClient,
40− private val eventStream: EventStreamClient,
41− // Optional on-disk cache so a conversation renders instantly on open and stays readable
42− // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
43− private val messageCache: MessageCacheStore? = null,
44−) {
45− open suspend fun listSessions() = api.listSessions()
46− open suspend fun createSession(title: String? = null, directory: String? = null) =
47− api.createSession(title, directory)
48− open suspend fun deleteSession(id: String) = api.deleteSession(id)
49− open suspend fun abort(sessionId: String) = api.abort(sessionId)
50−
51− open suspend fun sendPrompt(
52− sessionId: String,
53− text: String,
54− attachments: List<FilePromptPart> = emptyList(),
55− model: ModelRef?,
56− agent: String? = null,
57− ) = api.sendPrompt(sessionId, text, attachments, model, agent)
58−
59− open suspend fun runCommand(
60− sessionId: String,
61− command: String,
62− arguments: String = "",
63− agent: String? = null,
64− ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
65−
66− /**
67− * A live, ordered view of [sessionId]'s messages. Begins collecting the event
68− * stream before the initial REST load so no streamed part is missed, then reduces
69− * events into the in-memory state. The flow is conflated so a fast burst of
70− * streaming tokens (each publishing a fresh snapshot) never back-pressures the
71− * event reducer — a slow collector simply sees the most recent snapshot.
72− */
73− open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
74− val store = MessageStore()
75− val lock = Mutex()
76− // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
77− // single snapshot+send per demand cycle. Previously every changed event called
78− // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
79− // those copies were immediately discarded by the outer .conflate() during fast
80− // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
81− // drain loop means N events in a burst allocate ~1 snapshot, not N.
82− val dirty = Channel<Unit>(Channel.CONFLATED)
83−
84− // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
85− // collapses repeated marks into a single pending signal, so a tight burst of
86− // events doesn't even allocate N marks.
87− fun publish() { dirty.trySend(Unit) }
88−
89− // Subscribe to events first so we don't miss early deltas during the initial load.
90− launch {
91− eventStream.events.collect { event ->
92− if (lock.withLock { store.reduce(sessionId, event) }) publish()
93− }
94− }
95−
96− // Seed generation counter, shared between the initial load and the reconnect
97− // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
98− // initial load) clobbering a newer seed. Only mutated/read under [lock].
99− var seedGeneration = 0
100−
101− // Seed from the on-disk cache first so the conversation renders instantly — and stays
102− // readable offline — before the network responds. Guarded on generation 0 so a
103− // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
104− // (also generation 0) then overrides this with authoritative data when it succeeds.
105− messageCache?.let { cache ->
106− val cached = cache.load(sessionId)
107− if (cached.isNotEmpty()) {
108− val changed = lock.withLock { if (seedGeneration == 0) store.seed(cached) else false }
109− if (changed) publish()
110− }
111− }
112−
113− // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
114− // is performed *outside* the lock so events arriving via SSE during the fetch are
115− // not blocked. The seed itself is under the lock; a seed generation counter ensures
116− // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
117− launch {
118− // Re-seed from REST when the SSE stream reconnects after a drop. The
119− // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
120− // the first successful connection so a subsequent reconnect triggers the
121− // re-seed. Resetting it on disconnect would make the condition always false
122− // after the first cycle, turning the re-seed into dead code.
123− //
124− // A seed generation counter guards against a stale re-seed clobbering a
125− // newer one: if two reconnects fire in quick succession, the older fetch
126− // (which returns later) is discarded because its generation no longer matches.
127− var hasConnectedBefore = false
128− eventStream.state.collect { state ->
129− if (state == EventStreamClient.ConnectionState.Connected) {
130− if (hasConnectedBefore) {
131− // Pivot before the fetch: parts already in memory predate this
132− // snapshot and must yield to it, while parts that stream in during
133− // the fetch (added to streamedSincePivot via reduce) outrank it.
134− // Bump the generation under the same lock so the compare in the
135− // initial seed sees a consistent value.
136− val generation = lock.withLock {
137− store.beginReseed()
138− ++seedGeneration
139− }
140− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
141− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
142− .getOrDefault(emptyList())
143− var seedChanged = false
144− lock.withLock {
145− if (generation == seedGeneration) {
146− // Merge without pruning: SSE events arriving during the
147− // REST fetch may have added messages not yet indexed by
148− // the REST endpoint. Pruning would silently delete them.
149− // Stale messages from deletions during the disconnect
150− // gap are eventually removed via MessageRemoved events
151− // or on the next app restart.
152− seedChanged = store.seed(fresh, prune = false)
153− }
154− }
155− if (seedChanged) publish()
156− }
157− hasConnectedBefore = true
158− }
159− }
160− }
161−
162− val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
163− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
164− // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
165− // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
166− // returns after one has applied, seeding this older REST data would clobber the newer
167− // state — reverting a completed run to "running", regressing cost/token/part state —
168− // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
169− // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
170− val seeded = lock.withLock {
171− val list = initialResult.getOrNull()
172− if (seedGeneration == 0 && list != null) {
173− store.seed(list)
174− true
175− } else false
176− }
177− if (seeded) publish()
178−
179− // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
180− // taken under the lock so a reader never sees a half-reduced state; send() runs
181− // outside the lock so a slow downstream collector can't stall the drain. This
182− // loop also keeps the flow alive until the collector cancels; the launched jobs
183− // above are children of this scope and are torn down with it.
184− var lastCacheWrite = 0L
185− dirty.consumeAsFlow().collect {
186− val snapshot = lock.withLock { store.snapshot() }
187− send(snapshot)
188− // Persist the snapshot to the on-disk cache, throttled so a fast token stream
189− // doesn't hammer the disk — the cache only needs to be "recent enough" for an
190− // instant/offline first paint; the network corrects it on the next open.
191− if (messageCache != null && snapshot.isNotEmpty()) {
192− val now = System.currentTimeMillis()
193− if (now - lastCacheWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
194− lastCacheWrite = now
195− launch { messageCache.save(sessionId, snapshot) }
196− }
197− }
198− }
199− // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
200− // snapshot copy — on Dispatchers.Default rather than the collector's context. The
201− // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
202− // this every streamed token's reduce()/snapshot() would execute on the UI thread
203− // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
204− // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
205− }.flowOn(Dispatchers.Default).conflate()
206−
207− companion object {
208− /** Convenience: is this event a run-completion signal for [sessionId]?
209− * A null sessionID is NOT treated as a wildcard — an idle event with no
210− * session id must not reset the running state of every open chat. */
211− fun isIdle(event: BusEvent, sessionId: String): Boolean =
212− event is SessionIdle && event.properties.sessionID == sessionId
213−
214− fun isError(event: BusEvent, sessionId: String): Boolean =
215− event is SessionError && event.properties.sessionID == sessionId
216− }
217−}
218−
219−/**
220− * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
221− * Exposed as `internal` so the reducer logic can be unit-tested directly.
222− */
223−internal class MessageStore {
224− // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
225− // insertion-ordered.
226− private val messages = LinkedHashMap<String, Holder>()
227−
228− /**
229− * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
230− * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
231− * [MessageWithParts] view is materialized lazily and cached until the next mutation — so a
232− * burst of streamed token updates rebuilds a message's part list at most once per published
233− * [snapshot], not once per token. The cached view is also reference-stable across snapshots
234− * while unchanged, so Compose skips recomposing bubbles that didn't change.
235− */
236− private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
237− private var cached: MessageWithParts? = null
238− fun view(): MessageWithParts =
239− cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
240− /** Adopt an existing immutable instance as the cache — avoids an immediate rebuild and
241− * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
242− fun adopt(m: MessageWithParts) { cached = m }
243− fun invalidate() { cached = null }
244− }
245−
246− /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
247− private fun holderOf(m: MessageWithParts): Holder {
248− val parts = LinkedHashMap<String, Part>(m.parts.size)
249− for (p in m.parts) parts[p.id] = p
250− return Holder(m.info, parts).also { it.adopt(m) }
251− }
252−
253− /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
254− * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
255− * so they're newer than the snapshot and must win over it; every other part is taken
256− * from the authoritative REST snapshot. Without this distinction the merge kept every
257− * overlapping in-memory part, so a part that changed server-side during the disconnect
258− * (e.g. a tool going running -> completed) stayed stale until app restart. */
259− private val streamedSincePivot = mutableSetOf<String>()
260−
261− /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
262− * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
263− * on a reconnect re-seed these are the messages whose info changed *during* the REST
264− * fetch, so the in-memory info is newer than the snapshot and must win; every other
265− * message's info is taken from the authoritative REST snapshot. Without this the merge
266− * kept the in-memory info, so a run that finished server-side during the disconnect
267− * (e.g. cost/token totals, completion time) stayed stale until app restart. */
268− private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
269−
270− /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
271− * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
272− fun beginReseed() {
273− streamedSincePivot.clear()
274− messageInfoUpdatedSincePivot.clear()
275− }
276−
277− /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
278− private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
279−
280− /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
281− internal val maxMessages = NetworkConfig.maxInMemoryMessages
282−
283− fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
284−
285− fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
286− var changed = false
287− // On re-seed (after SSE reconnect), remove messages that are no longer in the
288− // server snapshot (e.g. deleted during the disconnection gap). This keeps the
289− // in-memory state in sync with the authoritative REST snapshot rather than
290− // accumulating stale messages forever. Skipped on the initial seed to avoid
291− // racing with just-arrived SSE events whose messages may not yet be in REST.
292− if (prune) {
293− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
294− val before = messages.size
295− messages.keys.retainAll(incomingIds)
296− if (messages.size != before) changed = true
297− }
298−
299− for (m in initial) {
300− val existing = messages[m.info.id]
301− if (existing == null) {
302− messages[m.info.id] = holderOf(m)
303− changed = true
304− } else {
305− // A part streamed in between subscribe and this initial load may already
306− // have populated this message (see observeMessages). Merge instead of
307− // overwriting so that newer streamed part isn't discarded: take the
308− // snapshot's part order, swap in the streamed version where ids overlap,
309− // append any streamed-only parts, and adopt the authoritative REST info.
310− // existing.parts is already keyed by part id (insertion-ordered).
311− val streamedById = existing.parts
312− val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
313− // Prefer the in-memory version of an overlapping part ONLY if it streamed
314− // in since the reseed pivot (i.e. during this fetch), so it's newer than
315− // the snapshot. Otherwise the REST snapshot is authoritative and wins,
316− // discarding an in-memory part that went stale during a disconnect.
317− val ordered = m.parts.map { p ->
318− if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
319− }.toMutableList()
320− for (p in existing.parts.values) {
321− if (p.id !in snapshotIds) ordered.add(p)
322− }
323− // Adopt the authoritative REST info unless the in-memory info was itself
324− // updated live since the pivot (i.e. during this fetch), in which case it's
325− // newer than the snapshot. Otherwise the REST snapshot wins, discarding
326− // in-memory info that went stale during a disconnect.
327− val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
328− val merged = MessageWithParts(info = info, parts = ordered)
329− if (merged != existing.view()) {
330− messages[m.info.id] = holderOf(merged)
331− changed = true
332− }
333− }
334− }
335− // A part that streamed in *before* this initial load (observeMessages subscribes to
336− // SSE before fetching) inserts its message at the front of the insertion-ordered map,
337− // and the merge above keeps that position — pinning a running message above the older
338− // history that seed() inserts afterward. Restore chronological order by creation time
339− // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
340− // removes from the map's front, which before reordering is the newest streamed message.
341− if (reorderByTime()) changed = true
342− if (evictOldMessages()) changed = true
343− // Reset the pivot: everything just merged is now the baseline, so only parts/info
344− // that stream in *after* this seed can outrank the next reconnect's snapshot.
345− streamedSincePivot.clear()
346− messageInfoUpdatedSincePivot.clear()
347− return changed
348− }
349−
350− /** Reorder the message map by message creation time (stable). Ties and entries with no
351− * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
352− * relative order, so such a message stays last. Preserves each entry's map key so the
353− * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
354− private fun reorderByTime(): Boolean {
355− val entries = messages.entries.map { it.key to it.value }
356− val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
357− // Detect a reordering by comparing keys positionally, instead of allocating two more
358− // N-sized key lists just to compare them.
359− var changed = false
360− for (i in sorted.indices) {
361− if (sorted[i].first != entries[i].first) { changed = true; break }
362− }
363− if (!changed) return false
364− messages.clear()
365− for ((k, v) in sorted) messages[k] = v
366− return true
367− }
368−
369− /** Returns true if the state changed (and a new snapshot should be published). */
370− fun reduce(sessionId: String, event: BusEvent): Boolean {
371− return when (event) {
372− is MessageUpdated -> handleMessageUpdated(sessionId, event)
373−
374− is MessagePartUpdated -> {
375− val part = event.properties.part
376− val messageId = part.messageID ?: event.properties.messageID
377− val partSession = part.sessionID ?: event.properties.sessionID
378− when {
379− messageId == null -> false
380− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
381− // No session id anywhere on the event: a null session is NOT a wildcard
382− // (mirroring isIdle/isError). Every open session's store sees the shared
383− // event stream, so accepting it unconditionally would leak the part into
384− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
385− // only if this store already holds the parent message — MessageUpdated,
386− // which always carries a session id, will have created it here first.
387− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
388− }
389− }
390−
391− is MessagePartRemoved -> handlePartRemoved(sessionId, event)
392−
393− is MessageRemoved -> handleMessageRemoved(sessionId, event)
394−
395− else -> false
396− }
397− }
398−
399− private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
400− val info = event.properties.info
401− if (info.sessionID != sessionId) return false
402− // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
403− // field) would collide with other such messages in the map. Generate a unique synthetic
404− // key so each unknown message gets its own entry instead of overwriting others.
405− val key = if (info.id.isEmpty() && info is UnknownMessage) {
406− "unknown-${unknownCounter.incrementAndGet()}"
407− } else {
408− info.id
409− }
410− val existing = messages[key]
411− // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
412− // redundant snapshot (mirrors upsertPart's identical-part guard).
413− if (existing != null && existing.info == info) return false
414− if (existing == null) {
415− messages[key] = Holder(info, LinkedHashMap())
416− } else {
417− existing.info = info
418− existing.invalidate()
419− }
420− // Record that this message's info streamed in live, so a re-seed after a reconnect knows
421− // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
422− messageInfoUpdatedSincePivot.add(key)
423− if (existing == null) evictOldMessages()
424− return true
425− }
426−
427− /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
428− private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
429− eventSession == null || eventSession == sessionId
430−
431− private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
432− val messageId = event.properties.messageID ?: return false
433− val partId = event.properties.partID ?: return false
434− if (!matchesSession(event.properties.sessionID, sessionId)) return false
435− return removePart(messageId, partId)
436− }
437−
438− private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
439− val id = event.properties.messageID ?: return false
440− if (!matchesSession(event.properties.sessionID, sessionId)) return false
441− val removed = messages.remove(id) ?: return false
442− messageInfoUpdatedSincePivot.remove(id)
443− removed.parts.keys.forEach { streamedSincePivot.remove(it) }
444− return true
445− }
446−
447− private fun upsertPart(messageId: String, part: Part): Boolean {
448− val current = messages[messageId]
449− if (current != null) {
450− // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
451− // on every streamed token. A LinkedHashMap re-put keeps the part's original
452− // position (replace-in-place); a new id is appended — matching the old list order.
453− if (current.parts[part.id] == part) return false
454− current.parts[part.id] = part
455− current.invalidate()
456− } else {
457− val parts = LinkedHashMap<String, Part>()
458− parts[part.id] = part
459− messages[messageId] = Holder(
460− info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
461− parts = parts,
462− )
463− }
464− // Record that this part streamed in, so a re-seed after a reconnect knows it's
465− // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
466− streamedSincePivot.add(part.id)
467− if (current == null) evictOldMessages()
468− return true
469− }
470−
471− private fun removePart(messageId: String, partId: String): Boolean {
472− val current = messages[messageId] ?: return false
473− if (current.parts.remove(partId) == null) return false
474− current.invalidate()
475− return true
476− }
477−
478− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
479− * Returns true if any messages were evicted. */
480− private fun evictOldMessages(): Boolean {
481− var evicted = false
482− while (messages.size > maxMessages) {
483− val oldestKey = messages.keys.iterator().next()
484− val removed = messages.remove(oldestKey)
485− // Prune the evicted message's pivot bookkeeping so these sets don't grow
486− // unbounded over a long-running session that never reconnects (they're only
487− // otherwise cleared on reseed/seed).
488− messageInfoUpdatedSincePivot.remove(oldestKey)
489− removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
490− evicted = true
491− }
492− return evicted
493− }
494−}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktdeleted−98
@@ -1,98 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.floatPreferencesKey
7−import androidx.datastore.preferences.core.stringPreferencesKey
8−import androidx.datastore.preferences.preferencesDataStore
9−import kotlinx.coroutines.flow.Flow
10−import kotlinx.coroutines.flow.map
11−
12−enum class ThemeMode { SYSTEM, LIGHT, DARK }
13−
14−private val Context.settingsDataStore by preferencesDataStore(name = "settings")
15−
16−/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
17−class SettingsStore(context: Context) {
18−
19− private val appContext = context.applicationContext
20− private val themeKey = stringPreferencesKey("theme_mode")
21− private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
22− private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
23− private val appLockKey = booleanPreferencesKey("app_lock")
24− private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
25− private val codeWrapKey = booleanPreferencesKey("code_wrap")
26−
27− companion object {
28− /** Bounds for the chat text-size multiplier so the UI can't be scaled into
29− * unreadability or an unusable micro-font. 1.0 is the design default. */
30− const val MIN_CHAT_TEXT_SCALE = 0.8f
31− const val MAX_CHAT_TEXT_SCALE = 1.6f
32− const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
33− }
34−
35− val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
36− runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
37− .getOrDefault(ThemeMode.SYSTEM)
38− }
39−
40− val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
41− prefs[dynamicColorKey] ?: true
42− }
43−
44− /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
45− * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
46− * true to match the prior hardcoded behavior. */
47− val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
48− prefs[sendOnEnterKey] ?: true
49− }
50−
51− /** When true, the app requires device biometric/credential authentication on launch
52− * (and when returning from the background) before the UI is shown, protecting the
53− * stored server credentials. Defaults to false so the app is usable out of the box. */
54− val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
55− prefs[appLockKey] ?: false
56− }
57−
58− /** Multiplier applied to chat message (markdown) and code text so users can size the
59− * conversation for readability independently of the system font scale. Clamped to
60− * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
61− * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
62− val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
63− (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
64− .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
65− }
66−
67− /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
68− * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
69− val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
70− prefs[codeWrapKey] ?: false
71− }
72−
73− suspend fun setThemeMode(mode: ThemeMode) {
74− appContext.settingsDataStore.edit { it[themeKey] = mode.name }
75− }
76−
77− suspend fun setDynamicColor(enabled: Boolean) {
78− appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
79− }
80−
81− suspend fun setSendOnEnter(enabled: Boolean) {
82− appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
83− }
84−
85− suspend fun setAppLock(enabled: Boolean) {
86− appContext.settingsDataStore.edit { it[appLockKey] = enabled }
87− }
88−
89− suspend fun setChatTextScale(scale: Float) {
90− appContext.settingsDataStore.edit {
91− it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
92− }
93− }
94−
95− suspend fun setCodeWrap(enabled: Boolean) {
96− appContext.settingsDataStore.edit { it[codeWrapKey] = enabled }
97− }
98−}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktdeleted−842
@@ -1,842 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import android.content.Context
4−import android.net.ConnectivityManager
5−import android.net.Network
6−import android.net.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.FilePromptPart
12−import soy.iko.opencode.data.model.MessagePartUpdated
13−import soy.iko.opencode.data.model.MessageUpdated
14−import soy.iko.opencode.data.model.Permission
15−import soy.iko.opencode.data.model.PermissionReplied
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.PermissionUpdated
18−import soy.iko.opencode.data.model.StepFinishPart
19−import soy.iko.opencode.data.model.ServerProfile
20−import soy.iko.opencode.data.model.SessionError
21−import soy.iko.opencode.data.model.SessionIdle
22−import soy.iko.opencode.data.repo.AttachmentDraftStore
23−import soy.iko.opencode.data.repo.BackupManager
24−import soy.iko.opencode.data.repo.DraftStore
25−import soy.iko.opencode.data.repo.ErrorKind
26−import soy.iko.opencode.data.repo.MessageCacheStore
27−import soy.iko.opencode.data.repo.OutboxStore
28−import soy.iko.opencode.data.repo.SessionPrefsStore
29−import soy.iko.opencode.data.repo.ProfileStore
30−import soy.iko.opencode.data.repo.SettingsStore
31−import soy.iko.opencode.data.repo.classifyError
32−import soy.iko.opencode.data.repo.responseStatusCode
33−import soy.iko.opencode.data.network.HttpClientFactory
34−import soy.iko.opencode.data.network.NetworkConfig
35−import soy.iko.opencode.data.network.OpencodeApiClient
36−import soy.iko.opencode.notification.NotificationChannels
37−import soy.iko.opencode.util.safeExceptionSummary
38−import soy.iko.opencode.notification.SessionNotifications
39−import soy.iko.opencode.R
40−import soy.iko.opencode.util.runCatchingCancellable
41−import kotlinx.coroutines.CoroutineScope
42−import kotlinx.coroutines.Dispatchers
43−import kotlinx.coroutines.ExperimentalCoroutinesApi
44−import kotlinx.coroutines.SupervisorJob
45−import kotlinx.coroutines.cancel
46−import kotlinx.coroutines.delay
47−import kotlinx.coroutines.flow.MutableStateFlow
48−import kotlinx.coroutines.flow.StateFlow
49−import kotlinx.coroutines.flow.asStateFlow
50−import kotlinx.coroutines.flow.combine
51−import kotlinx.coroutines.flow.emptyFlow
52−import kotlinx.coroutines.flow.first
53−import kotlinx.coroutines.Job
54−import kotlinx.coroutines.flow.flatMapLatest
55−import kotlinx.coroutines.flow.update
56−import kotlinx.coroutines.isActive
57−import kotlinx.coroutines.launch
58−import kotlinx.coroutines.sync.Mutex
59−import kotlinx.coroutines.sync.withLock
60−import kotlinx.coroutines.withTimeoutOrNull
61−import java.util.concurrent.atomic.AtomicBoolean
62−
63−/**
64− * Result of probing a server URL to check connectivity and whether authentication
65− * is required, without persisting a profile or opening a long-lived connection.
66− */
67−sealed class ProbeResult {
68− /** Server is reachable and accepts unauthenticated requests. */
69− object Reachable : ProbeResult()
70−
71− /** Server is reachable but rejected the probe with an authentication error. */
72− object NeedsAuth : ProbeResult()
73−
74− /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
75− data class Unreachable(val error: String) : ProbeResult()
76−}
77−
78−/**
79− * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
80− * process-wide singletons and the currently active [OpencodeConnection].
81− */
82−open class AppContainer private constructor(
83− private val appContext: Context?,
84− private val skipInit: Boolean,
85− @Suppress("unused") private val testMode: Boolean,
86−) {
87− constructor(context: Context) : this(context.applicationContext, false, false)
88− protected constructor() : this(null, true, true)
89−
90− private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
91− open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
92− open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
93− open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
94− open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
95− open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
96− open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
97− open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
98− open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
99−
100− /** True while the outbox is actively flushing queued messages to the server, so the UI
101− * can show a "sending queued messages…" indicator. */
102− private val _outboxSending = MutableStateFlow(false)
103− open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
104−
105− /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
106− * independent of connection/online changes. */
107− private val _outboxFlushTrigger = MutableStateFlow(0)
108− open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
109−
110− private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
111− open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
112−
113− /** True while a background auto-reconnect to the most-recent server is in flight. */
114− private val _reconnecting = MutableStateFlow(false)
115− open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
116−
117− /**
118− * Set once a startup auto-reconnect succeeds. The server list collects this and
119− * (once) navigates straight to the session list so a returning user skips it.
120− * [consumeAutoConnect] guards against re-firing on screen re-entry.
121− */
122− private val _autoConnectDone = MutableStateFlow(false)
123− open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
124− private val autoConnectConsumed = AtomicBoolean(false)
125− open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
126−
127− /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
128− private val _pendingShare = MutableStateFlow<String?>(null)
129− open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
130− open fun setPendingShare(text: String) { _pendingShare.value = text }
131− open fun consumePendingShare(): String? {
132− val current = _pendingShare.value ?: return null
133− return if (_pendingShare.compareAndSet(current, null)) current else null
134− }
135−
136− /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
137− * opened session. Consumed once by the first ChatScreen to compose after the share. */
138− private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
139− open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
140− open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
141− open fun consumePendingSharedMedia(): List<String> {
142− val current = _pendingSharedMedia.value
143− if (current.isEmpty()) return emptyList()
144− return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
145− }
146−
147− /**
148− * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
149− * consumes it once connected, creating a fresh session and opening it.
150− */
151− private val _pendingNewSession = MutableStateFlow(false)
152− open val pendingNewSession: StateFlow<Boolean> = _pendingNewSession.asStateFlow()
153− open fun requestNewSession() { _pendingNewSession.value = true }
154− open fun consumePendingNewSession(): Boolean = _pendingNewSession.compareAndSet(true, false)
155−
156− /**
157− * A session id to open from an external trigger (a notification tap or a deep link).
158− * The nav host consumes it once a connection is active.
159− */
160− private val _pendingOpenSession = MutableStateFlow<String?>(null)
161− open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
162− open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
163− open fun consumePendingOpenSession(): String? {
164− val current = _pendingOpenSession.value ?: return null
165− return if (_pendingOpenSession.compareAndSet(current, null)) current else null
166− }
167−
168− /**
169− * The session the user is currently viewing (or null when not in a chat). Drives the
170− * unread tracker: messages arriving for any *other* session mark it unread, and
171− * opening a session clears its unread state.
172− */
173− private val _currentSession = MutableStateFlow<String?>(null)
174− open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
175−
176− /** Session ids that received activity while not being viewed, mapped to the count
177− * of unread message events. A count (not just a presence set) lets the session
178− * list badge show "3 unread" instead of a bare dot, so the user can tell whether
179− * one reply or a whole burst arrived. */
180− private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
181− open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
182−
183− /** True when any assistant run is actively streaming across all sessions. Drives the
184− * disconnect confirmation on the session list (disconnecting mid-run kills it). */
185− private val _anyRunActive = MutableStateFlow(false)
186− open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
187−
188− /** Whether the device currently has network connectivity. Distinct from the SSE
189− * connection state so the UI can tell "you're offline" (device) from "server
190− * unreachable" (credentials/host). Updated by the [networkCallback]. */
191− private val _isOnline = MutableStateFlow(true)
192− open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
193−
194− /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
195− * which stays set while the app is merely backgrounded (the chat screen isn't disposed
196− * when the user locks their phone). Permission/completion notifications need the real
197− * foreground signal so they still fire for a session the user "has open" but has walked
198− * away from — the core of the run-in-the-background use case. Set from MainActivity's
199− * onStart/onStop. */
200− private val _isForeground = MutableStateFlow(false)
201− open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
202− open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
203−
204− /** True when the user is actively looking at [sessionId] (app foregrounded AND that
205− * session is the one on screen), so an in-app affordance (the permission dialog) is
206− * handling it and a notification would be redundant. */
207− private fun isActivelyViewing(sessionId: String): Boolean =
208− _isForeground.value && sessionId == _currentSession.value
209−
210− open fun setCurrentSession(id: String?) {
211− _currentSession.value = id
212− if (id != null) {
213− _unread.update { it - id }
214− unreadMessageIds.remove(id)
215− appContext?.let {
216− SessionNotifications.cancel(it, id)
217− SessionNotifications.cancelPermission(it, id)
218− }
219− }
220− }
221−
222− /** Restore a session's unread badge after a failed server switch reconnects.
223− * Preserves the prior [count] so a session badged with "5 unread" before the
224− * switch attempt still shows 5 (not 1) after the restore. No-op for the session
225− * the user is currently viewing, since a viewed session is never badged. */
226− open fun restoreUnread(id: String, count: Int) {
227− if (id != _currentSession.value) {
228− _unread.update { it + (id to count) }
229− }
230− }
231−
232− /** Resolve a localized string — view models reach resources through the container. */
233− open fun string(id: Int, vararg formatArgs: Any): String {
234− if (appContext == null) return ""
235− return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
236− }
237−
238− /**
239− * Convert a throwable into a user-facing message. Classifies the error by concrete
240− * type (network, timeout, HTTP status) rather than string-matching class names, so
241− * the message reflects what actually went wrong without leaking internal URLs/state.
242− */
243− open fun friendlyError(t: Throwable): String =
244− friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
245−
246− /**
247− * Same classification as [friendlyError] but accepts an explicit base URL, so callers
248− * that aren't operating on the active connection (e.g. [probeServer]) can still
249− * produce a user-facing message with the right server address.
250− */
251− open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
252− when (classifyError(t)) {
253− ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
254− string(R.string.error_not_reachable, baseUrl)
255− ErrorKind.TIMEOUT -> string(R.string.error_timeout)
256− ErrorKind.SERVER -> string(R.string.error_server)
257− // Show only the HTTP status (e.g. 401, 404) — never the request URL,
258− // which a ClientRequestException carries in its message and which we
259− // promised not to leak (it can include auth or internal paths).
260− ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
261− ?: string(R.string.error_generic)
262− ErrorKind.UNKNOWN -> string(R.string.error_generic)
263− }
264−
265− init {
266− if (!skipInit) {
267− NotificationChannels.create(appContext!!)
268− observeMessageActivity()
269− appScope.launch { runCatchingCancellable { outboxStore.load() } }
270− observeOutbox()
271− appScope.launch { autoConnect() }
272− }
273− }
274−
275− /**
276− * Flush the offline outbox whenever there's an active connection and the device is
277− * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
278− * server they were composed for (matched by profile id), so a reconnect to a different
279− * server never misfires them against a session id that doesn't exist there. Uses a plain
280− * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight — a
281− * cancelled+retried POST could double-send since each attempt carries a fresh idempotency
282− * key.
283− */
284− private fun observeOutbox() {
285− appScope.launch {
286− combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
287− .collect { (conn, online) ->
288− if (conn == null || !online) return@collect
289− flushOutboxNow(conn)
290− }
291− }
292− }
293−
294− private suspend fun flushOutboxNow(conn: OpencodeConnection) {
295− val pending = outboxStore.messages.value
296− .filter { it.profileId == conn.profile.id }
297− .sortedBy { it.createdAt }
298− if (pending.isEmpty()) return
299− _outboxSending.value = true
300− try {
301− for (msg in pending) {
302− val sent = runCatchingCancellable {
303− conn.repository.sendPrompt(
304− msg.sessionId,
305− msg.text,
306− attachments = msg.attachments.map {
307− FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
308− },
309− model = msg.model,
310− agent = msg.agent,
311− )
312− }.onFailure {
313− Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
314− }.isSuccess
315− // Stop on the first failure — the connection likely dropped again; the
316− // remaining messages stay queued and retry on the next trigger, preserving
317− // send order.
318− if (sent) outboxStore.remove(msg.id) else break
319− }
320− } finally {
321− _outboxSending.value = false
322− }
323− }
324−
325− /** Release resources held for the process lifetime (network callback, app scope). */
326− open fun shutdown() {
327− // Cancel the app scope first so any coroutine holding the connection mutex
328− // (e.g. a connect() suspended on profileStore) is cancelled and releases the
329− // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
330− // that can't be cancelled until after the runBlocking completes.
331− appScope.cancel()
332− // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
333− // 2 seconds is generous: appScope.cancel() already triggered cancellation of
334− // any coroutine holding the mutex; we're just waiting for it to unwind.
335− kotlinx.coroutines.runBlocking {
336− withTimeoutOrNull(2_000) {
337− connectionMutex.withLock {
338− runCatching { _activeConnection.value?.close() }
339− _activeConnection.value = null
340− }
341− }
342− // If the timeout fired, force-clear the connection so it isn't left dangling.
343− _activeConnection.value = null
344− }
345− val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
346− networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
347− if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
348− if (!skipInit) draftStore.shutdown()
349− }
350−
351− /**
352− * Watch the SSE bus for new message activity and badge any session that isn't
353− * currently open. This powers the unread dot on the session list so the user can
354− * tell which conversations got a reply while they were elsewhere.
355− *
356− * Uses [flatMapLatest] so a server switch (which replaces the active connection)
357− * re-subscribes to the new event stream and drops the old one.
358− */
359− @OptIn(ExperimentalCoroutinesApi::class)
360− private fun observeMessageActivity() {
361− appScope.launch {
362− // Retry the observer if it fails for any reason (transient flow exception,
363− // cancellation due to scope issues). Without this, a single failure would
364− // permanently disable unread tracking and completion notifications.
365− while (isActive) {
366− runCatchingCancellable {
367− activeConnection
368− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
369− .collect { event ->
370− // A permission request needs the user's approval. Post a heads-up
371− // notification (with Allow/Reject actions) unless the user is actively
372− // viewing this session, where the in-app permission dialog handles it.
373− if (event is PermissionUpdated) {
374− val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
375− if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties) }
376− return@collect
377− }
378− // The permission was answered (in-app, from the notification, or
379− // auto-resolved): clear its heads-up notification.
380− if (event is PermissionReplied) {
381− val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
382− appContext?.let { SessionNotifications.cancelPermission(it, psid) }
383− return@collect
384− }
385− // A run failed. Clear its active-run tracking (an error isn't always
386− // followed by a SessionIdle) and notify unless it's being viewed.
387− if (event is SessionError) {
388− val esid = event.properties.sessionID ?: return@collect
389− val wasRunning = synchronized(activeRuns) {
390− activeRuns.remove(esid).also { removed ->
391− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
392− }
393− }
394− if (wasRunning && !isActivelyViewing(esid)) {
395− appScope.launch { notifySessionError(esid) }
396− }
397− return@collect
398− }
399− // SessionIdle is not "message activity" (don't badge as unread)
400− // but signals a run finished — fire a completion notification if the
401− // session isn't currently being viewed and was actively streaming.
402− if (event is SessionIdle) {
403− val idleSid = event.properties.sessionID ?: return@collect
404− // Clear the run state unconditionally — even for the session on
405− // screen. Gating the removal on `idleSid != currentSession` (as
406− // this once did) leaks the id in activeRuns and pins anyRunActive
407− // true forever whenever a run finishes while being viewed. Only
408− // the completion *notification* is suppressed for the viewed one.
409− val wasRunning = synchronized(activeRuns) {
410− activeRuns.remove(idleSid).also { removed ->
411− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
412− }
413− }
414− if (wasRunning && !isActivelyViewing(idleSid)) {
415− // Fire this off the collector's coroutine: notifySessionCompleted
416− // does a network listSessions() to resolve the title, and blocking
417− // the shared-event collector here would stall the SharedFlow,
418− // overflowing its DROP_OLDEST buffer and silently dropping live
419− // parts/updates for every other subscriber (e.g. the message reducer).
420− appScope.launch { notifySessionCompleted(idleSid) }
421− }
422− return@collect
423− }
424− val sid = sessionOf(event) ?: return@collect
425− // Re-read currentSession here, right before mutating the badge state:
426− // the user may have opened this session between when this event was
427− // enqueued and now (setCurrentSession clears its badge and dedup set).
428− // A stale earlier check would race a badge — and a dedup entry — back
429− // in for the session that's actually on screen.
430− if (sid != _currentSession.value) {
431− // Increment the unread count once per distinct *message*, not per
432− // event: a single reply emits many message.updated / message.part.
433− // updated events (one per streamed token, plus cost/token refreshes),
434− // which would otherwise inflate the badge into the dozens/hundreds.
435− val messageId = messageIdOfEvent(event)
436− val counted = unreadMessageIds.getOrPut(sid) {
437− java.util.Collections.synchronizedSet(mutableSetOf())
438− }
439− if (messageId == null || counted.add(messageId)) {
440− _unread.update { current ->
441− // Guard again inside the atomic update lambda so a retry
442− // (or an open that landed just now) can't reintroduce the
443− // badge for the session on screen.
444− if (sid == _currentSession.value) current
445− else current + (sid to (current[sid] ?: 0) + 1)
446− }
447− }
448− }
449− // Track sessions actively streaming so we know which idle events
450− // represent a finished run worth notifying about.
451− if (isLiveRunActivity(event)) {
452− // The add-under-lock is required both for thread-safety and for the
453− // LRU access-order refresh (keeps a continuously-streaming session
454− // from being evicted), so it runs per event. Only the flag write is
455− // skippable once it's already set.
456− synchronized(activeRuns) {
457− if (activeRuns.size >= activeRunsLimit) {
458− activeRuns.remove(activeRuns.iterator().next())
459− }
460− activeRuns.add(sid)
461− }
462− if (!_anyRunActive.value) _anyRunActive.value = true
463− }
464− } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
465− if (!isActive) break
466− delay(NetworkConfig.observerRetryDelayMs)
467− }
468− }
469− }
470−
471− /** True if [event] is live streaming activity worth tracking as an active run. A
472− * trailing message.updated that lands *after* the run finished (final cost/token totals,
473− * carrying a completion time) is NOT live streaming: with no further SessionIdle to
474− * follow it, re-adding the session would pin anyRunActive true indefinitely. */
475− private fun isLiveRunActivity(event: BusEvent): Boolean {
476− if (event !is MessagePartUpdated && event !is MessageUpdated) return false
477− val info = (event as? MessageUpdated)?.properties?.info
478− if (info is AssistantMessage && info.isComplete) return false
479− // A step-finish part is the trailing completion marker for a run (it carries the
480− // final cost/token totals) and arrives after the last stream delta. Like a completed
481− // message.updated, treating it as live would re-add the session to activeRuns with no
482− // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
483− // a part is replayed/reordered after the run already went idle.
484− val part = (event as? MessagePartUpdated)?.properties?.part
485− if (part is StepFinishPart) return false
486− return true
487− }
488−
489− /** Session ids currently streaming an assistant run (best-effort, in-process). */
490− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
491−
492− /** Per non-viewed session, the set of message ids already reflected in its unread
493− * count. De-duplicates the many streaming events that share one message id so the
494− * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
495− private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
496−
497− /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
498− private val activeRunsLimit = NetworkConfig.activeRunsLimit
499−
500− /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
501− private val connectionMutex = Mutex()
502−
503− /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
504− * a DataStore + encrypted-prefs write on every rapid reconnect. */
505− private companion object {
506− const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
507− }
508−
509− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id. */
510− private suspend fun resolveSessionTitle(sessionId: String): String =
511− runCatchingCancellable {
512− activeConnection.value?.repository?.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
513− }.getOrNull() ?: sessionId
514−
515− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
516− private suspend fun notifySessionCompleted(sessionId: String) {
517− val title = resolveSessionTitle(sessionId)
518− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
519− }
520−
521− /** Post a heads-up permission notification for [permission], resolving its session title. */
522− private suspend fun notifyPermission(permission: Permission) {
523− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
524− val title = resolveSessionTitle(sessionId)
525− appContext?.let { SessionNotifications.postPermission(it, permission, title) }
526− }
527−
528− /** Post an error notification for a failed background run. */
529− private suspend fun notifySessionError(sessionId: String) {
530− val title = resolveSessionTitle(sessionId)
531− appContext?.let { SessionNotifications.postError(it, sessionId, title) }
532− }
533−
534− /** Respond to a permission from a notification action (Allow once / Always / Reject),
535− * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
536− * so the receiver's goAsync() result can finish. No-ops if the connection is gone. */
537− open fun respondToPermissionFromNotification(
538− sessionId: String,
539− permissionId: String,
540− response: PermissionResponse,
541− onDone: () -> Unit,
542− ) {
543− appScope.launch {
544− runCatchingCancellable {
545− activeConnection.value?.api?.respondPermission(sessionId, permissionId, response)
546− }.onFailure { Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}") }
547− onDone()
548− }
549− }
550−
551− /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
552− * server's default model/agent. [onDone] fires when the send resolves. */
553− open fun sendPromptFromNotification(sessionId: String, text: String, onDone: () -> Unit) {
554− appScope.launch {
555− runCatchingCancellable {
556− activeConnection.value?.repository?.sendPrompt(sessionId, text, model = null)
557− }.onFailure { Log.w("AppContainer", "notif reply send failed: ${safeExceptionSummary(it)}") }
558− onDone()
559− }
560− }
561−
562− /** Extract the session id an event pertains to, for message-activity events. */
563− private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
564−
565− /**
566− * On cold start, transparently reconnect to the most recently used server so a
567− * returning user lands in their session list instead of the empty server screen.
568− */
569− private suspend fun autoConnect() {
570− val recent = runCatchingCancellable { profileStore.profiles.first() }
571− .getOrDefault(emptyList())
572− .firstOrNull() ?: return
573− _reconnecting.value = true
574− val ok = runCatchingCancellable {
575− val conn = connect(recent)
576− conn.api.ping()
577− }.isSuccess
578− _reconnecting.value = false
579− if (ok) _autoConnectDone.value = true else disconnect()
580− }
581−
582− /**
583− * When the device regains connectivity, nudge the active SSE stream to reconnect
584− * immediately instead of waiting out the exponential backoff.
585− */
586− private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
587−
588− private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
589− if (appContext == null) return null
590− val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
591− ?: return null
592− // Seed the online state from the current active network so the indicator is
593− // correct on cold start rather than defaulting to "online" until a callback fires.
594− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
595− val callback = object : ConnectivityManager.NetworkCallback() {
596− override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
597− override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
598− }
599− // Only match networks that actually provide internet — a capability-less request
600− // fires onAvailable for transports that can't reach the server (and would trigger
601− // spurious reconnects), so require NET_CAPABILITY_INTERNET.
602− val request = NetworkRequest.Builder()
603− .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
604− .build()
605− runCatching { cm.registerNetworkCallback(request, callback) }
606− .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
607− return callback
608− }
609−
610− /** Open (or re-open) a connection to [profile], replacing any current one.
611− * Only persists the updated `lastUsed` timestamp if it's stale (older than a
612− * minute), so rapid reconnect storms don't each trigger a DataStore write. */
613− open suspend fun connect(profile: ServerProfile): OpencodeConnection =
614− connectionMutex.withLock {
615− _activeConnection.value?.close()
616− _activeConnection.value = null
617− activeRuns.clear()
618− _anyRunActive.value = false
619− _unread.value = emptyMap()
620− unreadMessageIds.clear()
621− val now = System.currentTimeMillis()
622− val resolved = profileStore.resolve(profile)
623− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
624− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
625− if (needsSave) profileStore.save(finalProfile)
626− OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
627− }
628−
629− open suspend fun disconnect() {
630− // Acquire the connection mutex so disconnect is serialized with connect().
631− // Previously this was fire-and-forget (launch on appScope), which raced with
632− // a subsequent connect(): connect() could acquire the mutex, create a new
633− // connection, and then the pending disconnect() would close it.
634− connectionMutex.withLock {
635− _activeConnection.value?.close()
636− _activeConnection.value = null
637− // Reset run/unread state on disconnect too. With no SSE stream there's no
638− // SessionIdle to drain activeRuns, so without this the working indicator
639− // (anyRunActive) and unread badges would stay pinned until the next connect().
640− activeRuns.clear()
641− _anyRunActive.value = false
642− _unread.value = emptyMap()
643− unreadMessageIds.clear()
644− }
645− }
646−
647− /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
648− * ViewModel scope) so navigating away during the undo window still commits the delete
649− * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
650− private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
651−
652− /**
653− * Schedule the profile [id] for deletion after [delayMs], cancellable via
654− * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
655− * after the delete resolves so the caller can clear its optimistic-hide state. A
656− * repeated schedule for the same id replaces the prior timer.
657− */
658− open fun scheduleProfileDelete(
659− id: String,
660− delayMs: Long,
661− onDeleted: () -> Unit,
662− onError: (Throwable) -> Unit,
663− ) {
664− val job = appScope.launch {
665− delay(delayMs)
666− // Claim the delete by removing our own entry BEFORE committing it — the atomic
667− // remove is the sync point with cancelProfileDelete. Losing the race (an undo
668− // removed us first) means we skip, honouring cancel's `true`; deleting first
669− // would let a cancel land in the gap and falsely report undo of a gone profile.
670− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
671− val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
672− if (!claimed) return@launch
673− runCatchingCancellable { profileStore.delete(id) }
674− .onSuccess { onDeleted() }
675− .onFailure { onError(it) }
676− }
677− pendingProfileDeletes.put(id, job)?.cancel()
678− }
679−
680− /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
681− * still pending (undo succeeded), false if the delete had already fired. */
682− open fun cancelProfileDelete(id: String): Boolean {
683− val job = pendingProfileDeletes.remove(id) ?: return false
684− job.cancel()
685− return true
686− }
687−
688− /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
689− * ViewModel scope) so navigating away during the undo window still commits the delete
690− * instead of silently cancelling it (which would leave the session deleted-in-UI but
691− * alive on the server, reappearing on the next refresh). */
692− private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
693−
694− /**
695− * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
696− * (the Undo action). Also clears the session's stored draft on a successful delete.
697− * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
698− * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
699− *
700− * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
701− * fires. Otherwise a server switch during the undo window would send the delete to whichever
702− * server happens to be active at fire time: the original session would never be deleted (its
703− * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
704− * on the new server could be deleted instead. A disconnect closes the captured connection's
705− * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
706− * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
707− * error) if there is no active connection at schedule time.
708− */
709− open fun scheduleSessionDelete(
710− id: String,
711− delayMs: Long,
712− onDeleted: () -> Unit,
713− onError: (Throwable) -> Unit,
714− ) {
715− val conn = _activeConnection.value
716− val job = appScope.launch {
717− delay(delayMs)
718− // Claim the delete by removing our own entry BEFORE committing it — the atomic
719− // remove is the sync point with cancelSessionDelete. Losing the race (an undo
720− // removed us first) means we skip, honouring cancel's `true`; deleting first
721− // would let a cancel land in the gap and falsely report undo of a gone session.
722− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
723− val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
724− if (!claimed) return@launch
725− if (conn == null) {
726− onError(IllegalStateException("No active connection"))
727− } else {
728− runCatchingCancellable { conn.repository.deleteSession(id) }
729− .onSuccess {
730− draftStore.remove(id)
731− attachmentDraftStore.remove(id)
732− sessionPrefsStore.forget(id)
733− messageCacheStore.remove(id)
734− outboxStore.removeForSession(id)
735− onDeleted()
736− }
737− .onFailure { onError(it) }
738− }
739− }
740− pendingSessionDeletes.put(id, job)?.cancel()
741− }
742−
743− /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
744− * still pending (undo succeeded), false if the delete had already fired. */
745− open fun cancelSessionDelete(id: String): Boolean {
746− val job = pendingSessionDeletes.remove(id) ?: return false
747− job.cancel()
748− return true
749− }
750−
751− /**
752− * Probe a server URL (without credentials) to check reachability and detect whether
753− * the server requires authentication. Builds a short-lived HTTP client with the same
754− * base URL normalization as [connect] but no auth, calls the health endpoint, and
755− * classifies the outcome:
756− *
757− * - 2xx → [ProbeResult.Reachable] (no auth needed)
758− * - 401/403 → [ProbeResult.NeedsAuth]
759− * - anything else → [ProbeResult.Unreachable] with a user-facing message
760− *
761− * The probe client is always closed afterwards so no resources linger. This does not
762− * touch the active connection or the profile store.
763− */
764− open suspend fun probeServer(baseUrl: String): ProbeResult {
765− return probeWithProfile(ServerProfile(
766− id = "probe",
767− label = "",
768− baseUrl = baseUrl.trim(),
769− username = null,
770− password = null,
771− ))
772− }
773−
774− /**
775− * Probe a server URL *with* credentials to validate them. Returns true if the
776− * server accepts the credentials (ping succeeds), false on 401/403, and throws
777− * on other failures (unreachable, timeout) so the caller can surface a friendly
778− * message. Mirrors [probeServer]'s short-lived-client pattern.
779− */
780− open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
781− val probeProfile = ServerProfile(
782− id = "probe-auth",
783− label = "",
784− baseUrl = baseUrl.trim(),
785− username = username.trim().takeIf { it.isNotBlank() },
786− password = password.trim().takeIf { it.isNotEmpty() },
787− )
788− val client = HttpClientFactory.create(probeProfile)
789− return try {
790− val api = OpencodeApiClient(client)
791− api.ping()
792− true
793− } catch (e: Exception) {
794− val status = responseStatusCode(e)
795− if (status == 401 || status == 403) false else throw e
796− } finally {
797− runCatching { client.close() }
798− }
799− }
800−
801− private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
802− val client = HttpClientFactory.create(profile)
803− return try {
804− val api = OpencodeApiClient(client)
805− api.ping()
806− ProbeResult.Reachable
807− } catch (e: Exception) {
808− val status = responseStatusCode(e)
809− if (status == 401 || status == 403) {
810− ProbeResult.NeedsAuth
811− } else {
812− ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
813− }
814− } finally {
815− runCatching { client.close() }
816− }
817− }
818−}
819−
820−/**
821− * Extract the session id a message-activity event pertains to, or null for events that
822− * don't describe message activity. This drives the unread badge: any session that
823− * receives activity while it isn't the currently-viewed one gets badged. Extracted as
824− * a top-level `internal` function so the rule is unit-testable without an Android
825− * [Context] (the surrounding [AppContainer] needs one).
826− */
827−internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
828− is MessageUpdated -> event.properties.info.sessionID
829− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
830− else -> null
831−}
832−
833−/**
834− * The message id a message-activity event pertains to, used to de-duplicate the unread
835− * badge so a reply's many streaming events count once. Null for events that carry no
836− * message id (those fall back to being counted individually).
837− */
838−internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
839− is MessageUpdated -> event.properties.info.id
840− is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
841− else -> null
842−}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktdeleted−47
@@ -1,47 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import soy.iko.opencode.data.network.EventStreamClient
5−import soy.iko.opencode.data.network.HttpClientFactory
6−import soy.iko.opencode.data.network.OpencodeApiClient
7−import soy.iko.opencode.data.repo.MessageCacheStore
8−import soy.iko.opencode.data.repo.SessionRepository
9−import kotlinx.coroutines.CoroutineScope
10−import kotlinx.coroutines.Dispatchers
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−
14−/**
15− * An active connection to one opencode server. Bundles the Ktor client, REST API,
16− * SSE event stream, and the reducing repository, all sharing a connection-scoped
17− * coroutine scope that is cancelled on [close].
18− */
19−open class OpencodeConnection(
20− val profile: ServerProfile,
21− // Optional on-disk message cache, supplied by AppContainer, so conversations render
22− // instantly and stay readable offline. Null in the test constructor.
23− private val messageCache: MessageCacheStore? = null,
24−) {
25−
26− protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"), null)
27−
28− private val scopeJob = SupervisorJob()
29− private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
30− private val client by lazy { HttpClientFactory.create(profile) }
31−
32− open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
33− open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
34− open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache) }
35−
36− /**
37− * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
38− * to wind down before closing the HTTP client. Calling client.close() while the SSE
39− * coroutine is still mid-request produces a spurious "stream error, will retry"
40− * warning during normal teardown.
41− */
42− open suspend fun close() {
43− scope.cancel()
44− scopeJob.join()
45− runCatching { client.close() }
46− }
47−}
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.ktdeleted−65
@@ -1,65 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.content.BroadcastReceiver
4−import android.content.Context
5−import android.content.Intent
6−import androidx.core.app.RemoteInput
7−import soy.iko.opencode.OpencodeApp
8−import soy.iko.opencode.data.model.PermissionResponse
9−
10−/**
11− * Handles the action buttons on the app's notifications so the user can respond without
12− * opening the app — the core of the "kick it off and walk away" flow:
13− *
14− * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
15− * request (Allow once / Always / Reject) via the active connection.
16− * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
17− * that just finished a run.
18− *
19− * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
20− * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
21− * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
22− */
23−class NotificationActionReceiver : BroadcastReceiver() {
24−
25− override fun onReceive(context: Context, intent: Intent) {
26− val container = (context.applicationContext as? OpencodeApp)?.container ?: return
27− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
28− when (intent.action) {
29− ACTION_PERMISSION -> {
30− val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
31− val response = PermissionResponse.entries.firstOrNull {
32− it.wire == intent.getStringExtra(EXTRA_RESPONSE)
33− } ?: return
34− // Dismiss the notification immediately for responsive feedback; the reply
35− // itself continues in the background.
36− SessionNotifications.cancelPermission(context, sessionId)
37− val pending = goAsync()
38− container.respondToPermissionFromNotification(sessionId, permissionId, response) {
39− pending.finish()
40− }
41− }
42− ACTION_REPLY -> {
43− val text = RemoteInput.getResultsFromIntent(intent)
44− ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
45− ?.takeIf { it.isNotEmpty() } ?: return
46− val pending = goAsync()
47− container.sendPromptFromNotification(sessionId, text) {
48− // The follow-up is on its way; clear the "session ready" notification.
49− // A fresh run (and its foreground indicator) now signals progress.
50− SessionNotifications.cancel(context, sessionId)
51− pending.finish()
52− }
53− }
54− }
55− }
56−
57− companion object {
58− const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
59− const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
60− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
61− const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
62− const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
63− const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
64− }
65−}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktdeleted−50
@@ -1,50 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.NotificationChannel
4−import android.app.NotificationManager
5−import android.content.Context
6−import soy.iko.opencode.R
7−
8−/**
9− * The app's notification channels. Created once at startup (idempotent — recreating an
10− * existing channel is a no-op). Two channels keep user control granular:
11− *
12− * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13− * "agent running" indicator.
14− * - [COMPLETED]: default importance. Used when a background session finishes a run.
15− * - [PERMISSION]: high importance (heads-up + sound). Used when a paused tool run needs
16− * the user's approval — the whole point is to reach the user who has walked away, so it
17− * interrupts rather than sitting silently in the shade.
18− * - [ERROR]: default importance. Used when a background run fails.
19− */
20−object NotificationChannels {
21− const val STATUS = "run_status"
22− const val COMPLETED = "session_completed"
23− const val PERMISSION = "permission_request"
24− const val ERROR = "session_error"
25−
26− fun create(context: Context) {
27− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
28− nm.createNotificationChannel(
29− NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
30− description = context.getString(R.string.notif_channel_status_desc)
31− setShowBadge(false)
32− },
33− )
34− nm.createNotificationChannel(
35− NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
36− description = context.getString(R.string.notif_channel_completed_desc)
37− },
38− )
39− nm.createNotificationChannel(
40− NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
41− description = context.getString(R.string.notif_channel_permission_desc)
42− },
43− )
44− nm.createNotificationChannel(
45− NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_DEFAULT).apply {
46− description = context.getString(R.string.notif_channel_error_desc)
47− },
48− )
49− }
50−}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktdeleted−106
@@ -1,106 +0,0 @@
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− // Show a live elapsed timer so the user can see how long the current run has
69− // been going — no per-tick plumbing needed; the system renders the chronometer.
70− .setWhen(System.currentTimeMillis())
71− .setUsesChronometer(true)
72− .setShowWhen(true)
73− .setPriority(NotificationCompat.PRIORITY_LOW)
74− .setCategory(NotificationCompat.CATEGORY_PROGRESS)
75− .build()
76− }
77−
78− override fun onDestroy() {
79− // Safety net: if the process is being killed while a run is in progress,
80− // ensure the notification is removed rather than lingering.
81− ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
82− super.onDestroy()
83− }
84−
85− override fun onBind(intent: Intent?): IBinder? = null
86−
87− companion object {
88− private const val TAG = "RunForegroundService"
89− private const val NOTIF_ID = 1
90−
91− fun start(context: Context) {
92− // startForegroundService can throw ForegroundServiceStartNotAllowedException
93− // on Android 12+ if the app is in the background. Wrap it so a backgrounded
94− // start (e.g. the user navigates away at the wrong moment) doesn't crash.
95− runCatching {
96− context.startForegroundService(Intent(context, RunForegroundService::class.java))
97− }.onFailure {
98− Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
99− }
100− }
101−
102− fun stop(context: Context) {
103− context.stopService(Intent(context, RunForegroundService::class.java))
104− }
105− }
106−}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktdeleted−215
@@ -1,215 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.annotation.SuppressLint
4−import android.app.PendingIntent
5−import android.content.Context
6−import android.content.Intent
7−import android.os.Build
8−import android.util.Log
9−import androidx.core.app.NotificationCompat
10−import androidx.core.app.NotificationManagerCompat
11−import androidx.core.app.RemoteInput
12−import soy.iko.opencode.MainActivity
13−import soy.iko.opencode.R
14−import soy.iko.opencode.data.model.Permission
15−import soy.iko.opencode.data.model.PermissionResponse
16−import java.security.MessageDigest
17−
18−/**
19− * Builds and posts the app's session notifications:
20− *
21− * - [postCompleted]: a "session ready" notification when a background run finishes, with an
22− * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
23− * - [postPermission]: a heads-up notification when a paused tool needs approval, with
24− * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
25− * - [postError]: a notification when a background run fails.
26− *
27− * Tapping a notification body opens the session via [MainActivity] with an
28− * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
29− * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
30− * skipped rather than throwing.
31− */
32−object SessionNotifications {
33−
34− private const val NOTIF_ID_PREFIX = 4000
35− private const val TAG = "SessionNotifications"
36−
37− // Distinct namespaces so a session can have a completion, a permission, and an error
38− // notification outstanding at once without their ids colliding.
39− private const val NS_COMPLETED = "done"
40− private const val NS_PERMISSION = "perm"
41− private const val NS_ERROR = "err"
42−
43− private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
44−
45− /** Derive a stable, collision-resistant notification id from a namespace + session id.
46− * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
47− * bits makes accidental collisions astronomically unlikely. The namespace keeps the
48− * three notification kinds for one session in separate id spaces. */
49− private fun notifId(namespace: String, sessionId: String): Int {
50− val digest = runCatching { MessageDigest.getInstance("SHA-256") }
51− .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
52− // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
53− // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
54− // left-to-right, so the shifts MUST be parenthesized individually — otherwise
55− // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
56− // byte out of the Int entirely and collapsing the id's entropy.
57− val hash = (((digest[0].toInt() and 0xFF) shl 24) or
58− ((digest[1].toInt() and 0xFF) shl 16) or
59− ((digest[2].toInt() and 0xFF) shl 8) or
60− (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
61− // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
62− // Using modulo ensures the prefix is preserved without Int overflow.
63− return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
64− }
65−
66− /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
67− * it, callers should skip posting instead of throwing. Foreground-service notifications
68− * are exempt, so the running indicator still shows during a run. */
69− private fun canPost(context: Context): Boolean =
70− Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
71− androidx.core.content.ContextCompat.checkSelfPermission(
72− context, android.Manifest.permission.POST_NOTIFICATIONS,
73− ) == android.content.pm.PackageManager.PERMISSION_GRANTED
74−
75− /** Sanitize a session id before embedding it in the deep-link URI so characters like
76− * /, ?, # can't inject path segments or query parameters. */
77− private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
78−
79− /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped. */
80− private fun openSessionIntent(context: Context, sessionId: String, requestCode: Int): PendingIntent {
81− val openIntent = Intent(context, MainActivity::class.java).apply {
82− action = Intent.ACTION_VIEW
83− data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
84− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
85− }
86− return PendingIntent.getActivity(
87− context, requestCode, openIntent,
88− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
89− )
90− }
91−
92− // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
93− // check through the helper, so suppress its MissingPermission flag on these posts.
94− @SuppressLint("MissingPermission")
95− fun postCompleted(context: Context, sessionId: String, title: String) {
96− if (!canPost(context)) return
97− val notifId = notifId(NS_COMPLETED, sessionId)
98−
99− // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
100− // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
101− // fill in the RemoteInput results before delivering it.
102− val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
103− .setLabel(context.getString(R.string.notif_reply_hint))
104− .build()
105− val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
106− action = NotificationActionReceiver.ACTION_REPLY
107− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
108− }
109− val replyPending = PendingIntent.getBroadcast(
110− context, notifId, replyIntent,
111− PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
112− )
113− val replyAction = NotificationCompat.Action.Builder(
114− R.drawable.ic_launcher_foreground,
115− context.getString(R.string.notif_action_reply),
116− replyPending,
117− ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
118−
119− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
120− .setSmallIcon(R.drawable.ic_launcher_foreground)
121− .setContentTitle(context.getString(R.string.notif_completed_title))
122− // No % escaping here: getString(id, arg) inserts the argument verbatim and
123− // never re-scans it for format specifiers, so escaping the title would show
124− // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
125− .setContentText(context.getString(R.string.notif_completed_text, title))
126− .setAutoCancel(true)
127− .setContentIntent(openSessionIntent(context, sessionId, notifId))
128− .addAction(replyAction)
129− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
130− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
131− .build()
132−
133− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
134− .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
135− }
136−
137− /** Post a heads-up notification for a permission request with Allow once / Always /
138− * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
139− * pattern/type supplies the detail line. */
140− @SuppressLint("MissingPermission")
141− fun postPermission(context: Context, permission: Permission, sessionTitle: String) {
142− if (!canPost(context)) return
143− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
144− val notifId = notifId(NS_PERMISSION, sessionId)
145− val detail = permission.title?.takeIf { it.isNotBlank() }
146− ?: permission.patternText?.takeIf { it.isNotBlank() }
147− ?: permission.type?.takeIf { it.isNotBlank() }
148− ?: context.getString(R.string.notif_permission_fallback)
149−
150− val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
151− .setSmallIcon(R.drawable.ic_launcher_foreground)
152− .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
153− .setContentText(detail)
154− .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
155− .setAutoCancel(true)
156− .setContentIntent(openSessionIntent(context, sessionId, notifId))
157− .setCategory(NotificationCompat.CATEGORY_CALL)
158− .setPriority(NotificationCompat.PRIORITY_HIGH)
159−
160− // One action per response. Each PendingIntent needs a distinct request code or
161− // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
162− listOf(
163− PermissionResponse.ONCE to R.string.notif_action_allow_once,
164− PermissionResponse.ALWAYS to R.string.notif_action_always,
165− PermissionResponse.REJECT to R.string.notif_action_reject,
166− ).forEach { (response, labelRes) ->
167− val intent = Intent(context, NotificationActionReceiver::class.java).apply {
168− action = NotificationActionReceiver.ACTION_PERMISSION
169− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
170− putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
171− putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
172− }
173− val pending = PendingIntent.getBroadcast(
174− context, notifId + response.ordinal + 1, intent,
175− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
176− )
177− builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
178− }
179−
180− runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
181− .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
182− }
183−
184− /** Post a notification when a background run fails. */
185− @SuppressLint("MissingPermission")
186− fun postError(context: Context, sessionId: String, title: String) {
187− if (!canPost(context)) return
188− val notifId = notifId(NS_ERROR, sessionId)
189− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
190− .setSmallIcon(R.drawable.ic_launcher_foreground)
191− .setContentTitle(context.getString(R.string.notif_error_title))
192− .setContentText(context.getString(R.string.notif_error_text, title))
193− .setAutoCancel(true)
194− .setContentIntent(openSessionIntent(context, sessionId, notifId))
195− .setCategory(NotificationCompat.CATEGORY_ERROR)
196− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
197− .build()
198− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
199− .onFailure { Log.w(TAG, "Failed to post error notification", it) }
200− }
201−
202− /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
203− private fun mutableFlag(): Int =
204− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
205−
206− /** Cancel a session's completion notification (e.g. when the user opens it). */
207− fun cancel(context: Context, sessionId: String) {
208− NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
209− }
210−
211− /** Cancel a session's permission notification (on reply, or when the user opens it). */
212− fun cancelPermission(context: Context, sessionId: String) {
213− NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
214− }
215−}
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktdeleted−58
@@ -1,58 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.net.Uri
6−import androidx.core.content.pm.ShortcutInfoCompat
7−import androidx.core.content.pm.ShortcutManagerCompat
8−import androidx.core.graphics.drawable.IconCompat
9−import soy.iko.opencode.MainActivity
10−import soy.iko.opencode.R
11−import soy.iko.opencode.data.repo.RecentSession
12−
13−/**
14− * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
15− * "Resume last". Rebuilt whenever the recent-session list changes so "Resume last" points at
16− * the most recent conversation via the existing `opencode://session/{id}` deep link.
17− */
18−object AppShortcuts {
19−
20− private const val ID_NEW = "new_session"
21− private const val ID_RESUME = "resume_last"
22− private val idRegex = Regex("[^A-Za-z0-9_-]")
23−
24− fun update(context: Context, lastSession: RecentSession?) {
25− val ctx = context.applicationContext
26− val shortcuts = mutableListOf<ShortcutInfoCompat>()
27−
28− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_NEW)
29− .setShortLabel(ctx.getString(R.string.shortcut_new_short))
30− .setLongLabel(ctx.getString(R.string.shortcut_new_long))
31− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
32− .setIntent(
33− Intent(ctx, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
34− )
35− .build()
36−
37− if (lastSession != null) {
38− val safeId = idRegex.replace(lastSession.id, "")
39− if (safeId.isNotEmpty()) {
40− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_RESUME)
41− .setShortLabel(ctx.getString(R.string.shortcut_resume_short))
42− .setLongLabel(
43− lastSession.title.ifBlank { ctx.getString(R.string.shortcut_resume_long) }.take(40),
44− )
45− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
46− .setIntent(
47− Intent(ctx, MainActivity::class.java).apply {
48− action = Intent.ACTION_VIEW
49− data = Uri.parse("opencode://session/$safeId")
50− },
51− )
52− .build()
53− }
54− }
55−
56− runCatching { ShortcutManagerCompat.setDynamicShortcuts(ctx, shortcuts) }
57− }
58−}
app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.ktdeleted−33
@@ -1,33 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.content.Intent
5−import android.os.Build
6−import android.service.quicksettings.TileService
7−import soy.iko.opencode.MainActivity
8−
9−/**
10− * Quick-settings tile that starts a new opencode session. Tapping launches [MainActivity]
11− * with [MainActivity.ACTION_NEW_SESSION]; the app creates and opens a fresh session once
12− * connected. On Android 14+ [startActivityAndCollapse] requires a PendingIntent.
13− */
14−class NewSessionTileService : TileService() {
15−
16− override fun onClick() {
17− super.onClick()
18− val intent = Intent(this, MainActivity::class.java).apply {
19− action = MainActivity.ACTION_NEW_SESSION
20− addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
21− }
22− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
23− val pending = PendingIntent.getActivity(
24− this, 0, intent,
25− PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
26− )
27− startActivityAndCollapse(pending)
28− } else {
29− @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
30− startActivityAndCollapse(intent)
31− }
32− }
33−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.ktdeleted−83
@@ -1,83 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.appwidget.AppWidgetManager
5−import android.appwidget.AppWidgetProvider
6−import android.content.ComponentName
7−import android.content.Context
8−import android.content.Intent
9−import android.net.Uri
10−import android.os.Build
11−import android.widget.RemoteViews
12−import soy.iko.opencode.MainActivity
13−import soy.iko.opencode.R
14−
15−/**
16− * Home-screen widget listing recent opencode sessions (fed by
17− * [soy.iko.opencode.data.repo.RecentSessionsStore]) with a "New session" action. Each row deep-links
18− * into its session; the header opens the app. The list is backed by [SessionsWidgetService].
19− */
20−class SessionsWidgetProvider : AppWidgetProvider() {
21−
22− override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
23− for (id in appWidgetIds) render(context, appWidgetManager, id)
24− }
25−
26− companion object {
27− /** Re-render all widgets and tell them to reload their list data (call after the
28− * recent-session list changes). No-op when no widget is placed. */
29− fun refresh(context: Context) {
30− val manager = AppWidgetManager.getInstance(context) ?: return
31− val ids = runCatching {
32− manager.getAppWidgetIds(ComponentName(context, SessionsWidgetProvider::class.java))
33− }.getOrNull() ?: return
34− if (ids.isEmpty()) return
35− for (id in ids) render(context, manager, id)
36− manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list)
37− }
38−
39− private fun render(context: Context, manager: AppWidgetManager, widgetId: Int) {
40− val views = RemoteViews(context.packageName, R.layout.widget_sessions)
41−
42− // Header: title opens the app; the "+" starts a new session.
43− views.setOnClickPendingIntent(
44− R.id.widget_title,
45− activityPending(context, requestCode = 2, Intent(context, MainActivity::class.java)),
46− )
47− views.setOnClickPendingIntent(
48− R.id.widget_new,
49− activityPending(
50− context, requestCode = 1,
51− Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
52− ),
53− )
54−
55− // Bind the list to the RemoteViewsService. A unique data Uri per widget id keeps
56− // the framework from reusing one adapter across widgets.
57− val serviceIntent = Intent(context, SessionsWidgetService::class.java).apply {
58− putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
59− data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
60− }
61− views.setRemoteAdapter(R.id.widget_list, serviceIntent)
62− views.setEmptyView(R.id.widget_list, R.id.widget_empty)
63−
64− // Template intent filled in per row (fill-in carries EXTRA_SESSION_ID). Must be
65− // mutable on Android 12+ so the row's fill-in can be merged.
66− val templateIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW }
67− val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
68− val templatePending = PendingIntent.getActivity(
69− context, 3, templateIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
70− )
71− views.setPendingIntentTemplate(R.id.widget_list, templatePending)
72−
73− manager.updateAppWidget(widgetId, views)
74− manager.notifyAppWidgetViewDataChanged(widgetId, R.id.widget_list)
75− }
76−
77− private fun activityPending(context: Context, requestCode: Int, intent: Intent): PendingIntent =
78− PendingIntent.getActivity(
79− context, requestCode, intent,
80− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
81− )
82− }
83−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.ktdeleted−47
@@ -1,47 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.widget.RemoteViews
6−import android.widget.RemoteViewsService
7−import soy.iko.opencode.MainActivity
8−import soy.iko.opencode.R
9−import soy.iko.opencode.data.repo.RecentSession
10−import soy.iko.opencode.data.repo.RecentSessionsStore
11−
12−/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
13−class SessionsWidgetService : RemoteViewsService() {
14− override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
15− SessionsWidgetFactory(applicationContext)
16−}
17−
18−private class SessionsWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
19−
20− private var items: List<RecentSession> = emptyList()
21−
22− override fun onCreate() { items = RecentSessionsStore.read(context) }
23−
24− // Re-read from disk whenever the app signals the data changed (notifyAppWidgetViewDataChanged).
25− override fun onDataSetChanged() { items = RecentSessionsStore.read(context) }
26−
27− override fun onDestroy() { items = emptyList() }
28−
29− override fun getCount(): Int = items.size
30−
31− override fun getViewAt(position: Int): RemoteViews {
32− val row = RemoteViews(context.packageName, R.layout.widget_session_item)
33− val session = items.getOrNull(position) ?: return row
34− row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
35− // Fill-in intent merged into the list's template: carries the session id the app opens.
36− row.setOnClickFillInIntent(
37− R.id.widget_item_root,
38− Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id),
39− )
40− return row
41− }
42−
43− override fun getLoadingView(): RemoteViews? = null
44− override fun getViewTypeCount(): Int = 1
45− override fun getItemId(position: Int): Long = position.toLong()
46− override fun hasStableIds(): Boolean = true
47−}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.ktdeleted−181
@@ -1,181 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import android.content.Context
4−import android.content.ContextWrapper
5−import android.os.Build
6−import androidx.biometric.BiometricManager
7−import androidx.biometric.BiometricPrompt
8−import androidx.compose.foundation.interaction.MutableInteractionSource
9−import androidx.compose.foundation.clickable
10−import androidx.compose.foundation.layout.Arrangement
11−import androidx.compose.foundation.layout.Box
12−import androidx.compose.foundation.layout.Column
13−import androidx.compose.foundation.layout.fillMaxSize
14−import androidx.compose.foundation.layout.padding
15−import androidx.compose.foundation.layout.size
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material3.Button
19−import androidx.compose.material3.Icon
20−import androidx.compose.material3.MaterialTheme
21−import androidx.compose.material3.Surface
22−import androidx.compose.material3.Text
23−import androidx.compose.runtime.Composable
24−import androidx.compose.runtime.DisposableEffect
25−import androidx.compose.runtime.LaunchedEffect
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateOf
28−import androidx.compose.runtime.remember
29−import androidx.compose.runtime.saveable.rememberSaveable
30−import androidx.compose.runtime.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.platform.LocalContext
34−import androidx.compose.ui.res.stringResource
35−import androidx.compose.ui.unit.dp
36−import androidx.core.content.ContextCompat
37−import androidx.fragment.app.FragmentActivity
38−import androidx.lifecycle.Lifecycle
39−import androidx.lifecycle.LifecycleEventObserver
40−import androidx.lifecycle.compose.LocalLifecycleOwner
41−import soy.iko.opencode.R
42−
43−/**
44− * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
45− * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
46− * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
47− */
48−private val lockAuthenticators: Int
49− get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
50− BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
51− } else {
52− BiometricManager.Authenticators.BIOMETRIC_STRONG
53− }
54−
55−/** Whether device authentication is currently possible, so Settings can gate the toggle. */
56−fun canAuthenticateForAppLock(context: Context): Boolean =
57− BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
58−
59−/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
60−private fun Context.findFragmentActivity(): FragmentActivity? {
61− var ctx: Context? = this
62− while (ctx is ContextWrapper) {
63− if (ctx is FragmentActivity) return ctx
64− ctx = ctx.baseContext
65− }
66− return null
67−}
68−
69−/**
70− * Gates [content] behind device authentication when [enabled]. The content stays composed
71− * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
72− * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
73− * device can't authenticate, the gate is a no-op — the setting is only offered when
74− * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
75− */
76−@Composable
77−fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
78− if (!enabled) {
79− content()
80− return
81− }
82− val context = LocalContext.current
83− val activity = remember(context) { context.findFragmentActivity() }
84− if (activity == null) {
85− content()
86− return
87− }
88−
89− // Survives config changes / process-death restore so a rotation doesn't re-prompt, but
90− // defaults to locked on a fresh start.
91− var unlocked by rememberSaveable { mutableStateOf(false) }
92−
93− // Re-lock when the app is backgrounded so returning to it requires authentication again.
94− val lifecycleOwner = LocalLifecycleOwner.current
95− DisposableEffect(lifecycleOwner) {
96− val observer = LifecycleEventObserver { _, event ->
97− if (event == Lifecycle.Event.ON_STOP) unlocked = false
98− }
99− lifecycleOwner.lifecycle.addObserver(observer)
100− onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
101− }
102−
103− val title = stringResource(R.string.app_lock_prompt_title)
104− val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
105−
106− // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
107− // BiometricPrompts, which throws.
108− var authInFlight by remember { mutableStateOf(false) }
109−
110− fun authenticate() {
111− if (authInFlight || unlocked) return
112− authInFlight = true
113− val executor = ContextCompat.getMainExecutor(activity)
114− val callback = object : BiometricPrompt.AuthenticationCallback() {
115− override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
116− authInFlight = false
117− unlocked = true
118− }
119−
120− override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
121− // User cancelled / lockout / hardware error: stay locked and let them retry
122− // via the button rather than looping the system prompt.
123− authInFlight = false
124− }
125−
126− override fun onAuthenticationFailed() {
127− // A single non-matching attempt; the system UI lets the user try again.
128− }
129− }
130− val info = BiometricPrompt.PromptInfo.Builder()
131− .setTitle(title)
132− .setSubtitle(subtitle)
133− .setAllowedAuthenticators(lockAuthenticators)
134− .build()
135− runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
136− .onFailure { authInFlight = false }
137− }
138−
139− // Auto-prompt on first show and whenever we return to the locked state.
140− LaunchedEffect(unlocked) { if (!unlocked) authenticate() }
141−
142− Box(modifier = Modifier.fillMaxSize()) {
143− content()
144− if (!unlocked) {
145− // Opaque overlay covering the content. The no-op clickable swallows touches so
146− // the hidden UI beneath can't be interacted with while locked.
147− val interaction = remember { MutableInteractionSource() }
148− Surface(
149− modifier = Modifier
150− .fillMaxSize()
151− .clickable(interactionSource = interaction, indication = null) {},
152− color = MaterialTheme.colorScheme.background,
153− ) {
154− Column(
155− modifier = Modifier.fillMaxSize().padding(32.dp),
156− horizontalAlignment = Alignment.CenterHorizontally,
157− verticalArrangement = Arrangement.Center,
158− ) {
159− Icon(
160− Icons.Filled.Lock,
161− contentDescription = null,
162− modifier = Modifier.size(48.dp),
163− tint = MaterialTheme.colorScheme.onSurfaceVariant,
164− )
165− Text(
166− stringResource(R.string.app_locked),
167− style = MaterialTheme.typography.titleMedium,
168− modifier = Modifier.padding(top = 16.dp),
169− )
170− Button(
171− onClick = { authenticate() },
172− enabled = !authInFlight,
173− modifier = Modifier.padding(top = 24.dp),
174− ) {
175− Text(stringResource(R.string.app_unlock))
176− }
177− }
178− }
179− }
180− }
181−}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+98−284
@@ -1,310 +1,124 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3−import androidx.compose.animation.core.tween
3+import androidx.activity.compose.BackHandler
4−import androidx.compose.animation.fadeIn
5−import androidx.compose.animation.fadeOut
4+import androidx.compose.animation.AnimatedContent
6import androidx.compose.animation.slideInHorizontally
5import androidx.compose.animation.slideInHorizontally
7import androidx.compose.animation.slideOutHorizontally
6import androidx.compose.animation.slideOutHorizontally
8−import androidx.compose.foundation.layout.BoxWithConstraints
7+import androidx.compose.animation.togetherWith
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
13+import androidx.compose.material3.SnackbarHost
14+import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
9import androidx.compose.runtime.Composable
17import androidx.compose.runtime.Composable
10−import androidx.compose.runtime.CompositionLocalProvider
11import androidx.compose.runtime.LaunchedEffect
18import androidx.compose.runtime.LaunchedEffect
12import androidx.compose.runtime.getValue
19import androidx.compose.runtime.getValue
13import androidx.compose.runtime.mutableStateOf
20import androidx.compose.runtime.mutableStateOf
14import androidx.compose.runtime.remember
21import androidx.compose.runtime.remember
15−import androidx.compose.runtime.rememberCoroutineScope
16import androidx.compose.runtime.setValue
22import androidx.compose.runtime.setValue
17−import androidx.compose.ui.unit.dp
23+import androidx.compose.ui.Modifier
18−import androidx.navigation.NavType
24+import soy.iko.opencode.core.Event
19−import androidx.navigation.compose.NavHost
20−import androidx.navigation.compose.composable
21−import androidx.navigation.compose.rememberNavController
22−import androidx.navigation.navArgument
23−import androidx.lifecycle.compose.collectAsStateWithLifecycle
24−import kotlinx.coroutines.launch
25−import soy.iko.opencode.di.AppContainer
25+import soy.iko.opencode.core.Screen
26−import soy.iko.opencode.data.network.NetworkConfig
26+import soy.iko.opencode.core.UiState
27−import soy.iko.opencode.ui.chat.ChatScreen
27+import soy.iko.opencode.ui.screens.ChatScreen
28−import soy.iko.opencode.ui.file.FileBrowserScreen
28+import soy.iko.opencode.ui.screens.ConnectScreen
29−import soy.iko.opencode.ui.file.FileViewScreen
30−import soy.iko.opencode.ui.server.ServerEditScreen
31−import soy.iko.opencode.ui.server.ServerListScreen
32−import soy.iko.opencode.ui.search.GlobalSearchScreen
33−import soy.iko.opencode.ui.components.LocalChatTextScale
34−import soy.iko.opencode.ui.components.LocalCodeWrap
29+import soy.iko.opencode.ui.screens.SessionsScreen
35−import soy.iko.opencode.ui.session.SessionListScreen
36−import soy.iko.opencode.ui.session.TwoPaneSessionChat
37−import soy.iko.opencode.ui.settings.DiagnosticsScreen
38−import soy.iko.opencode.ui.settings.SettingsScreen
39−import soy.iko.opencode.ui.usage.UsageScreen
40−import soy.iko.opencode.ui.mcp.McpScreen
41−import soy.iko.opencode.util.runCatchingCancellable
42
30
43@Composable
31@Composable
44−fun OpencodeApp(container: AppContainer) {
32+fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
45− val navController = rememberNavController()
33+ val snackbar = remember { SnackbarHostState() }
46− val scope = rememberCoroutineScope()
34+ var showStopConfirm by remember { mutableStateOf(false) }
47− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
35+ var showDisconnectConfirm by remember { mutableStateOf(false) }
48− val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
36+
49− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
50− val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
51− val connection by container.activeConnection.collectAsStateWithLifecycle()
52− // Chat presentation preferences, provided to the whole nav graph so the markdown/code
53− // renderers honor the user's text-size and code-wrap choices everywhere.
54− val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
55− val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
56−
57− // Hold a foreground priority for as long as ANY session is actively running — not just
58− // while a running chat is on screen — so a run started and then backgrounded (or left
59− // for the session list) still keeps the SSE stream alive to deliver its completion
60− // notification. Doze/app-standby would otherwise choke the socket once priority drops.
61− val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
62− val appContext = androidx.compose.ui.platform.LocalContext.current.applicationContext
63− LaunchedEffect(anyRunActive) {
37+ LaunchedEffect(state.error) {
64− if (anyRunActive) {
38+ state.error?.let {
65− soy.iko.opencode.notification.RunForegroundService.start(appContext)
39+ snackbar.showSnackbar(
66− } else {
40+ message = it,
67− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
41+ actionLabel = "Dismiss",
42+ duration = SnackbarDuration.Short,
43+ )
44+ // Always clear the error from state after showing.
45+ dispatch(Event.DismissError)
68 }
46 }
69 }
47 }
70
48
71− // When text is shared into the app, surface the session list so the user can pick
49+ BackHandler(enabled = state.screen != Screen.Connect) {
72− // (or create) a conversation to drop it into. The chosen session's draft is set
50+ when (state.screen) {
73− // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
51+ // Leaving mid-generation would silently drop the reply, so confirm first.
74− // before auto-reconnect completes is retried once the connection is established.
52+ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
75− // Tracks the last connection id seen so a server switch (a non-null -> non-null
53+ // Disconnecting drops the server session, so guard the back button.
76− // change) doesn't re-bounce the user to the session list for a share they've
54+ Screen.Sessions -> showDisconnectConfirm = true
77− // already decided to abandon; only the null -> non-null initial connect retries.
55+ Screen.Connect -> Unit
78− var lastConnectionId by remember { mutableStateOf<String?>(null) }
79− LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
80− if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
81− if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
82− val currentId = connection?.profile?.id
83− // Only (re)navigate on the initial null -> non-null connect. A subsequent
84− // server switch while a share is pending (rare) leaves the user where they
85− // are instead of yanking them back to the session list.
86− if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
87− lastConnectionId = currentId
88− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
89− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
90 }
56 }
91 }
57 }
92
58
93− // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
59+ if (showStopConfirm) {
94− // session and open it. Consumed once (compareAndSet) so a recomposition can't re-create.
60+ AlertDialog(
95− LaunchedEffect(pendingNewSession, connection) {
61+ onDismissRequest = { showStopConfirm = false },
96− if (!pendingNewSession) return@LaunchedEffect
62+ title = { Text("Stop generation?") },
97− val conn = connection ?: return@LaunchedEffect
63+ text = { Text("Leaving will stop the current generation.") },
98− if (!container.consumePendingNewSession()) return@LaunchedEffect
64+ confirmButton = {
99− val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
65+ TextButton(onClick = {
100− ?: return@LaunchedEffect
66+ showStopConfirm = false
101− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
67+ dispatch(Event.CancelGeneration)
102− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
68+ dispatch(Event.NavigateToSessions)
69+ }) {
70+ Text("Stop and leave")
71+ }
72+ },
73+ dismissButton = {
74+ TextButton(onClick = { showStopConfirm = false }) {
75+ Text("Stay")
76+ }
103− }
77+ },
104− navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
78+ )
105 }
79 }
106
80
107− CompositionLocalProvider(
81+ if (showDisconnectConfirm) {
108− LocalChatTextScale provides chatTextScale,
82+ AlertDialog(
109− LocalCodeWrap provides codeWrap,
83+ onDismissRequest = { showDisconnectConfirm = false },
110− ) {
84+ title = { Text("Disconnect?") },
111− // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
85+ text = { Text("You will be disconnected from the server.") },
112− // the open conversation side by side instead of a single-pane back stack.
86+ confirmButton = {
113− BoxWithConstraints {
87+ TextButton(onClick = {
114− val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
88+ showDisconnectConfirm = false
115−
89+ dispatch(Event.NavigateToConnect)
116− // Open a session requested by a notification tap or deep link, once connected.
90+ }) {
117− LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
91+ Text("Disconnect")
118− val id = pendingOpenSession ?: return@LaunchedEffect
119− if (connection == null) return@LaunchedEffect
120− // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
121− // route) consumes the request and opens it in the detail pane. But it only
122− // exists while the NavHost is on SESSIONS, so if the user is currently on
123− // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
124− // pending request is never consumed and the session never opens. Don't consume
125− // here; let TwoPaneSessionChat's effect do it once it's composed.
126− if (isTwoPane) {
127− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
128− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
129 }
92 }
130− return@LaunchedEffect
131− }
93+ },
132− container.consumePendingOpenSession()
94+ dismissButton = {
133− // Ensure SESSIONS sits on the back stack below the chat destination so backing
95+ TextButton(onClick = { showDisconnectConfirm = false }) {
134− // out of the chat lands on the session list (not the server list). Bring SESSIONS
96+ Text("Cancel")
135− // to front (or push it if a cold-start deep link left only SERVERS on the stack),
97+ }
136− // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
137− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
138− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
139− }
140− navController.navigate(Routes.chat(id)) { launchSingleTop = true }
141− }
142−
143− NavHost(
144− navController = navController,
145− startDestination = Routes.SERVERS,
146− // Slide horizontally for forward/back navigation to feel native; fade for
147− // the root so the first frame doesn't slide in from off-screen.
148− enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
149− exitTransition = { fadeOut(tween(180)) },
150− popEnterTransition = { fadeIn(tween(180)) },
151− popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
152− ) {
153−
154− composable(Routes.SERVERS) {
155− ServerListScreen(
156− container = container,
157− onConnected = {
158− // Only leave the server list if we're still on it. A pending deep-link /
159− // notification open sets activeConnection (which drives the open-session
160− // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
161− // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
162− // on top of the chat it just opened, burying it. Order-independent: if the
163− // deep link already navigated away from SERVERS, this is a no-op.
164− if (navController.currentDestination?.route == Routes.SERVERS) {
165− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
166− }
167− },
168− onAddProfile = { navController.navigate(Routes.serverEdit()) },
169− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
170− onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
171− )
172− }
173−
174− composable(
175− route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
176− arguments = listOf(
177− navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
178− navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
179− ),
98+ },
180− ) { entry ->
99+ )
181− ServerEditScreen(
182− container = container,
183− profileId = entry.arguments?.getString("id"),
184− sourceId = entry.arguments?.getString("dup"),
185− onDone = { navController.popBackStack() },
186− )
187− }
100+ }
188
101
189− composable(Routes.SESSIONS) {
102+ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
190− if (isTwoPane) {
103+ AnimatedContent(
191− TwoPaneSessionChat(
104+ targetState = state.screen,
192− container = container,
105+ modifier = Modifier.fillMaxSize().padding(padding),
193− onOpenFiles = { navController.navigate(Routes.FILES) },
106+ transitionSpec = {
194− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
107+ // Compare screen ordinals to decide slide direction: forward navigation
195− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
108+ // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
196− onOpenSearch = { navController.navigate(Routes.SEARCH) },
109+ if (targetState.ordinal > initialState.ordinal) {
197− onDisconnect = {
110+ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
198− scope.launch { runCatchingCancellable { container.disconnect() } }
199− navController.popBackStack(Routes.SERVERS, inclusive = false)
200− },
201− onAddServer = { navController.navigate(Routes.serverEdit()) },
202− )
203− } else {
111+ } else {
204− SessionListScreen(
112+ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
205− container = container,
113+ }
206− onOpenSession = { id ->
114+ },
207− // Set the draft synchronously in-memory before navigating so the
115+ label = "screen",
208− // ChatScreen sees it on first composition. The async persistence
116+ ) { screen ->
209− // to disk happens in the background via draftStore.set.
117+ when (screen) {
210− container.consumePendingShare()?.let { shareText ->
118+ Screen.Connect -> ConnectScreen(state, dispatch)
211− container.draftStore.setImmediate(id, shareText)
119+ Screen.Sessions -> SessionsScreen(state, dispatch)
212− scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
120+ Screen.Chat -> ChatScreen(state, dispatch)
213− }
214− navController.navigate(Routes.chat(id))
215− },
216− onDisconnect = {
217− scope.launch { runCatchingCancellable { container.disconnect() } }
218− navController.popBackStack(Routes.SERVERS, inclusive = false)
219− },
220− onOpenFiles = { navController.navigate(Routes.FILES) },
221− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
222− onOpenSearch = { navController.navigate(Routes.SEARCH) },
223− onAddServer = { navController.navigate(Routes.serverEdit()) },
224− )
225 }
121 }
226 }
122 }
227−
228− composable(
229− route = "${Routes.CHAT}/{sessionId}",
230− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
231− ) { entry ->
232− ChatScreen(
233− container = container,
234− sessionId = entry.arguments?.getString("sessionId").orEmpty(),
235− onBack = { navController.popBackStack() },
236− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
237− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
238− )
239− }
240−
241− composable(Routes.FILES) {
242− FileBrowserScreen(
243− container = container,
244− onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
245− onBack = { navController.popBackStack() },
246− )
247− }
248−
249− composable(
250− route = "${Routes.FILE_VIEW}?path={path}&line={line}",
251− arguments = listOf(
252− navArgument("path") { type = NavType.StringType; defaultValue = "" },
253− navArgument("line") { type = NavType.IntType; defaultValue = -1 },
254− ),
255− ) { entry ->
256− val line = entry.arguments?.getInt("line") ?: -1
257− FileViewScreen(
258− container = container,
259− path = entry.arguments?.getString("path").orEmpty(),
260− initialLine = line.takeIf { it > 0 },
261− onBack = { navController.popBackStack() },
262− )
263− }
264−
265− composable(Routes.SETTINGS) {
266− SettingsScreen(
267− container = container,
268− onBack = { navController.popBackStack() },
269− // Navigate to the server list as a new entry on top of Sessions so the
270− // user can back out to their conversations. Previously this popped back
271− // to the root SERVERS destination, which removed Sessions from the stack
272− // and left the user unable to return to their conversations without
273− // reconnecting. Pop only Settings, then push SERVERS; back from the
274− // server list lands on Sessions (the screen the user was on before
275− // Settings), and connecting pushes a fresh SESSIONS as usual.
276− onManageServers = {
277− navController.navigate(Routes.SERVERS) {
278− popUpTo(Routes.SETTINGS) { inclusive = true }
279− launchSingleTop = true
280− }
281− },
282− onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
283− onOpenUsage = { navController.navigate(Routes.USAGE) },
284− onOpenMcp = { navController.navigate(Routes.MCP) },
285− )
286− }
287−
288− composable(Routes.DIAGNOSTICS) {
289− DiagnosticsScreen(onBack = { navController.popBackStack() })
290− }
291−
292− composable(Routes.USAGE) {
293− UsageScreen(container = container, onBack = { navController.popBackStack() })
294− }
295−
296− composable(Routes.MCP) {
297− McpScreen(container = container, onBack = { navController.popBackStack() })
298− }
299−
300− composable(Routes.SEARCH) {
301− GlobalSearchScreen(
302− container = container,
303− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
304− onBack = { navController.popBackStack() },
305− )
306− }
307− }
308− }
309 }
123 }
310}
124}
app/src/main/java/soy/iko/opencode/ui/Routes.ktdeleted−30
@@ -1,30 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−/** String routes for the app's NavHost. Kept simple and centralized. */
4−object Routes {
5− const val SERVERS = "servers"
6− const val SESSIONS = "sessions"
7− const val SETTINGS = "settings"
8− const val DIAGNOSTICS = "diagnostics"
9− const val USAGE = "usage"
10− const val MCP = "mcp"
11− const val FILES = "files"
12− const val SEARCH = "search"
13−
14− const val SERVER_EDIT = "server_edit"
15− fun serverEdit(id: String? = null) =
16− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
17−
18− /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
19− fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
20−
21− const val CHAT = "chat"
22− fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
23−
24− const val FILE_VIEW = "file_view"
25− fun fileView(path: String, line: Int? = null): String {
26− val base = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
27− // A positive [line] scrolls the viewer to that 1-based line (from a search hit).
28− return if (line != null && line > 0) "$base&line=$line" else base
29− }
30−}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktdeleted−16
@@ -1,16 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewmodel.CreationExtras
5−import androidx.lifecycle.viewmodel.initializer
6−import androidx.lifecycle.viewmodel.viewModelFactory
7−
8−/**
9− * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10− * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11− */
12−inline fun <reified VM : ViewModel> vmFactory(
13− crossinline create: (CreationExtras) -> VM,
14−) = viewModelFactory {
15− initializer { create(this) }
16−}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktdeleted−213
@@ -1,213 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.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/AttachmentResult.ktdeleted−12
@@ -1,12 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−/** Outcome of turning a picked Uri into a [PendingAttachment] (see `Uri.toAttachmentResult`). */
4−sealed interface AttachmentResult {
5− data class Ok(val attachment: PendingAttachment) : AttachmentResult
6−
7− /** The file exceeded the size cap ([soy.iko.opencode.data.network.NetworkConfig.maxAttachmentBytes]). */
8− data object TooLarge : AttachmentResult
9−
10− /** The file couldn't be read/resolved. */
11− data object Failed : AttachmentResult
12−}
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.ktdeleted−62
@@ -1,62 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import android.content.Context
4−import android.net.Uri
5−import android.provider.OpenableColumns
6−import android.util.Base64
7−import androidx.core.content.FileProvider
8−import soy.iko.opencode.data.model.FilePromptPart
9−import soy.iko.opencode.data.network.NetworkConfig
10−import kotlinx.coroutines.Dispatchers
11−import kotlinx.coroutines.withContext
12−import java.io.File
13−
14−/**
15− * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
16− * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
17− * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
18− */
19−suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
20− val resolver = context.contentResolver
21− val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
22− val name = displayName(context) ?: defaultName(mime)
23− val bytes = runCatching {
24− resolver.openInputStream(this@toAttachmentResult)?.use { it.readBytes() }
25− }.getOrNull() ?: return@withContext AttachmentResult.Failed
26− if (bytes.size > NetworkConfig.maxAttachmentBytes) return@withContext AttachmentResult.TooLarge
27− val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
28− val dataUrl = "data:$mime;base64,$b64"
29− AttachmentResult.Ok(
30− PendingAttachment(
31− id = java.util.UUID.randomUUID().toString(),
32− name = name,
33− mime = mime,
34− previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
35− part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
36− ),
37− )
38−}
39−
40−/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
41−private fun Uri.displayName(context: Context): String? = runCatching {
42− context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
43− val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
44− if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
45− }
46−}.getOrNull()?.takeIf { it.isNotBlank() }
47−
48−private fun defaultName(mime: String): String {
49− val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
50− return "attachment.$ext"
51−}
52−
53−/**
54− * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
55− * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
56− * created. The file is overwritten on each capture; the OS cleans the cache dir.
57− */
58−fun newCameraCaptureUri(context: Context): Uri? = runCatching {
59− val dir = File(context.cacheDir, "captures").apply { mkdirs() }
60− val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
61− FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
62−}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktdeleted−92
@@ -1,92 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Row
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.padding
7−import androidx.compose.foundation.layout.size
8−import androidx.compose.foundation.layout.widthIn
9−import androidx.compose.foundation.horizontalScroll
10−import androidx.compose.foundation.rememberScrollState
11−import androidx.compose.material.icons.Icons
12−import androidx.compose.material.icons.filled.Close
13−import androidx.compose.material.icons.filled.Description
14−import androidx.compose.material3.Icon
15−import androidx.compose.material3.IconButton
16−import androidx.compose.material3.MaterialTheme
17−import androidx.compose.material3.Surface
18−import androidx.compose.material3.Text
19−import androidx.compose.runtime.Composable
20−import androidx.compose.ui.Alignment
21−import androidx.compose.ui.Modifier
22−import androidx.compose.ui.draw.clip
23−import androidx.compose.ui.layout.ContentScale
24−import androidx.compose.ui.res.stringResource
25−import androidx.compose.ui.text.style.TextOverflow
26−import androidx.compose.ui.unit.dp
27−import coil.compose.AsyncImage
28−import soy.iko.opencode.R
29−
30−/**
31− * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
32− * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
33− */
34−@Composable
35−internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (String) -> Unit) {
36− if (attachments.isEmpty()) return
37− // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
38− Row(
39− modifier = Modifier
40− .fillMaxWidth()
41− .horizontalScroll(rememberScrollState())
42− .padding(start = 8.dp, end = 8.dp, top = 8.dp),
43− horizontalArrangement = Arrangement.spacedBy(8.dp),
44− ) {
45− attachments.forEach { att ->
46− AttachmentChip(att, onRemove = { onRemove(att.id) })
47− }
48− }
49−}
50−
51−/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
52− * and a remove button. */
53−@Composable
54−private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
55− Surface(
56− shape = MaterialTheme.shapes.small,
57− color = MaterialTheme.colorScheme.surfaceVariant,
58− tonalElevation = 1.dp,
59− ) {
60− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
61− if (attachment.previewModel != null) {
62− AsyncImage(
63− model = attachment.previewModel,
64− contentDescription = attachment.name,
65− contentScale = ContentScale.Crop,
66− modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
67− )
68− } else {
69− Icon(
70− Icons.Filled.Description,
71− contentDescription = null,
72− modifier = Modifier.size(24.dp),
73− tint = MaterialTheme.colorScheme.onSurfaceVariant,
74− )
75− }
76− Text(
77− attachment.name,
78− style = MaterialTheme.typography.labelMedium,
79− maxLines = 1,
80− overflow = TextOverflow.Ellipsis,
81− modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
82− )
83− IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
84− Icon(
85− Icons.Filled.Close,
86− contentDescription = stringResource(R.string.remove),
87− modifier = Modifier.size(16.dp),
88− )
89− }
90− }
91− }
92−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktdeleted−1654
@@ -1,1654 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import android.app.Activity
4−import android.content.Intent
5−import android.net.Uri
6−import android.speech.RecognizerIntent
7−import androidx.activity.compose.BackHandler
8−import androidx.activity.compose.rememberLauncherForActivityResult
9−import androidx.activity.result.PickVisualMediaRequest
10−import androidx.activity.result.contract.ActivityResultContracts
11−import androidx.compose.animation.AnimatedVisibility
12−import androidx.compose.foundation.clickable
13−import androidx.compose.foundation.layout.Arrangement
14−import androidx.compose.foundation.layout.Box
15−import androidx.compose.foundation.layout.Column
16−import androidx.compose.foundation.layout.FlowRow
17−import androidx.compose.foundation.layout.PaddingValues
18−import androidx.compose.foundation.layout.Row
19−import androidx.compose.foundation.layout.Spacer
20−import androidx.compose.foundation.layout.WindowInsets
21−import androidx.compose.foundation.layout.fillMaxSize
22−import androidx.compose.foundation.layout.fillMaxWidth
23−import androidx.compose.foundation.layout.imePadding
24−import androidx.compose.foundation.layout.navigationBars
25−import androidx.compose.foundation.layout.padding
26−import androidx.compose.foundation.layout.size
27−import androidx.compose.foundation.layout.union
28−import androidx.compose.foundation.layout.windowInsetsPadding
29−import androidx.compose.foundation.lazy.LazyColumn
30−import androidx.compose.foundation.lazy.items
31−import androidx.compose.foundation.lazy.rememberLazyListState
32−import androidx.compose.material.icons.Icons
33−import androidx.compose.material.icons.automirrored.filled.ArrowBack
34−import androidx.compose.material.icons.automirrored.filled.Send
35−import androidx.compose.material.icons.filled.Add
36−import androidx.compose.material.icons.filled.AutoAwesome
37−import androidx.compose.material.icons.filled.Delete
38−import androidx.compose.material.icons.filled.Description
39−import androidx.compose.material.icons.filled.Edit
40−import androidx.compose.material.icons.filled.KeyboardArrowDown
41−import androidx.compose.material.icons.filled.Mic
42−import androidx.compose.material.icons.filled.MoreVert
43−import androidx.compose.material.icons.filled.PhotoCamera
44−import androidx.compose.material.icons.filled.PhotoLibrary
45−import androidx.compose.material.icons.filled.Refresh
46−import androidx.compose.material.icons.filled.Restore
47−import androidx.compose.material.icons.filled.Stop
48−import androidx.compose.material3.AlertDialog
49−import androidx.compose.material3.Button
50−import androidx.compose.material3.CircularProgressIndicator
51−import androidx.compose.material3.DropdownMenu
52−import androidx.compose.material3.DropdownMenuItem
53−import androidx.compose.material3.ExperimentalMaterial3Api
54−import androidx.compose.material3.ExtendedFloatingActionButton
55−import androidx.compose.material3.FloatingActionButton
56−import androidx.compose.material3.Icon
57−import androidx.compose.material3.IconButton
58−import androidx.compose.material3.MaterialTheme
59−import androidx.compose.material3.OutlinedTextField
60−import androidx.compose.material3.Scaffold
61−import androidx.compose.material3.SnackbarHost
62−import androidx.compose.material3.SnackbarHostState
63−import androidx.compose.material3.Surface
64−import androidx.compose.material3.Text
65−import androidx.compose.material3.TextButton
66−import androidx.compose.material3.TopAppBar
67−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
68−import androidx.compose.runtime.Composable
69−import androidx.compose.runtime.CompositionLocalProvider
70−import androidx.compose.runtime.DisposableEffect
71−import androidx.compose.runtime.LaunchedEffect
72−import androidx.compose.runtime.derivedStateOf
73−import androidx.compose.runtime.getValue
74−import androidx.compose.runtime.mutableStateOf
75−import androidx.compose.runtime.remember
76−import androidx.compose.runtime.rememberCoroutineScope
77−import androidx.compose.runtime.rememberUpdatedState
78−import androidx.compose.runtime.saveable.rememberSaveable
79−import androidx.compose.runtime.setValue
80−import androidx.compose.runtime.snapshotFlow
81−import androidx.compose.foundation.text.KeyboardActions
82−import androidx.compose.foundation.text.KeyboardOptions
83−import androidx.compose.ui.text.input.KeyboardCapitalization
84−import androidx.compose.ui.Alignment
85−import androidx.compose.ui.Modifier
86−import androidx.compose.ui.focus.focusRequester
87−import androidx.compose.ui.input.key.Key
88−import androidx.compose.ui.input.key.KeyEventType
89−import androidx.compose.ui.input.key.isCtrlPressed
90−import androidx.compose.ui.input.key.isShiftPressed
91−import androidx.compose.ui.input.key.key
92−import androidx.compose.ui.input.key.onPreviewKeyEvent
93−import androidx.compose.ui.input.key.type
94−import androidx.compose.ui.platform.LocalContext
95−import androidx.compose.ui.platform.LocalHapticFeedback
96−import androidx.compose.ui.platform.LocalView
97−import androidx.compose.ui.platform.testTag
98−import androidx.compose.ui.res.stringResource
99−import androidx.compose.ui.semantics.Role
100−import androidx.compose.ui.semantics.contentDescription
101−import androidx.compose.ui.semantics.semantics
102−import androidx.compose.ui.text.input.ImeAction
103−import androidx.compose.ui.text.style.TextOverflow
104−import androidx.compose.ui.unit.dp
105−import androidx.lifecycle.compose.collectAsStateWithLifecycle
106−import androidx.lifecycle.viewmodel.compose.viewModel
107−import kotlinx.coroutines.Dispatchers
108−import kotlinx.coroutines.launch
109−import kotlinx.coroutines.withContext
110−import soy.iko.opencode.data.model.Part
111−import soy.iko.opencode.data.model.MessageWithParts
112−import soy.iko.opencode.data.model.ReasoningPart
113−import soy.iko.opencode.data.model.TextPart
114−import soy.iko.opencode.data.model.ToolCompleted
115−import soy.iko.opencode.data.model.ToolError
116−import soy.iko.opencode.data.model.ToolPart
117−import soy.iko.opencode.data.model.ToolRunning
118−import soy.iko.opencode.data.network.EventStreamClient
119−import soy.iko.opencode.data.network.NetworkConfig
120−import soy.iko.opencode.di.AppContainer
121−import soy.iko.opencode.R
122−import soy.iko.opencode.ui.components.CommandPalette
123−import soy.iko.opencode.ui.components.ConnectionBanner
124−import soy.iko.opencode.ui.components.PaletteAction
125−import soy.iko.opencode.ui.components.copyToClipboard
126−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
128−import soy.iko.opencode.ui.components.showToast
129−import soy.iko.opencode.ui.components.toImageContext
130−import soy.iko.opencode.ui.vmFactory
131−import soy.iko.opencode.util.runCatchingCancellable
132−
133−@OptIn(ExperimentalMaterial3Api::class)
134−@Composable
135−fun ChatScreen(
136− container: AppContainer,
137− sessionId: String,
138− onBack: () -> Unit,
139− onOpenFile: ((String) -> Unit)? = null,
140− onOpenSession: ((String) -> Unit)? = null,
141−) {
142− val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
143− val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
144− val running by vm.running.collectAsStateWithLifecycle()
145− val aborting by vm.aborting.collectAsStateWithLifecycle()
146− val loading by vm.loading.collectAsStateWithLifecycle()
147− val loadError by vm.loadError.collectAsStateWithLifecycle()
148− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
149− val models by vm.models.collectAsStateWithLifecycle()
150− val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
151− val modelsError by vm.modelsError.collectAsStateWithLifecycle()
152− val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
153− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
154− val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
155− val agents by vm.agents.collectAsStateWithLifecycle()
156− val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
157− val agentsError by vm.agentsError.collectAsStateWithLifecycle()
158− val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
159− val commands by vm.commands.collectAsStateWithLifecycle()
160− val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
161− val commandsError by vm.commandsError.collectAsStateWithLifecycle()
162− val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
163− val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
164− val draft by vm.draft.collectAsStateWithLifecycle()
165− val attachments by vm.attachments.collectAsStateWithLifecycle()
166− val reverted by vm.reverted.collectAsStateWithLifecycle()
167− val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
168− val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
169− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
170− val isOnline by container.isOnline.collectAsStateWithLifecycle()
171− val haptics = LocalHapticFeedback.current
172− val scope = rememberCoroutineScope()
173− val shareContext = LocalContext.current
174− val defaultShareSubject = stringResource(R.string.share_subject)
175− val sessionLabel = stringResource(R.string.session)
176− val defaultModelLabel = stringResource(R.string.default_model)
177− val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
178− buildString {
179− append(selectedModel?.modelLabel ?: defaultModelLabel)
180− selectedAgent?.let { append(" · $it") }
181− }
182− }
183−
184− // Connection profile (with auth) for rendering inline image attachments.
185− // Collect as state so a server switch recomposes the image context.
186− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
187− val imageContext = remember(activeConnection?.profile) {
188− activeConnection?.profile?.toImageContext()
189− }
190−
191− // Mark this session as read while the user is viewing it; clear on leave so new
192− // background activity can badge it again.
193− DisposableEffect(sessionId) {
194− container.setCurrentSession(sessionId)
195− onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
196− }
197−
198− // Navigate away when the session is deleted via SSE so the user isn't left
199− // on a zombie screen showing a conversation that no longer exists. Surface a
200− // toast (not a snackbar) because the screen is about to be popped — a snackbar
201− // would be torn down before the user could read it, while a toast survives.
202− // Covers both SSE-driven deletion (deleted on another device) and a user-
203− // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
204− // sets the same flag on success).
205− val deletedContext = LocalContext.current
206− LaunchedEffect(sessionDeleted) {
207− if (sessionDeleted) {
208− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
209− onBack()
210− }
211− }
212−
213− val snackbar = remember { SnackbarHostState() }
214− val retryLabel = stringResource(R.string.retry)
215− val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
216− var showModelPicker by rememberSaveable { mutableStateOf(false) }
217− var showAgentPicker by rememberSaveable { mutableStateOf(false) }
218− var showCommandPicker by rememberSaveable { mutableStateOf(false) }
219− var showExitConfirm by rememberSaveable { mutableStateOf(false) }
220− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
221− var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
222− var showRenameDialog by rememberSaveable { mutableStateOf(false) }
223− var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
224− var showShellDialog by rememberSaveable { mutableStateOf(false) }
225− var showPalette by rememberSaveable { mutableStateOf(false) }
226−
227− // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
228− // action list doesn't need a Composable context. Actions map to the same handlers the
229− // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
230− val paletteModelLabel = stringResource(R.string.palette_switch_model)
231− val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
232− val paletteCommandLabel = stringResource(R.string.palette_run_command)
233− val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
234− val paletteRenameLabel = stringResource(R.string.rename_session_chat)
235− val paletteShellLabel = stringResource(R.string.run_shell_command)
236− val paletteActions = remember(
237− paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
238− paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
239− ) {
240− buildList {
241− add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
242− add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
243− add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
244− if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
245− if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
246− add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
247− }
248− }
249−
250− // Keep the screen awake while the agent is working in *this* session. The foreground
251− // service that holds process priority during a run is managed app-wide off
252− // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
253− // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
254− // SSE stream before the completion notification fires.
255− val currentView = LocalView.current
256− val appContext = LocalContext.current.applicationContext
257− DisposableEffect(running) {
258− currentView.keepScreenOn = running
259− onDispose { currentView.keepScreenOn = false }
260− }
261−
262− LaunchedEffect(Unit) {
263− vm.errorEvents.collect { event ->
264− // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
265− // message-load failure) would silently re-submit the last prompt on tap.
266− val result = if (event.retryable) {
267− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
268− } else {
269− snackbar.showSnackbar(event.message)
270− }
271− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
272− }
273− }
274−
275− // --- Attachments, voice, and share-link plumbing ---
276− val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
277− val attachFailedMsg = stringResource(R.string.attachment_failed)
278− val attachLimitMsg = stringResource(R.string.attachment_limit)
279− val noCameraMsg = stringResource(R.string.no_camera_app)
280− val noVoiceMsg = stringResource(R.string.no_voice_app)
281− val voicePrompt = stringResource(R.string.voice_prompt)
282− val linkCopiedMsg = stringResource(R.string.link_copied)
283−
284− // Convert each picked Uri to a base64 attachment off the main thread, honoring the
285− // per-prompt count cap and surfacing per-file errors without aborting the batch.
286− fun stageUris(uris: List<Uri>) {
287− if (uris.isEmpty()) return
288− scope.launch {
289− for (uri in uris) {
290− if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
291− snackbar.showSnackbar(attachLimitMsg)
292− break
293− }
294− when (val result = uri.toAttachmentResult(appContext)) {
295− is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
296− AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
297− AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
298− }
299− }
300− }
301− }
302−
303− val photoPicker = rememberLauncherForActivityResult(
304− ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
305− ) { uris -> stageUris(uris) }
306−
307− val filePicker = rememberLauncherForActivityResult(
308− ActivityResultContracts.GetMultipleContents(),
309− ) { uris -> stageUris(uris) }
310−
311− // Uri survives a config change mid-capture so the TakePicture result still resolves.
312− var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
313− val cameraLauncher = rememberLauncherForActivityResult(
314− ActivityResultContracts.TakePicture(),
315− ) { success ->
316− val uri = pendingCaptureUri
317− pendingCaptureUri = null
318− if (success && uri != null) stageUris(listOf(uri))
319− }
320−
321− val voiceLauncher = rememberLauncherForActivityResult(
322− ActivityResultContracts.StartActivityForResult(),
323− ) { result ->
324− if (result.resultCode == Activity.RESULT_OK) {
325− val spoken = result.data
326− ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
327− ?.firstOrNull()
328− ?.takeIf { it.isNotBlank() }
329− if (spoken != null) {
330− // Append to the existing draft rather than replacing, so dictation adds to
331− // whatever the user already typed.
332− val current = vm.draft.value
333− val combined = if (current.isBlank()) spoken else "$current $spoken"
334− vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
335− }
336− }
337− }
338−
339− fun launchCamera() {
340− val uri = newCameraCaptureUri(appContext)
341− if (uri == null) {
342− scope.launch { snackbar.showSnackbar(attachFailedMsg) }
343− return
344− }
345− pendingCaptureUri = uri
346− runCatching { cameraLauncher.launch(uri) }
347− .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
348− }
349−
350− fun launchVoice() {
351− val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
352− putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
353− putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
354− }
355− runCatching { voiceLauncher.launch(intent) }
356− .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
357− }
358−
359− // A freshly-created share link is copied to the clipboard so the user can paste it right away.
360− LaunchedEffect(Unit) {
361− vm.shareLinkEvents.collect { url ->
362− copyToClipboard(shareContext, "share", url)
363− snackbar.showSnackbar(linkCopiedMsg)
364− }
365− }
366−
367− // Navigate to a session freshly branched from a message (branchFrom), once it's created.
368− val branchedMsg = stringResource(R.string.branch_created)
369− LaunchedEffect(onOpenSession) {
370− vm.branchEvents.collect { newId ->
371− showToast(shareContext, branchedMsg)
372− onOpenSession?.invoke(newId)
373− }
374− }
375−
376− // Stage images shared into the app (via the system share sheet) as attachments, once.
377− LaunchedEffect(Unit) {
378− val shared = container.consumePendingSharedMedia()
379− if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
380− }
381−
382− fun doSend() {
383− if (vm.send(draft)) {
384− // TextHandleMove (a light tick) is semantically right for a send commit;
385− // LongPress is reserved for long-press/swipe gestures.
386− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
387− }
388− }
389−
390− BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
391−
392− Scaffold(
393− // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
394− // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
395− // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
396− // typing (and the composer's own Enter-to-send handling) is untouched.
397− modifier = Modifier.onPreviewKeyEvent { ev ->
398− if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
399− when {
400− ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
401− ev.key == Key.Escape && showPalette -> { showPalette = false; true }
402− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
403− showStopConfirm = true; true
404− }
405− else -> false
406− }
407− },
408− topBar = {
409− TopAppBar(
410− title = {
411− val changeLabel = stringResource(R.string.change_model_agent)
412− Row(
413− modifier = Modifier
414− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
415− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
416− verticalAlignment = Alignment.CenterVertically,
417− ) {
418− Column(modifier = Modifier.weight(1f, fill = false)) {
419− // maxLines=1 + Ellipsis so a long session title can't wrap and
420− // push the top bar to multiple lines, shifting the whole layout.
421− Text(
422− sessionTitle ?: sessionLabel,
423− maxLines = 1,
424− overflow = TextOverflow.Ellipsis,
425− )
426− Text(
427− subtitle,
428− style = MaterialTheme.typography.labelSmall,
429− color = MaterialTheme.colorScheme.onSurfaceVariant,
430− maxLines = 1,
431− overflow = TextOverflow.Ellipsis,
432− )
433− }
434− // Visual affordance that the title opens the model picker;
435− // without it the tappable title is indistinguishable from plain
436− // text and the picker is effectively undiscoverable.
437− if (models.isNotEmpty()) {
438− Icon(
439− Icons.Filled.KeyboardArrowDown,
440− contentDescription = null,
441− tint = MaterialTheme.colorScheme.onSurfaceVariant,
442− modifier = Modifier.size(20.dp),
443− )
444− }
445− }
446− },
447− navigationIcon = {
448− IconButton(onClick = {
449− if (running) showExitConfirm = true else onBack()
450− }) {
451− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
452− }
453− },
454− actions = {
455− // At-a-glance run status in the top bar. When the user is scrolled
456− // up (jump-to-latest FAB visible), the input-bar Stop button and the
457− // trailing "working…" row are off-screen, so a top-bar spinner is the
458− // only signal the agent is still working.
459− if (running) {
460− val workingLabel = stringResource(R.string.working)
461− CircularProgressIndicator(
462− Modifier.size(18.dp).semantics { contentDescription = workingLabel },
463− strokeWidth = 2.dp,
464− )
465− }
466− // Manual refresh: forces an SSE reconnect, which re-seeds messages
467− // from REST. A recovery path when the stream silently drops and the
468− // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
469− // immediate tap feedback — the SSE reconnect may not visibly change
470− // the connection state when already Connected, so without it the tap
471− // appears to do nothing.
472− val refreshLabel = stringResource(R.string.refresh)
473− IconButton(
474− onClick = { vm.refreshMessages() },
475− enabled = activeConnection != null && !refreshing,
476− ) {
477− if (refreshing) {
478− CircularProgressIndicator(
479− Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
480− strokeWidth = 2.dp,
481− )
482− } else {
483− Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
484− }
485− }
486− // Overflow menu consolidates the less-frequent actions (share,
487− // commands, agent, model, rename, delete) so the top bar stays
488− // scannable on narrow phones — previously 5 icons crowded the bar
489− // alongside the back button and tappable title. Catalog-loading
490− // spinners stay inline (next to the overflow) so a load state is
491− // visible without opening the menu.
492− val moreLabel = stringResource(R.string.more)
493− val loadingLabel = stringResource(R.string.loading)
494− val shareLabel = stringResource(R.string.share_conversation)
495− val commandsLabel = stringResource(R.string.commands)
496− val agentLabel = stringResource(R.string.choose_agent)
497− val modelLabel = stringResource(R.string.choose_model)
498− val renameLabel = stringResource(R.string.rename_session_chat)
499− val deleteLabel = stringResource(R.string.delete_session_chat)
500− val createShareLabel = stringResource(R.string.create_share_link)
501− val copyShareLabel = stringResource(R.string.copy_share_link)
502− val stopShareLabel = stringResource(R.string.stop_sharing)
503− val summarizeLabel = stringResource(R.string.summarize_conversation)
504− val initLabel = stringResource(R.string.generate_agents_md)
505− val shellLabel = stringResource(R.string.run_shell_command)
506− Box {
507− IconButton(onClick = { showOverflowMenu = true }) {
508− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
509− }
510− DropdownMenu(
511− expanded = showOverflowMenu,
512− onDismissRequest = { showOverflowMenu = false },
513− ) {
514− DropdownMenuItem(
515− text = { Text(shareLabel) },
516− enabled = hasMessages,
517− onClick = {
518− showOverflowMenu = false
519− scope.launch {
520− val md = withContext(Dispatchers.Default) {
521− buildConversationMarkdown(vm.messages.value, sessionTitle)
522− }
523− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
524− type = "text/markdown"
525− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
526− putExtra(android.content.Intent.EXTRA_TEXT, md)
527− }
528− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
529− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
530− }
531− },
532− )
533− DropdownMenuItem(
534− text = { Text(commandsLabel) },
535− enabled = !commandsLoading || commands.isNotEmpty(),
536− onClick = { showOverflowMenu = false; showCommandPicker = true },
537− )
538− DropdownMenuItem(
539− text = { Text(agentLabel) },
540− enabled = !agentsLoading || agents.isNotEmpty(),
541− onClick = { showOverflowMenu = false; showAgentPicker = true },
542− )
543− DropdownMenuItem(
544− text = { Text(modelLabel) },
545− enabled = !modelsLoading || models.isNotEmpty(),
546− onClick = { showOverflowMenu = false; showModelPicker = true },
547− )
548− androidx.compose.material3.HorizontalDivider()
549− // Session sharing: create a public link, copy an existing one, or revoke it.
550− if (shareUrl == null) {
551− DropdownMenuItem(
552− text = { Text(createShareLabel) },
553− enabled = hasMessages,
554− onClick = { showOverflowMenu = false; vm.shareSession() },
555− )
556− } else {
557− DropdownMenuItem(
558− text = { Text(copyShareLabel) },
559− onClick = {
560− showOverflowMenu = false
561− shareUrl?.let {
562− copyToClipboard(shareContext, "share", it)
563− scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
564− }
565− },
566− )
567− DropdownMenuItem(
568− text = { Text(stopShareLabel) },
569− onClick = { showOverflowMenu = false; vm.unshareSession() },
570− )
571− }
572− DropdownMenuItem(
573− text = { Text(summarizeLabel) },
574− enabled = hasMessages && !running,
575− onClick = { showOverflowMenu = false; vm.summarize() },
576− )
577− DropdownMenuItem(
578− text = { Text(initLabel) },
579− enabled = !running,
580− onClick = { showOverflowMenu = false; vm.initProject() },
581− )
582− DropdownMenuItem(
583− text = { Text(shellLabel) },
584− enabled = !running,
585− onClick = { showOverflowMenu = false; showShellDialog = true },
586− )
587− androidx.compose.material3.HorizontalDivider()
588− DropdownMenuItem(
589− text = { Text(renameLabel) },
590− onClick = { showOverflowMenu = false; showRenameDialog = true },
591− )
592− DropdownMenuItem(
593− text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
594− onClick = { showOverflowMenu = false; showDeleteDialog = true },
595− )
596− }
597− // While a catalog is initially loading (empty list + loading flag),
598− // show a tiny inline spinner beside the overflow so the user can see
599− // a load is in progress without opening the menu. Once loaded the
600− // spinner disappears; the menu items are always enabled (tapping a
601− // picker with no items shows the "no models/agents/commands" state).
602− val catalogLoading = (commandsLoading && commands.isEmpty()) ||
603− (agentsLoading && agents.isEmpty()) ||
604− (modelsLoading && models.isEmpty())
605− if (catalogLoading) {
606− CircularProgressIndicator(
607− Modifier
608− .align(Alignment.TopEnd)
609− .size(10.dp)
610− .semantics { contentDescription = loadingLabel },
611− strokeWidth = 1.dp,
612− )
613− }
614− }
615− },
616− )
617− },
618− snackbarHost = { SnackbarHost(snackbar) },
619− bottomBar = {
620− val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
621− val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
622− val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
623− val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
624− Column {
625− // Pinned above the composer so the agent's plan and progress stay visible
626− // while scrolled up mid-run; renders nothing when there's no plan.
627− TodoPlanBar(todoPlan)
628− AnimatedVisibility(visible = reverted) {
629− RevertBanner(onUndo = { vm.unrevert() })
630− }
631− AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
632− OutboxBanner(
633− count = queuedOffline.size,
634− sending = outboxSending,
635− onFlush = { vm.flushQueued() },
636− onDiscard = { vm.discardAllQueued() },
637− )
638− }
639− ChatInputBar(
640− value = draft,
641− onValueChange = vm::updateDraft,
642− running = running,
643− aborting = aborting,
644− enabled = activeConnection != null,
645− sendOnEnter = sendOnEnter,
646− onSend = ::doSend,
647− onAbort = { showStopConfirm = true },
648− queuedFollowUp = queuedFollowUp,
649− onQueueFollowUp = vm::queueFollowUp,
650− onCancelQueue = { vm.queueFollowUp("") },
651− focusRequester = inputFocusRequester,
652− attachments = attachments,
653− onRemoveAttachment = vm::removeAttachment,
654− onPickPhoto = {
655− photoPicker.launch(
656− PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
657− )
658− },
659− onPickFile = { filePicker.launch("*/*") },
660− onCamera = { launchCamera() },
661− onVoice = { launchVoice() },
662− )
663− }
664− },
665− ) { padding ->
666− // Collect messages inside the content lambda so streaming token updates
667− // recompose only this subtree, not the top bar / input bar / 20+ other
668− // state reads in the ChatScreen body.
669− val messages by vm.messages.collectAsStateWithLifecycle()
670− val listState = rememberLazyListState()
671− val contentScope = rememberCoroutineScope()
672− val timeTick = rememberRelativeTimeTick()
673− // Group messages by day and interleave date separators so a long conversation
674− // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
675− // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
676− // outside the PullToRefreshBox so the auto-scroll effects below can reference
677− // the same list the LazyColumn renders (separators shift the indices vs. the
678− // raw messages list, so scroll targets must be in listItems space).
679− val todayLabel = stringResource(R.string.today)
680− val yesterdayLabel = stringResource(R.string.yesterday)
681− val listItems = remember(messages, todayLabel, yesterdayLabel) {
682− buildMessageListItems(messages, todayLabel, yesterdayLabel)
683− }
684− // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
685− // derivedStateOf and snapshotFlow lambdas below are created once (keyless
686− // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
687− // would capture the first composition's list — which is empty, since `messages`
688− // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
689− // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
690− // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
691− // whose value tracks the latest list.
692− val currentListItems by rememberUpdatedState(listItems)
693−
694− val isPinnedToBottom by remember {
695− derivedStateOf {
696− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
697− // When a run is active, the LazyColumn has one trailing "__typing"
698− // item after the last list item. Account for it so "pinned to bottom"
699− // includes the working indicator — otherwise the pin check targets
700− // listItems.lastIndex, the typing row sits just below the viewport,
701− // and the user never sees the progress indicator even when pinned.
702− val items = currentListItems
703− val effectiveLast = if (running) items.size else items.lastIndex
704− lastVisible >= effectiveLast || items.isEmpty()
705− }
706− }
707−
708− // Jump to the newest message once, when the conversation first loads. Guarded by a
709− // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
710− // (rotation) — rememberLazyListState restores the user's scroll offset, and an
711− // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
712− var didInitialScroll by rememberSaveable { mutableStateOf(false) }
713− LaunchedEffect(listItems.isNotEmpty()) {
714− if (listItems.isNotEmpty() && !didInitialScroll) {
715− // When a run is active the LazyColumn has a trailing "__typing" row at
716− // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
717− // running session lands truly pinned to bottom and streaming auto-scroll engages.
718− listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
719− didInitialScroll = true
720− }
721− }
722−
723− // When a run starts, the LazyColumn gains a trailing "__typing" row, so
724− // effectiveLast jumps from listItems.lastIndex to listItems.size and the
725− // isPinnedToBottom check flips to false the instant you send — freezing
726− // auto-scroll. If the user was pinned to the last item just before the run
727− // began, bring the typing row into view so the pin (and streaming follow) is
728− // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
729− // recomputed to false by now) so we don't scroll when the user had scrolled up.
730− LaunchedEffect(running) {
731− if (running && listItems.isNotEmpty()) {
732− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
733− if (lastVisible >= listItems.lastIndex) {
734− runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
735− }
736− }
737− }
738−
739− LaunchedEffect(Unit) {
740− snapshotFlow {
741− // Track list size + the streaming length of the last part so we auto-scroll
742− // as content arrives, plus the pinned flag. A small data class with primitive
743− // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
744− // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
745− // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
746− // and tool output — not just TextPart — keeps a pinned view following a long
747− // reasoning/tool block while it streams (previously it stalled at 0).
748− val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
749− AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
750− }.collect { signal ->
751− if (signal.size > 0 && signal.pinned) {
752− // Scroll to the effective last index, including the trailing
753− // "__typing" row when a run is active so the working indicator
754− // is brought into view (not just the last message).
755− val items = currentListItems
756− val target = if (running) items.size else items.lastIndex
757− listState.scrollToItem(target)
758− }
759− }
760− }
761−
762− CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
763− // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
764− // content padding accounts for the bottomBar's raised height, so the message
765− // list is already above the keyboard. Adding imePadding here would double-apply
766− // the IME inset and push messages too far up.
767− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
768− if (activeConnection == null) {
769− Column(
770− modifier = Modifier.align(Alignment.Center).padding(24.dp),
771− horizontalAlignment = Alignment.CenterHorizontally,
772− ) {
773− Text(
774− stringResource(R.string.not_connected),
775− color = MaterialTheme.colorScheme.error,
776− )
777− Spacer(Modifier.size(12.dp))
778− Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
779− if (reconnecting) {
780− CircularProgressIndicator(
781− Modifier.size(20.dp),
782− color = MaterialTheme.colorScheme.onPrimary,
783− strokeWidth = 2.dp,
784− )
785− } else {
786− Text(stringResource(R.string.reconnect))
787− }
788− }
789− }
790− } else {
791− ConnectionBanner(
792− state = connectionState,
793− modifier = Modifier.align(Alignment.TopCenter),
794− isOnline = isOnline,
795− // On a hard failure, retry by forcing an SSE reconnect (which
796− // re-seeds from REST). refreshMessages is the right recovery path
797− // when the connection is present but the stream died; reconnect()
798− // is the path when the whole connection is gone (handled by the
799− // separate "Not connected" state below).
800− onRetry = { vm.refreshMessages() },
801− )
802− if (loading && messages.isEmpty()) {
803− val loadingLabel = stringResource(R.string.loading)
804− CircularProgressIndicator(
805− Modifier
806− .align(Alignment.Center)
807− .semantics { contentDescription = loadingLabel },
808− )
809− } else if (loadError && messages.isEmpty()) {
810− // A failed load with nothing to show. Distinct from the empty
811− // conversation state below — the conversation isn't empty, it
812− // failed to load, and offering "Start a conversation" here is
813− // misleading. Offer a Retry instead, mirroring SessionListScreen.
814− Column(
815− modifier = Modifier.align(Alignment.Center).padding(24.dp),
816− horizontalAlignment = Alignment.CenterHorizontally,
817− ) {
818− Text(
819− stringResource(R.string.load_messages_failed),
820− color = MaterialTheme.colorScheme.error,
821− )
822− Spacer(Modifier.size(12.dp))
823− Button(onClick = { vm.refreshMessages() }) {
824− Text(stringResource(R.string.retry))
825− }
826− }
827− } else if (messages.isEmpty() && running) {
828− // A run just started but no parts have arrived yet — the empty
829− // list with only the trailing "working…" row looks broken, so
830− // surface a clear starting state until the first part streams in.
831− val workingText = stringResource(R.string.working)
832− Column(
833− modifier = Modifier.align(Alignment.Center).padding(24.dp),
834− horizontalAlignment = Alignment.CenterHorizontally,
835− ) {
836− CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
837− Spacer(Modifier.size(12.dp))
838− Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
839− }
840− } else if (messages.isEmpty()) {
841− EmptyConversation(
842− onSuggestion = {
843− vm.updateDraft(it)
844− // Focus the input so the user can edit the suggestion before
845− // sending, instead of having to manually tap the field.
846− runCatching { inputFocusRequester.requestFocus() }
847− },
848− modifier = Modifier.align(Alignment.Center),
849− )
850− } else {
851− // Pull-to-refresh wraps the message list so the user can force an SSE
852− // reconnect + re-seed with the same gesture they use on the session and
853− // file lists, instead of hunting for the top-bar refresh icon. The empty
854− // / loading / error states above stay outside the PTR box since there's
855− // no list content to pull against.
856− PullToRefreshBox(
857− isRefreshing = refreshing,
858− onRefresh = { vm.refreshMessages() },
859− modifier = Modifier.fillMaxSize(),
860− ) {
861− val lastMessageId = messages.lastOrNull()?.info?.id
862− // Text-to-speech for reading assistant replies aloud; shuts down when the
863− // message list leaves composition.
864− val tts = rememberTtsController()
865− val speakingMessageId by tts.speakingId
866− LazyColumn(
867− state = listState,
868− modifier = Modifier.fillMaxSize(),
869− contentPadding = PaddingValues(16.dp),
870− verticalArrangement = Arrangement.spacedBy(16.dp),
871− ) {
872− items(
873− items = listItems,
874− key = { it.key },
875− contentType = { it.contentType },
876− ) { item ->
877− when (item) {
878− is MessageListItem.Separator -> DateSeparator(item.label)
879− is MessageListItem.Message -> {
880− val message = item.message
881− // Only the last (streaming) message needs isRunning — it drives the
882− // reasoning-block spinner. Passing the live flag to every bubble
883− // makes all visible messages recompose whenever a run starts or stops.
884− val modelLabel = remember(message.info, models) {
885− (message.info as? soy.iko.opencode.data.model.AssistantMessage)
886− ?.let { resolveModelLabel(it, models) }
887− }
888− MessageBubble(
889− message,
890− isRunning = running && message.info.id == lastMessageId,
891− imageContext = imageContext,
892− modelLabel = modelLabel,
893− onOpenFile = onOpenFile,
894− onRevert = { vm.revertTo(message.info.id) },
895− onEdit = { text -> vm.editMessage(message.info.id, text) },
896− onSpeak = { text -> tts.toggle(message.info.id, text) },
897− isSpeaking = message.info.id == speakingMessageId,
898− onQuote = { text ->
899− vm.quoteReply(text)
900− runCatching { inputFocusRequester.requestFocus() }
901− },
902− onBranch = { text -> vm.branchFrom(text) },
903− )
904− }
905− }
906− }
907− if (running) {
908− item(key = "__typing") {
909− val workingText = stringResource(R.string.working)
910− Row(
911− verticalAlignment = Alignment.CenterVertically,
912− modifier = Modifier.semantics {
913− contentDescription = workingText
914− },
915− ) {
916− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
917− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
918− }
919− }
920− }
921− }
922− }
923− // Jump-to-latest affordance when the user has scrolled away during a stream.
924− AnimatedVisibility(
925− visible = !isPinnedToBottom && listItems.isNotEmpty(),
926− modifier = Modifier.align(Alignment.BottomEnd),
927− ) {
928− ExtendedFloatingActionButton(
929− onClick = {
930− if (listItems.isNotEmpty()) {
931− // Scroll to the effective last index, including the
932− // trailing "__typing" row when a run is active.
933− val target = if (running) listItems.size else listItems.lastIndex
934− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
935− }
936− },
937− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
938− text = { Text(stringResource(R.string.latest)) },
939− modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
940− )
941− }
942− }
943− }
944− }
945− }
946− }
947−
948− if (showExitConfirm) {
949− AlertDialog(
950− onDismissRequest = { showExitConfirm = false },
951− title = { Text(stringResource(R.string.stop_and_exit_title)) },
952− text = { Text(stringResource(R.string.stop_and_exit_text)) },
953− confirmButton = {
954− TextButton(onClick = {
955− showExitConfirm = false
956− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
957− vm.abort()
958− onBack()
959− }) { Text(stringResource(R.string.stop_and_exit)) }
960− },
961− dismissButton = {
962− TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
963− },
964− )
965− }
966−
967− if (showStopConfirm) {
968− AlertDialog(
969− onDismissRequest = { showStopConfirm = false },
970− title = { Text(stringResource(R.string.stop_run_title)) },
971− text = { Text(stringResource(R.string.stop_run_text)) },
972− confirmButton = {
973− TextButton(onClick = {
974− showStopConfirm = false
975− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
976− vm.abort()
977− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
978− },
979− dismissButton = {
980− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
981− },
982− )
983− }
984−
985− if (showRenameDialog) {
986− RenameSessionChatDialog(
987− initialTitle = sessionTitle ?: "",
988− onDismiss = { showRenameDialog = false },
989− onConfirm = { newName ->
990− showRenameDialog = false
991− vm.renameSession(newName)
992− },
993− )
994− }
995−
996− if (showDeleteDialog) {
997− AlertDialog(
998− onDismissRequest = { showDeleteDialog = false },
999− title = { Text(stringResource(R.string.delete_session_chat_title)) },
1000− text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1001− confirmButton = {
1002− TextButton(onClick = {
1003− showDeleteDialog = false
1004− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1005− vm.deleteSession()
1006− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1007− },
1008− dismissButton = {
1009− TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1010− },
1011− )
1012− }
1013−
1014− if (showShellDialog) {
1015− ShellCommandDialog(
1016− onDismiss = { showShellDialog = false },
1017− onRun = { cmd ->
1018− showShellDialog = false
1019− vm.runShell(cmd)
1020− },
1021− )
1022− }
1023−
1024− if (showModelPicker) {
1025− ModelPickerSheet(
1026− options = models,
1027− selected = selectedModel,
1028− loading = modelsLoading,
1029− error = modelsError,
1030− onSelect = { vm.selectModel(it) },
1031− onRetry = { vm.reloadModels() },
1032− onDismiss = { showModelPicker = false },
1033− )
1034− }
1035−
1036− if (showAgentPicker) {
1037− AgentPickerSheet(
1038− agents = agents,
1039− selected = selectedAgent,
1040− loading = agentsLoading,
1041− error = agentsError,
1042− onSelect = { vm.selectAgent(it?.name) },
1043− onRetry = { vm.reloadAgents() },
1044− onDismiss = { showAgentPicker = false },
1045− )
1046− }
1047−
1048− if (showCommandPicker) {
1049− CommandPickerSheet(
1050− commands = commands,
1051− loading = commandsLoading,
1052− error = commandsError,
1053− onSelect = { vm.runCommand(it) },
1054− onRetry = { vm.reloadCommands() },
1055− onDismiss = { showCommandPicker = false },
1056− )
1057− }
1058−
1059− pendingPermission?.let { permission ->
1060− PermissionDialog(
1061− permission = permission,
1062− onRespond = { response -> vm.respondPermission(permission, response) },
1063− )
1064− }
1065−
1066− if (showPalette) {
1067− CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1068− }
1069−}
1070−
1071−@Composable
1072−private fun ChatInputBar(
1073− value: String,
1074− onValueChange: (String) -> Unit,
1075− running: Boolean,
1076− aborting: Boolean,
1077− enabled: Boolean,
1078− sendOnEnter: Boolean,
1079− onSend: () -> Unit,
1080− onAbort: () -> Unit,
1081− queuedFollowUp: String?,
1082− onQueueFollowUp: (String) -> Unit,
1083− onCancelQueue: () -> Unit,
1084− focusRequester: androidx.compose.ui.focus.FocusRequester,
1085− attachments: List<PendingAttachment>,
1086− onRemoveAttachment: (String) -> Unit,
1087− onPickPhoto: () -> Unit,
1088− onPickFile: () -> Unit,
1089− onCamera: () -> Unit,
1090− onVoice: () -> Unit,
1091−) {
1092− // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1093− val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1094− Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1095− Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1096− // A queued follow-up replaces the Stop button with a "queued" chip so the
1097− // user sees their message will be sent when the run finishes, and can cancel.
1098− if (queuedFollowUp != null) {
1099− Row(
1100− modifier = Modifier
1101− .fillMaxWidth()
1102− .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1103− verticalAlignment = Alignment.CenterVertically,
1104− ) {
1105− Column(modifier = Modifier.weight(1f)) {
1106− Text(
1107− stringResource(R.string.queued),
1108− style = MaterialTheme.typography.labelSmall,
1109− color = MaterialTheme.colorScheme.onSurfaceVariant,
1110− )
1111− Text(
1112− queuedFollowUp,
1113− style = MaterialTheme.typography.bodySmall,
1114− color = MaterialTheme.colorScheme.onSurfaceVariant,
1115− maxLines = 1,
1116− overflow = TextOverflow.Ellipsis,
1117− )
1118− }
1119− TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1120− Text(stringResource(R.string.cancel))
1121− }
1122− }
1123− }
1124− // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1125− AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1126− Row(
1127− modifier = Modifier.fillMaxWidth().padding(8.dp),
1128− verticalAlignment = Alignment.Bottom,
1129− ) {
1130− // Attach menu: photo picker, camera, or any file.
1131− var showAttachMenu by remember { mutableStateOf(false) }
1132− Box {
1133− IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1134− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1135− }
1136− DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1137− DropdownMenuItem(
1138− leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1139− text = { Text(stringResource(R.string.attach_photo)) },
1140− onClick = { showAttachMenu = false; onPickPhoto() },
1141− )
1142− DropdownMenuItem(
1143− leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1144− text = { Text(stringResource(R.string.attach_camera)) },
1145− onClick = { showAttachMenu = false; onCamera() },
1146− )
1147− DropdownMenuItem(
1148− leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1149− text = { Text(stringResource(R.string.attach_file)) },
1150− onClick = { showAttachMenu = false; onPickFile() },
1151− )
1152− }
1153− }
1154− // Voice dictation: appends recognized speech to the draft.
1155− IconButton(onClick = onVoice, enabled = enabled) {
1156− Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1157− }
1158− OutlinedTextField(
1159− value = value,
1160− onValueChange = { v ->
1161− // Cap input length so a huge paste can't stall the UI. Shares are
1162− // capped separately in MainActivity; this guards typed/pasted drafts.
1163− onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1164− },
1165− modifier = Modifier
1166− .weight(1f)
1167− .focusRequester(focusRequester)
1168− .testTag("chat_input")
1169− .onPreviewKeyEvent { event ->
1170− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1171− // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1172− // With it off, Enter inserts a newline and Ctrl+Enter sends.
1173− val send = enterShouldSend(
1174− enabled = enabled,
1175− hasContent = hasContent,
1176− sendOnEnter = sendOnEnter,
1177− shift = event.isShiftPressed,
1178− ctrl = event.isCtrlPressed,
1179− )
1180− if (send) {
1181− if (running) onQueueFollowUp(value) else onSend()
1182− true
1183− } else {
1184− false
1185− }
1186− },
1187− placeholder = { Text(stringResource(R.string.message_placeholder)) },
1188− enabled = enabled,
1189− maxLines = 6,
1190− // Show a "N / max" countdown once the draft crosses a high fraction of
1191− // the cap, so the user knows a paste is about to be truncated instead
1192− // of being silently cut off. Hidden for normal short prompts to avoid
1193− // clutter under a typical one-line message.
1194− supportingText = {
1195− val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
1196− if (value.length >= threshold) {
1197− Text(
1198− stringResource(
1199− R.string.draft_chars_remaining,
1200− NetworkConfig.maxDraftLengthChars - value.length,
1201− NetworkConfig.maxDraftLengthChars,
1202− ),
1203− style = MaterialTheme.typography.labelSmall,
1204− color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
1205− else MaterialTheme.colorScheme.onSurfaceVariant,
1206− )
1207− }
1208− },
1209− keyboardOptions = KeyboardOptions(
1210− imeAction = ImeAction.Send,
1211− capitalization = KeyboardCapitalization.Sentences,
1212− ),
1213− keyboardActions = KeyboardActions(onSend = {
1214− if (enabled && hasContent) {
1215− if (running) onQueueFollowUp(value) else onSend()
1216− }
1217− }),
1218− )
1219− ComposerTrailingButton(
1220− running = running,
1221− aborting = aborting,
1222− canSend = enabled && hasContent,
1223− onSend = onSend,
1224− onAbort = onAbort,
1225− )
1226− }
1227− }
1228− }
1229−}
1230−
1231−/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
1232− * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
1233−private fun enterShouldSend(
1234− enabled: Boolean,
1235− hasContent: Boolean,
1236− sendOnEnter: Boolean,
1237− shift: Boolean,
1238− ctrl: Boolean,
1239−): Boolean = when {
1240− !enabled || !hasContent -> false
1241− sendOnEnter -> !shift
1242− else -> ctrl
1243−}
1244−
1245−/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
1246− * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1247−@Composable
1248−private fun ComposerTrailingButton(
1249− running: Boolean,
1250− aborting: Boolean,
1251− canSend: Boolean,
1252− onSend: () -> Unit,
1253− onAbort: () -> Unit,
1254−) {
1255− if (running) {
1256− // Show a spinner while the abort REST call is in flight so the user sees the stop
1257− // was sent, and disable to prevent a double-tap.
1258− IconButton(
1259− onClick = onAbort,
1260− enabled = !aborting,
1261− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1262− ) {
1263− if (aborting) {
1264− val stopLabel = stringResource(R.string.stop)
1265− CircularProgressIndicator(
1266− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1267− strokeWidth = 2.dp,
1268− )
1269− } else {
1270− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1271− }
1272− }
1273− } else {
1274− IconButton(
1275− onClick = onSend,
1276− enabled = canSend,
1277− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1278− ) {
1279− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1280− }
1281− }
1282−}
1283−
1284−@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
1285−@Composable
1286−private fun EmptyConversation(
1287− onSuggestion: (String) -> Unit,
1288− modifier: Modifier = Modifier,
1289−) {
1290− // A few example prompts so a first-time user has a one-tap path to their first
1291− // message instead of staring at a blank input. Tapping a chip fills the draft
1292− // (without sending) so the user can edit it before sending.
1293− val suggestions = remember {
1294− listOf(
1295− R.string.empty_chat_suggest_1,
1296− R.string.empty_chat_suggest_2,
1297− R.string.empty_chat_suggest_3,
1298− )
1299− }
1300− Column(
1301− modifier = modifier.padding(32.dp),
1302− horizontalAlignment = Alignment.CenterHorizontally,
1303− ) {
1304− Icon(
1305− Icons.Filled.AutoAwesome,
1306− contentDescription = null,
1307− modifier = Modifier.size(48.dp),
1308− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1309− )
1310− Spacer(Modifier.size(16.dp))
1311− Text(
1312− stringResource(R.string.empty_chat_title),
1313− style = MaterialTheme.typography.titleMedium,
1314− )
1315− Spacer(Modifier.size(8.dp))
1316− Text(
1317− stringResource(R.string.empty_chat_hint),
1318− style = MaterialTheme.typography.bodyMedium,
1319− color = MaterialTheme.colorScheme.onSurfaceVariant,
1320− )
1321− Spacer(Modifier.size(20.dp))
1322− // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
1323− // so a longer localized prompt doesn't push the chips off-screen on a phone.
1324− FlowRow(
1325− modifier = Modifier.fillMaxWidth(),
1326− horizontalArrangement = Arrangement.spacedBy(8.dp),
1327− verticalArrangement = Arrangement.spacedBy(8.dp),
1328− ) {
1329− suggestions.forEach { resId ->
1330− val text = stringResource(resId)
1331− androidx.compose.material3.AssistChip(
1332− onClick = { onSuggestion(text) },
1333− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
1334− )
1335− }
1336− }
1337− }
1338−}
1339−
1340−/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1341−@Composable
1342−private fun RevertBanner(onUndo: () -> Unit) {
1343− Surface(
1344− color = MaterialTheme.colorScheme.tertiaryContainer,
1345− tonalElevation = 2.dp,
1346− modifier = Modifier.fillMaxWidth(),
1347− ) {
1348− Row(
1349− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1350− verticalAlignment = Alignment.CenterVertically,
1351− horizontalArrangement = Arrangement.SpaceBetween,
1352− ) {
1353− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1354− Icon(
1355− Icons.Filled.Restore,
1356− contentDescription = null,
1357− tint = MaterialTheme.colorScheme.onTertiaryContainer,
1358− modifier = Modifier.size(18.dp),
1359− )
1360− Text(
1361− stringResource(R.string.reverted_banner),
1362− style = MaterialTheme.typography.bodySmall,
1363− color = MaterialTheme.colorScheme.onTertiaryContainer,
1364− modifier = Modifier.padding(start = 8.dp),
1365− )
1366− }
1367− TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1368− }
1369− }
1370−}
1371−
1372−/** A banner shown while messages composed offline are queued in the outbox, with a
1373− * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
1374−@Composable
1375−private fun OutboxBanner(
1376− count: Int,
1377− sending: Boolean,
1378− onFlush: () -> Unit,
1379− onDiscard: () -> Unit,
1380−) {
1381− Surface(
1382− color = MaterialTheme.colorScheme.secondaryContainer,
1383− tonalElevation = 2.dp,
1384− modifier = Modifier.fillMaxWidth(),
1385− ) {
1386− Row(
1387− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1388− verticalAlignment = Alignment.CenterVertically,
1389− horizontalArrangement = Arrangement.SpaceBetween,
1390− ) {
1391− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1392− if (sending) {
1393− CircularProgressIndicator(
1394− Modifier.size(16.dp),
1395− strokeWidth = 2.dp,
1396− color = MaterialTheme.colorScheme.onSecondaryContainer,
1397− )
1398− Spacer(Modifier.size(8.dp))
1399− }
1400− Text(
1401− if (sending) {
1402− stringResource(R.string.outbox_sending)
1403− } else {
1404− val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
1405− stringResource(res, count)
1406− },
1407− style = MaterialTheme.typography.bodySmall,
1408− color = MaterialTheme.colorScheme.onSecondaryContainer,
1409− )
1410− }
1411− if (!sending) {
1412− TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
1413− TextButton(onClick = onDiscard) {
1414− Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
1415− }
1416− }
1417− }
1418− }
1419−}
1420−
1421−/** Prompt for a one-off shell command to run in the session's worktree. */
1422−@Composable
1423−private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1424− var command by rememberSaveable { mutableStateOf("") }
1425− AlertDialog(
1426− onDismissRequest = onDismiss,
1427− title = { Text(stringResource(R.string.run_shell_command)) },
1428− text = {
1429− OutlinedTextField(
1430− value = command,
1431− onValueChange = { command = it },
1432− modifier = Modifier.fillMaxWidth(),
1433− placeholder = { Text(stringResource(R.string.shell_command_hint)) },
1434− label = { Text(stringResource(R.string.shell_command_label)) },
1435− singleLine = true,
1436− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1437− keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1438− )
1439− },
1440− confirmButton = {
1441− TextButton(
1442− onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1443− enabled = command.isNotBlank(),
1444− ) { Text(stringResource(R.string.run)) }
1445− },
1446− dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1447− )
1448−}
1449−
1450−/**
1451− * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1452− * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1453− * equality is structural so `snapshotFlow` emits only when something relevant changes.
1454− */
1455−private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
1456−
1457−/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
1458− * Covers text, reasoning, and tool output so a pinned view keeps following non-text
1459− * streaming, not just plain assistant text. O(1). */
1460−private fun streamingContentLength(part: Part?): Int = when (part) {
1461− is TextPart -> part.text.length
1462− is ReasoningPart -> part.text.length
1463− is ToolPart -> when (val st = part.state) {
1464− is ToolCompleted -> st.output?.length ?: 0
1465− is ToolError -> st.error?.length ?: 0
1466− is ToolRunning -> st.title?.length ?: 0
1467− else -> 0
1468− }
1469− else -> 0
1470−}
1471−
1472−/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1473− * dialog but lives in the chat screen so the user can rename without backing out to
1474− * the list. Pre-filled with the current title; the cap matches the list dialog. */
1475−@Composable
1476−private fun RenameSessionChatDialog(
1477− initialTitle: String,
1478− onDismiss: () -> Unit,
1479− onConfirm: (String) -> Unit,
1480−) {
1481− var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1482− AlertDialog(
1483− onDismissRequest = onDismiss,
1484− title = { Text(stringResource(R.string.rename_session_chat)) },
1485− text = {
1486− OutlinedTextField(
1487− value = title,
1488− onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1489− modifier = Modifier.fillMaxWidth(),
1490− placeholder = { Text(stringResource(R.string.session_title_hint)) },
1491− label = { Text(stringResource(R.string.rename_session_chat)) },
1492− singleLine = true,
1493− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1494− keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1495− )
1496− },
1497− confirmButton = {
1498− TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1499− Text(stringResource(R.string.save))
1500− }
1501− },
1502− dismissButton = {
1503− TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1504− },
1505− )
1506−}
1507−
1508−/**
1509− * A single item in the chat message list — either a [Message] bubble or a day
1510− * [Separator] inserted before the first message of a new day. The separator shifts
1511− * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1512− * the [List] of these items (not `messages.size`/`lastIndex`).
1513− */
1514−private sealed interface MessageListItem {
1515− val key: Any
1516− val contentType: Any
1517−
1518− data class Message(val message: MessageWithParts) : MessageListItem {
1519− override val key: Any get() = message.info.id
1520− override val contentType: Any get() = message.info::class
1521− }
1522−
1523− data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1524− // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1525− // can share a label (e.g. an untimestamped message — empty label — between two
1526− // same-day messages, or two untimestamped groups), and a label-only key would
1527− // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1528− // stable for a given message ordering so slots aren't needlessly recreated.
1529− override val key: Any get() = "sep_${ordinal}_$label"
1530− // Separators share a contentType so the LazyColumn can recycle their slots.
1531− override val contentType: Any get() = "separator"
1532− }
1533−}
1534−
1535−/**
1536− * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1537− * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1538− * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1539− * Messages with no timestamp are grouped under an empty-label separator only if they
1540− * start the list, so a server that omits timestamps doesn't suppress the first divider.
1541− *
1542− * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1543− * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1544− * labels; older days fall back to a locale-stable medium-date format.
1545− */
1546−private fun buildMessageListItems(
1547− messages: List<MessageWithParts>,
1548− todayLabel: String,
1549− yesterdayLabel: String,
1550−): List<MessageListItem> {
1551− if (messages.isEmpty()) return emptyList()
1552− val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1553− val dateFmt = mediumDateFormat()
1554− val result = ArrayList<MessageListItem>(messages.size + 4)
1555− var lastDayKey: String? = null
1556− var sepOrdinal = 0
1557− for (message in messages) {
1558− val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1559− val dayKey = ts?.let { dayKey(it) } ?: ""
1560− if (dayKey != lastDayKey) {
1561− result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1562− lastDayKey = dayKey
1563− }
1564− result.add(MessageListItem.Message(message))
1565− }
1566− return result
1567−}
1568−
1569−/**
1570− * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
1571− *
1572− * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
1573− * whole `messages` list, which must stay so the live message's fresh parts render), so a
1574− * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
1575− * every message on every token. Message timestamps are immutable once assigned, so we
1576− * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
1577− * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
1578− * thread during composition, so the coarse lock is uncontended.
1579− */
1580−private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
1581− override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
1582−}
1583−
1584−private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
1585− dayKeyCache.getOrPut(epochMillis) {
1586− java.time.Instant.ofEpochMilli(epochMillis)
1587− .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
1588− }
1589−}
1590−
1591−// The medium-date formatter is an ICU locale lookup; cache one instance instead of
1592−// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
1593−// locale changes at runtime. Main-thread-only, so no synchronization is needed.
1594−private var cachedDateFmt: java.text.DateFormat? = null
1595−private var cachedDateFmtLocale: java.util.Locale? = null
1596−
1597−private fun mediumDateFormat(): java.text.DateFormat {
1598− val locale = java.util.Locale.getDefault()
1599− val existing = cachedDateFmt
1600− if (existing != null && cachedDateFmtLocale == locale) return existing
1601− return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
1602− cachedDateFmt = it
1603− cachedDateFmtLocale = locale
1604− }
1605−}
1606−
1607−/**
1608− * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1609− * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1610− * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1611− * messages.
1612− */
1613−private fun dayLabel(
1614− dayKey: String,
1615− ts: Long,
1616− today: java.time.LocalDate,
1617− todayLabel: String,
1618− yesterdayLabel: String,
1619− dateFmt: java.text.DateFormat,
1620−): String {
1621− if (dayKey.isEmpty()) return ""
1622− val epochDay = dayKey.toLongOrNull() ?: return ""
1623− val date = java.time.LocalDate.ofEpochDay(epochDay)
1624− return when {
1625− date == today -> todayLabel
1626− date == today.minusDays(1) -> yesterdayLabel
1627− else -> dateFmt.format(java.util.Date(ts))
1628− }
1629−}
1630−
1631−/**
1632− * A centered day divider in the message list. Renders nothing for an empty label
1633− * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1634− */
1635−@Composable
1636−private fun DateSeparator(label: String) {
1637− if (label.isBlank()) return
1638− Row(
1639− modifier = Modifier.fillMaxWidth(),
1640− horizontalArrangement = Arrangement.Center,
1641− ) {
1642− Surface(
1643− color = MaterialTheme.colorScheme.surfaceVariant,
1644− shape = MaterialTheme.shapes.small,
1645− ) {
1646− Text(
1647− label,
1648− style = MaterialTheme.typography.labelSmall,
1649− color = MaterialTheme.colorScheme.onSurfaceVariant,
1650− modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1651− )
1652− }
1653− }
1654−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktdeleted−1179
@@ -1,1179 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
5−import soy.iko.opencode.data.model.Agent
6−import soy.iko.opencode.data.model.Command
7−import soy.iko.opencode.data.model.FilePromptPart
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.model.ModelOption
10−import soy.iko.opencode.data.model.Permission
11−import soy.iko.opencode.data.model.PermissionReplied
12−import soy.iko.opencode.data.model.PermissionResponse
13−import soy.iko.opencode.data.model.PermissionUpdated
14−import soy.iko.opencode.data.model.SessionDeleted
15−import soy.iko.opencode.data.model.SessionUpdated
16−import soy.iko.opencode.data.model.TextPart
17−import soy.iko.opencode.data.model.TodoItem
18−import soy.iko.opencode.data.model.currentTodoPlan
19−import soy.iko.opencode.data.model.defaultOption
20−import soy.iko.opencode.data.model.toOptions
21−import soy.iko.opencode.data.network.EventStreamClient
22−import soy.iko.opencode.data.network.NetworkConfig
23−import soy.iko.opencode.data.repo.OutboxMessage
24−import soy.iko.opencode.data.repo.SessionRepository
25−import soy.iko.opencode.di.AppContainer
26−import soy.iko.opencode.R
27−import soy.iko.opencode.util.runCatchingCancellable
28−import soy.iko.opencode.util.safeExceptionSummary
29−import android.util.Log
30−import kotlinx.coroutines.ExperimentalCoroutinesApi
31−import kotlinx.coroutines.channels.BufferOverflow
32−import kotlinx.coroutines.channels.Channel
33−import kotlinx.coroutines.flow.Flow
34−import kotlinx.coroutines.flow.MutableStateFlow
35−import kotlinx.coroutines.flow.SharingStarted
36−import kotlinx.coroutines.flow.StateFlow
37−import kotlinx.coroutines.flow.asStateFlow
38−import kotlinx.coroutines.flow.receiveAsFlow
39−import kotlinx.coroutines.CancellationException
40−import kotlinx.coroutines.flow.collectLatest
41−import kotlinx.coroutines.flow.debounce
42−import kotlinx.coroutines.flow.distinctUntilChanged
43−import kotlinx.coroutines.flow.drop
44−import kotlinx.coroutines.flow.first
45−import kotlinx.coroutines.flow.filterIsInstance
46−import kotlinx.coroutines.flow.flatMapLatest
47−import kotlinx.coroutines.flow.flowOf
48−import kotlinx.coroutines.flow.map
49−import kotlinx.coroutines.flow.merge
50−import kotlinx.coroutines.flow.onEach
51−import kotlinx.coroutines.flow.retryWhen
52−import kotlinx.coroutines.flow.stateIn
53−import kotlinx.coroutines.flow.update
54−import kotlinx.coroutines.delay
55−import kotlinx.coroutines.launch
56−
57−/**
58− * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
59− * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
60− * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
61− * inherit a Retry that silently re-submits the last prompt.
62− */
63−data class ChatError(val message: String, val retryable: Boolean = false)
64−
65−/**
66− * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
67− * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
68− * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
69− * the server.
70− */
71−@androidx.compose.runtime.Immutable
72−data class PendingAttachment(
73− val id: String,
74− val name: String,
75− val mime: String,
76− val previewModel: Any?,
77− val part: FilePromptPart,
78−) {
79− val isImage: Boolean get() = mime.startsWith("image/")
80−}
81−
82−/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
83−private fun PendingAttachment.toPersisted() =
84− soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
85−
86−/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
87− * preview model for images (the original content Uri didn't survive the process restart). */
88−private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
89− id = id,
90− name = name,
91− mime = mime,
92− previewModel = if (mime.startsWith("image/")) url else null,
93− part = FilePromptPart(mime = mime, url = url, filename = filename),
94−)
95−
96−// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
97−// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
98−// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
99−// suppressed for the same reason. A future split into sub-controllers is tracked separately.
100−@Suppress("LargeClass")
101−@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
102−class ChatViewModel(
103− private val container: AppContainer,
104− private val sessionId: String,
105−) : ViewModel() {
106−
107− private val connection get() = container.activeConnection.value
108−
109− val connected: Boolean get() = connection != null
110−
111− private val _loading = MutableStateFlow(true)
112− val loading: StateFlow<Boolean> = _loading.asStateFlow()
113−
114− /** Set when the messages flow failed to load (and the list is empty), so the UI
115− * can render a distinct error state instead of masquerading as an empty
116− * conversation. Cleared on a successful non-empty emission. */
117− private val _loadError = MutableStateFlow(false)
118− val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
119−
120− /** Tracks whether the messages flow has ever emitted a non-empty list, so
121− * [messages]' retryWhen can decide whether a re-fetch failure should set
122− * [loadError] (nothing shown yet → error screen) or just emit a snackbar
123− * (already showing messages → don't replace the conversation with an error). */
124− private var hasShownMessages = false
125−
126− /** True once we've surfaced a snackbar for the current message-load failure streak.
127− * Reset on any successful emission so a persistent failure doesn't spam a fresh
128− * snackbar every retry cycle (every few seconds). */
129− private var loadErrorSnackbarShown = false
130−
131− /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
132− * doesn't conflict with the messages flow's loading state. */
133− private val _reconnecting = MutableStateFlow(false)
134− val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
135−
136− /** When true, the debounced draft collector skips persisting an empty draft so
137− * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
138− * debounce timer firing before the send completes. */
139− private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
140−
141− /** The draft value this VM last persisted, so the drafts observer can tell its own
142− * (debounced, possibly stale) echo apart from a genuine external write. Without this
143− * a debounced persist of an older value could echo back and overwrite newer text the
144− * user has since typed. */
145− @Volatile private var lastPersistedDraft: String? = null
146−
147− /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
148− * collectLatest to cancel any in-flight fetch and start a fresh one, so a
149− * manual reload supersedes a stale observeCatalog fetch. */
150− private val _modelsReload = MutableStateFlow(0)
151− private val _agentsReload = MutableStateFlow(0)
152− private val _commandsReload = MutableStateFlow(0)
153−
154− val messages: StateFlow<List<MessageWithParts>> =
155− container.activeConnection
156− .flatMapLatest { conn ->
157− conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
158− }
159− .onEach {
160− _loading.value = false
161− // A value flowed through: the stream recovered, so allow the next distinct
162− // failure to surface a fresh snackbar again.
163− loadErrorSnackbarShown = false
164− if (it.isNotEmpty()) {
165− hasShownMessages = true
166− _loadError.value = false
167− }
168− }
169− .retryWhen { cause, _ ->
170− _loading.value = false
171− // Only surface the persistent error state when there's nothing to
172− // show — if we already have messages, a transient re-fetch failure
173− // is better surfaced as a snackbar (via errorEvents) than by
174− // replacing the visible conversation with an error screen.
175− if (!hasShownMessages) _loadError.value = true
176− // Surface the snackbar only once per failure streak — retryWhen loops
177− // every retryInitialDelayMs, so emitting here unconditionally would spam a
178− // new snackbar every few seconds on a persistent load failure.
179− if (!loadErrorSnackbarShown) {
180− _errorEvents.trySend(ChatError(container.friendlyError(cause)))
181− loadErrorSnackbarShown = true
182− }
183− // Delay before retrying to avoid a tight loop on persistent errors.
184− delay(NetworkConfig.retryInitialDelayMs)
185− true
186− }
187− .stateIn(
188− scope = viewModelScope,
189− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
190− initialValue = emptyList(),
191− )
192−
193− /** Whether the conversation has any messages. Derived separately so the top bar
194− * (share button enabled state) can observe this cheap boolean instead of the
195− * full messages list, avoiding per-token recomposition of the app bar during
196− * streaming. */
197− val hasMessages: StateFlow<Boolean> = messages
198− .map { it.isNotEmpty() }
199− .stateIn(
200− scope = viewModelScope,
201− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
202− initialValue = false,
203− )
204−
205− /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
206− * live progress checklist above the composer. distinctUntilChanged so a per-token messages
207− * emission that doesn't change the plan doesn't recompose the bar. */
208− val todoPlan: StateFlow<List<TodoItem>> = messages
209− .map { currentTodoPlan(it) }
210− .distinctUntilChanged()
211− .stateIn(
212− scope = viewModelScope,
213− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
214− initialValue = emptyList(),
215− )
216−
217− private val _running = MutableStateFlow(false)
218− val running: StateFlow<Boolean> = _running.asStateFlow()
219−
220− /** True while an abort REST call is in flight, so the Stop button can show a
221− * spinner and prevent double-taps from firing a second abort. */
222− private val _aborting = MutableStateFlow(false)
223− val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
224−
225− /** A follow-up the user typed while a run was active. send() queues it here and
226− * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
227− * no way to send and no indication why the Send button is gone. */
228− private val _queuedFollowUp = MutableStateFlow<String?>(null)
229− val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
230−
231− /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
232− * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
233− * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
234− * composition). Each event is delivered exactly once and not replayed to a
235− * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
236− private val _errorEvents = Channel<ChatError>(
237− capacity = NetworkConfig.snackbarEventBufferCapacity,
238− onBufferOverflow = BufferOverflow.DROP_OLDEST,
239− )
240− val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
241−
242− private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
243− val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
244−
245− private val _modelsLoading = MutableStateFlow(true)
246− val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
247−
248− private val _modelsError = MutableStateFlow(false)
249− val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
250−
251− private val _selectedModel = MutableStateFlow<ModelOption?>(null)
252− val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
253−
254− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
255− container.activeConnection
256− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
257− .stateIn(
258− scope = viewModelScope,
259− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
260− initialValue = EventStreamClient.ConnectionState.Disconnected,
261− )
262−
263− private val _pendingPermission = MutableStateFlow<Permission?>(null)
264− val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
265−
266− private val _agents = MutableStateFlow<List<Agent>>(emptyList())
267− val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
268−
269− private val _agentsLoading = MutableStateFlow(true)
270− val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
271−
272− private val _agentsError = MutableStateFlow(false)
273− val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
274−
275− private val _selectedAgent = MutableStateFlow<String?>(null)
276− val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
277−
278− private val _commands = MutableStateFlow<List<Command>>(emptyList())
279− val commands: StateFlow<List<Command>> = _commands.asStateFlow()
280−
281− private val _commandsLoading = MutableStateFlow(true)
282− val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
283−
284− private val _commandsError = MutableStateFlow(false)
285− val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
286−
287− private val _sessionTitle = MutableStateFlow<String?>(null)
288− val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
289−
290− /** Set when the current session is deleted via SSE, so the UI can navigate away. */
291− private val _sessionDeleted = MutableStateFlow(false)
292− val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
293−
294− /** The text of the last send that failed, surfaced so the UI can offer a retry. */
295− private val _failedDraft = MutableStateFlow<String?>(null)
296− val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
297−
298− /** Per-session draft, persisted so it survives navigation/process death. */
299− private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
300− val draft: StateFlow<String> = _draft.asStateFlow()
301−
302− /** Messages composed for this session while offline/disconnected, queued in the outbox and
303− * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
304− * chip with a discard/send-now affordance. */
305− val outbox: StateFlow<List<OutboxMessage>> =
306− container.outboxStore.messages
307− .map { list -> list.filter { it.sessionId == sessionId } }
308− .stateIn(
309− scope = viewModelScope,
310− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
311− initialValue = emptyList(),
312− )
313−
314− /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
315− * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
316− * survives process death — the source content Uris wouldn't survive, but the data URLs
317− * do. Cleared on a successful send. */
318− private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
319− val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
320−
321− /** True while a revert checkpoint is active for this session (messages after it are hidden
322− * server-side). Drives the "reverted" banner with its Undo. */
323− private val _reverted = MutableStateFlow(false)
324− val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
325−
326− /** The active public share URL for this session, or null when not shared. */
327− private val _shareUrl = MutableStateFlow<String?>(null)
328− val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
329−
330− /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
331− private val _shareLinkEvents = Channel<String>(
332− capacity = NetworkConfig.snackbarEventBufferCapacity,
333− onBufferOverflow = BufferOverflow.DROP_OLDEST,
334− )
335− val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
336−
337− /** One-shot events carrying the id of a session freshly branched from a message, so the
338− * UI can navigate to it. */
339− private val _branchEvents = Channel<String>(
340− capacity = NetworkConfig.snackbarEventBufferCapacity,
341− onBufferOverflow = BufferOverflow.DROP_OLDEST,
342− )
343− val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
344−
345− init {
346− // DraftStore loads SharedPreferences asynchronously; on a cold start the
347− // synchronous get() above returns "" until the background load completes.
348− // Observe the ready signal and re-seed the draft so it appears in the UI.
349− viewModelScope.launch {
350− container.draftStore.ready.collect { ready ->
351− if (!ready) return@collect
352− if (_draft.value.isEmpty()) {
353− _draft.value = container.draftStore.get(sessionId)
354− }
355− // Recover a follow-up queued before process death (its in-memory StateFlow
356− // is gone). If the input is now free, drop it back there for review/send and
357− // consume the stored copy; otherwise re-queue it so it auto-sends when the
358− // current run next idles. Guarded on == null so it never clobbers a
359− // follow-up the user queued after opening this session.
360− val savedFollowUp = container.draftStore.getFollowUp(sessionId)
361− if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
362− if (_draft.value.isEmpty()) {
363− _draft.value = savedFollowUp
364− container.draftStore.flushFollowUp(sessionId, "")
365− } else {
366− _queuedFollowUp.value = savedFollowUp
367− }
368− }
369− }
370− }
371− // Observe the drafts store for external mutations to this session's draft
372− // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
373− // before navigation in single-pane mode). Without this, a share injected into
374− // an already-open session never appears in the input field — the init-time
375− // read and the ready-only re-seed miss it when the draft is non-empty.
376− // The VM's own debounced persist writes the same value _draft already holds,
377− // so those updates are no-ops here (storeValue == _draft.value). The
378− // suppressDraftPersist guard prevents a stale store value from clobbering
379− // the deliberate draft clear during a send (the store still holds the
380− // pre-send draft until the send resolves).
381− viewModelScope.launch {
382− container.draftStore.drafts.collect { drafts ->
383− // Ignore emissions until the initial disk load completes: the load itself
384− // emits the persisted snapshot, and applying it here would clobber text the
385− // user typed during the async load (the `ready` observer above seeds an
386− // empty draft). After ready, emissions are external writes.
387− if (!container.draftStore.ready.value) return@collect
388− val storeValue = drafts[sessionId].orEmpty()
389− // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
390− // genuine external write (e.g. a two-pane share injection) should overwrite
391− // the live draft.
392− if (storeValue == lastPersistedDraft) return@collect
393− if (storeValue != _draft.value && !suppressDraftPersist.get()) {
394− _draft.value = storeValue
395− }
396− }
397− }
398− }
399−
400− init {
401− // Restore attachments staged before process death. They were persisted as
402− // self-contained data URLs, so they re-send and (for images) re-preview intact.
403− // Guarded on empty so it never clobbers attachments the user staged after opening.
404− viewModelScope.launch {
405− val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
406− .getOrDefault(emptyList())
407− if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
408− _attachments.value = restored.map { it.toPending() }
409− }
410− }
411− }
412−
413− fun selectModel(option: ModelOption) { _selectedModel.value = option }
414−
415− fun selectAgent(name: String?) { _selectedAgent.value = name }
416−
417− fun reloadModels() {
418− val conn = connection ?: return
419− viewModelScope.launch {
420− conn.api.invalidateProvidersCache()
421− _modelsReload.value++
422− }
423− }
424−
425− fun reloadAgents() {
426− val conn = connection ?: return
427− viewModelScope.launch {
428− conn.api.invalidateAgentsCache()
429− _agentsReload.value++
430− }
431− }
432−
433− fun reloadCommands() {
434− val conn = connection ?: return
435− viewModelScope.launch {
436− conn.api.invalidateCommandsCache()
437− _commandsReload.value++
438− }
439− }
440−
441− /**
442− * Shared helper for init-block catalog observers: re-fetches whenever the active
443− * connection or the catalog's reload trigger changes, invoking [onNull] to clear
444− * state on null so stale data from the old server doesn't persist. The
445− * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
446− * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
447− * via collectLatest, preventing a stale observe result from overwriting a fresh
448− * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
449− */
450− private fun <T> observeCatalog(
451− tag: String,
452− loading: MutableStateFlow<Boolean>,
453− error: MutableStateFlow<Boolean>,
454− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
455− onSuccess: (T) -> Unit,
456− onNull: () -> Unit,
457− reloadTrigger: StateFlow<Int>,
458− ) {
459− viewModelScope.launch {
460− merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
461− val conn = container.activeConnection.value
462− if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
463− loading.value = true
464− error.value = false
465− runCatchingCancellable { fetch(conn.api) }
466− // fetch() does HTTP via withRetry, so the failure can be a
467− // ClientRequestException whose message embeds the full request URL
468− // (may contain auth/paths). Log only a scrubbed summary.
469− .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
470− .getOrNull()?.let(onSuccess)
471− loading.value = false
472− }
473− }
474− }
475−
476− fun updateDraft(text: String) {
477− _draft.value = text
478− }
479−
480− init {
481− // Debounce draft persistence so we don't write to disk on every keystroke.
482− // Skip persisting empty drafts that were set by send() (suppressed via
483− // suppressDraftPersist) — send() clears the in-memory draft immediately for
484− // UI feedback but deliberately doesn't persist the clear until the send
485− // succeeds, so a failed send can restore the draft.
486− viewModelScope.launch {
487− _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
488− if (text.isBlank() && suppressDraftPersist.get()) return@collect
489− lastPersistedDraft = text
490− runCatchingCancellable { container.draftStore.set(sessionId, text) }
491− .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
492− }
493− }
494− // Reset per-connection state when the active server changes so stale spinners,
495− // permission dialogs, errors, and agent selections from the old server don't
496− // persist into the new one.
497− viewModelScope.launch {
498− container.activeConnection.collectLatest { conn ->
499− if (conn == null) {
500− // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
501− // arrive to deliver SessionIdle/SessionError, so reset the run state here —
502− // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
503− // the RunForegroundService alive, and the Stop button no-ops (abort() early-
504− // returns with no connection) until a later reconnect happens to clear it.
505− _running.value = false
506− _aborting.value = false
507− _pendingPermission.value = null
508− return@collectLatest
509− }
510− _running.value = false
511− _pendingPermission.value = null
512− _failedDraft.value = null
513− // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
514− // user intent that is now persisted; wiping it on every (re)connect — which
515− // this collector does, including transient SSE drops and the cold-start
516− // restore path — would drop a legitimately queued/recovered follow-up.
517− _selectedAgent.value = null
518− _sessionTitle.value = null
519− try {
520− conn.events.events.collect { event ->
521− if (SessionRepository.isIdle(event, sessionId)) {
522− _running.value = false
523− // Auto-send a queued follow-up once the previous run finishes,
524− // so the user's drafted-while-running message isn't lost.
525− val queued = _queuedFollowUp.value
526− if (queued != null) {
527− setQueuedFollowUp(null)
528− send(queued, includeAttachments = false)
529− }
530− }
531− if (SessionRepository.isError(event, sessionId)) {
532− _running.value = false
533− // The run errored, so the queued follow-up won't auto-send.
534− // Restore it to the input (if free) so the user's typed text
535− // isn't silently lost; otherwise just clear the queue.
536− val queued = _queuedFollowUp.value
537− if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
538− _draft.value = queued
539− }
540− setQueuedFollowUp(null)
541− _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
542− }
543− when (event) {
544− is PermissionUpdated ->
545− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
546− is PermissionReplied ->
547− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
548− is SessionUpdated ->
549− if (event.properties.info.id == sessionId) {
550− val info = event.properties.info
551− _sessionTitle.value = info.displayTitle
552− _reverted.value = info.isReverted
553− _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
554− }
555− is SessionDeleted ->
556− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
557− _sessionDeleted.value = true
558− }
559− else -> {}
560− }
561− }
562− } catch (e: CancellationException) {
563− throw e
564− } catch (e: Exception) {
565− Log.w("ChatViewModel", "SSE event collector error", e)
566− }
567− }
568− }
569− // Load the model catalog; preselect the server default. Failure is non-fatal —
570− // sending with no model just uses the server's default agent/model.
571− observeCatalog(
572− tag = "model catalog",
573− loading = _modelsLoading,
574− error = _modelsError,
575− fetch = { it.providers() },
576− onSuccess = { resp ->
577− val options = resp.toOptions()
578− _models.value = options
579− // Only (re)apply the server default when there's no valid current
580− // selection. On re-fetch/reconnect/Retry the user's chosen model must
581− // survive — keep it as long as it's still present in the refreshed list.
582− val current = _selectedModel.value
583− if (current == null || options.none { it.ref == current.ref }) {
584− _selectedModel.value = resp.defaultOption(options)
585− }
586− },
587− onNull = { _models.value = emptyList(); _selectedModel.value = null },
588− reloadTrigger = _modelsReload,
589− )
590− // Load the agent catalog (non-fatal).
591− observeCatalog(
592− tag = "agent catalog",
593− loading = _agentsLoading,
594− error = _agentsError,
595− fetch = { it.agents() },
596− onSuccess = { _agents.value = it },
597− onNull = { _agents.value = emptyList() },
598− reloadTrigger = _agentsReload,
599− )
600− // Load the command catalog (non-fatal).
601− observeCatalog(
602− tag = "command catalog",
603− loading = _commandsLoading,
604− error = _commandsError,
605− fetch = { it.commands() },
606− onSuccess = { _commands.value = it },
607− onNull = { _commands.value = emptyList() },
608− reloadTrigger = _commandsReload,
609− )
610− // Resolve the human-readable session title for the app bar (non-fatal).
611− // Skips the REST call when the SSE event collector already delivered the title
612− // (SessionUpdated fires shortly after connect), avoiding a redundant full
613− // session-list download.
614− viewModelScope.launch {
615− container.activeConnection.collectLatest { conn ->
616− if (conn == null) return@collectLatest
617− if (_sessionTitle.value != null) return@collectLatest
618− runCatchingCancellable { conn.repository.listSessions() }
619− .getOrNull()
620− ?.firstOrNull { it.id == sessionId }
621− ?.let { session ->
622− _sessionTitle.value = session.displayTitle
623− _reverted.value = session.isReverted
624− _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
625− }
626− }
627− }
628− // If the SSE stream drops mid-run, the run indicator would spin forever;
629− // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
630− viewModelScope.launch {
631− container.activeConnection.collectLatest { conn ->
632− if (conn == null) return@collectLatest
633− try {
634− conn.events.state.collect { state ->
635− if ((state == EventStreamClient.ConnectionState.Disconnected ||
636− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
637− _running.value = false
638− }
639− }
640− } catch (e: CancellationException) {
641− throw e
642− } catch (e: Exception) {
643− Log.w("ChatViewModel", "SSE state collector error", e)
644− }
645− }
646− }
647− }
648−
649− /** Sends [text] together with any staged attachments; returns true on success so the
650− * caller can clear the draft only then. */
651− fun send(text: String): Boolean = send(text, includeAttachments = true)
652−
653− /**
654− * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
655− * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
656− */
657− private fun send(text: String, includeAttachments: Boolean): Boolean {
658− val trimmed = text.trim()
659− val attachments = if (includeAttachments) _attachments.value else emptyList()
660− // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
661− // attached is not.
662− if (trimmed.isEmpty() && attachments.isEmpty()) return false
663− // Offline, or no active connection: queue the prompt to the outbox (flushed on
664− // reconnect) instead of failing the send. Device-offline is caught even when a stale
665− // connection object lingers, so a phone that lost signal mid-conversation still
666− // captures the message rather than bouncing it off a dead socket.
667− val conn = connection
668− if (conn == null || !container.isOnline.value) {
669− return enqueueOffline(trimmed, attachments)
670− }
671− if (!_running.compareAndSet(false, true)) return false
672− _failedDraft.value = null
673− // Clear the staged attachments optimistically so the composer empties immediately;
674− // restore them if the send fails (mirrors the draft handling below).
675− if (attachments.isNotEmpty()) _attachments.value = emptyList()
676− // Clear the in-memory draft for the UI immediately, but don't persist the clear
677− // yet — if the send fails and the process dies before we restore, the draft
678− // would be lost forever. The persisted draft is cleared only on success.
679− // suppressDraftPersist prevents the debounced collector from persisting the
680− // empty draft before the send resolves.
681− //
682− // Only clear when the text being sent IS the current draft. send() is also
683− // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
684− // the user may have typed a NEW draft since — wiping it would lose that text.
685− if (_draft.value.trim() == trimmed) {
686− suppressDraftPersist.set(true)
687− _draft.value = ""
688− }
689− viewModelScope.launch {
690− runCatchingCancellable {
691− conn.repository.sendPrompt(
692− sessionId,
693− trimmed,
694− attachments = attachments.map { it.part },
695− model = _selectedModel.value?.ref,
696− agent = _selectedAgent.value,
697− )
698− }.onFailure {
699− suppressDraftPersist.set(false)
700− _failedDraft.value = trimmed
701− // Only restore the draft if the user hasn't typed anything new since.
702− if (_draft.value.isBlank()) updateDraft(trimmed)
703− // Restore the staged attachments too, so a failed send doesn't lose them —
704− // but only if the user hasn't staged new ones in the meantime.
705− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
706− // Re-persist so the restored attachments survive process death after a failure.
707− persistAttachments()
708− // Retryable: this is the failed send whose prompt Retry re-submits.
709− _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
710− _running.value = false
711− }.onSuccess {
712− suppressDraftPersist.set(false)
713− // Send succeeded — the attachments rode along, so clear their persisted copy
714− // (or keep any the user staged after the optimistic clear).
715− persistAttachments()
716− // Send succeeded — persist the cleared draft, but only if the user hasn't
717− // typed a new one while the send was in flight. Otherwise clearing the
718− // store echoes back through the draft observer and wipes the in-progress
719− // text (data loss). Mirrors the guard on the failure path above.
720− if (_draft.value.isBlank()) {
721− lastPersistedDraft = ""
722− container.draftStore.set(sessionId, "")
723− }
724− // Don't reset _running here: the agent continues streaming via SSE.
725− // _running is cleared on SessionIdle/SessionError (see event collector)
726− // or when the SSE stream drops (see connection state watcher below).
727− }
728− }
729− return true
730− }
731−
732− /**
733− * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
734− * optimistically like a real send. AppContainer flushes the outbox automatically on
735− * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
736− * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
737− * are restored so nothing is lost.
738− */
739− private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
740− _failedDraft.value = null
741− if (attachments.isNotEmpty()) _attachments.value = emptyList()
742− val clearDraft = _draft.value.trim() == trimmed
743− if (clearDraft) {
744− suppressDraftPersist.set(true)
745− _draft.value = ""
746− }
747− viewModelScope.launch {
748− val profileId = connection?.profile?.id
749− ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
750− if (profileId == null) {
751− // No server to attribute the message to — restore the composer and report it
752− // rather than silently dropping the prompt.
753− suppressDraftPersist.set(false)
754− if (_draft.value.isBlank()) updateDraft(trimmed)
755− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
756− persistAttachments()
757− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
758− return@launch
759− }
760− runCatchingCancellable {
761− container.outboxStore.enqueue(
762− OutboxMessage(
763− id = java.util.UUID.randomUUID().toString(),
764− profileId = profileId,
765− sessionId = sessionId,
766− text = trimmed,
767− attachments = attachments.map { it.toPersisted() },
768− model = _selectedModel.value?.ref,
769− agent = _selectedAgent.value,
770− createdAt = System.currentTimeMillis(),
771− ),
772− )
773− }.onSuccess {
774− suppressDraftPersist.set(false)
775− // The staged attachments now live in the outbox item; clear their draft copy.
776− persistAttachments()
777− if (_draft.value.isBlank()) {
778− lastPersistedDraft = ""
779− container.draftStore.set(sessionId, "")
780− }
781− // Nudge a flush in case we're actually online with a live connection.
782− container.flushOutbox()
783− _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
784− }.onFailure {
785− suppressDraftPersist.set(false)
786− if (_draft.value.isBlank()) updateDraft(trimmed)
787− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
788− persistAttachments()
789− _errorEvents.trySend(ChatError(container.friendlyError(it)))
790− }
791− }
792− return true
793− }
794−
795− /** Discard a single queued (offline) message. */
796− fun discardQueued(id: String) {
797− viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
798− }
799−
800− /** Discard every queued (offline) message for this session. */
801− fun discardAllQueued() {
802− viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
803− }
804−
805− /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
806− fun flushQueued() { container.flushOutbox() }
807−
808− /** Re-send the last draft whose send failed, if any. */
809− fun retryFailed() {
810− val draft = _failedDraft.value ?: return
811− // Don't clear _failedDraft until send() accepts the text — if _running is
812− // already true, send() returns false and the draft would be lost forever.
813− // Attachments aren't re-sent on retry (they were optimistically restored to the
814− // composer on the original failure, so they'll ride the next manual send).
815− if (send(draft, includeAttachments = false)) {
816− _failedDraft.value = null
817− }
818− }
819−
820− /**
821− * Queue [text] to be sent automatically when the current run finishes, or clear any
822− * queued follow-up when [text] is blank. Used when the user taps Send while a run
823− * is already active — the Send button is replaced by Stop during a run, but the
824− * input field stays enabled, so a follow-up typed mid-run would otherwise have
825− * nowhere to go.
826− */
827− fun queueFollowUp(text: String) {
828− val trimmed = text.trim()
829− setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
830− // The text now lives in the queued chip, so clear the input field just as a
831− // normal send would. No-op for the cancel case (blank text), which must leave
832− // whatever the user has since typed untouched.
833− if (trimmed.isNotEmpty()) _draft.value = ""
834− }
835−
836− /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
837− * queued mid-run survives process death (recovered on the next open of this session).
838− * Passing null / blank clears both the in-memory value and the persisted copy. */
839− private fun setQueuedFollowUp(text: String?) {
840− _queuedFollowUp.value = text
841− container.draftStore.flushFollowUp(sessionId, text.orEmpty())
842− }
843−
844− /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
845− * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
846− * refreshMessages may not visibly change [connectionState] (it's already
847− * Connected), so without this the tap appears to do nothing. Clears after a
848− * short delay or when the connection state next becomes Connected. */
849− private val _refreshing = MutableStateFlow(false)
850− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
851−
852− /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
853− * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
854− * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
855− * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
856− * existing generation-guarded merge. Also clears [loadError] optimistically and
857− * sets [refreshing] for immediate tap feedback. */
858− fun refreshMessages() {
859− val conn = connection ?: return
860− _loadError.value = false
861− _refreshing.value = true
862− conn.events.triggerReconnect()
863− viewModelScope.launch {
864− delay(NetworkConfig.refreshFeedbackMs)
865− _refreshing.value = false
866− }
867− }
868−
869− /** Invoke a slash-command by name via the server's /command endpoint. */
870− fun runCommand(command: Command) {
871− val conn = connection ?: return
872− // A run is already in flight — surface feedback instead of silently dropping the
873− // command (the picker stays openable during a run and dismisses on select).
874− if (!_running.compareAndSet(false, true)) {
875− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
876− return
877− }
878− viewModelScope.launch {
879− runCatchingCancellable {
880− conn.repository.runCommand(sessionId, command.name, agent = command.agent)
881− }.onFailure {
882− _errorEvents.trySend(ChatError(container.friendlyError(it)))
883− _running.value = false
884− }
885− }
886− }
887−
888− fun abort() {
889− val conn = connection ?: return
890− // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
891− // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
892− setQueuedFollowUp(null)
893− if (!_aborting.compareAndSet(false, true)) return
894− viewModelScope.launch {
895− try {
896− runCatchingCancellable { conn.repository.abort(sessionId) }
897− .onSuccess { _running.value = false }
898− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
899− } finally {
900− _aborting.value = false
901− }
902− }
903− }
904−
905− fun addAttachment(attachment: PendingAttachment) {
906− _attachments.update { it + attachment }
907− persistAttachments()
908− }
909−
910− fun removeAttachment(id: String) {
911− _attachments.update { list -> list.filterNot { it.id == id } }
912− persistAttachments()
913− }
914−
915− fun clearAttachments() {
916− _attachments.value = emptyList()
917− persistAttachments()
918− }
919−
920− /** Persist the current staged attachments so an interrupted compose survives process
921− * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
922− private fun persistAttachments() {
923− val snapshot = _attachments.value
924− viewModelScope.launch {
925− runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
926− .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
927− }
928− }
929−
930− /** Revert the conversation to just before [messageId], hiding everything after it. The
931− * authoritative state also arrives via SessionUpdated; we flag it here for immediate
932− * feedback. */
933− fun revertTo(messageId: String) {
934− val conn = connection ?: return
935− viewModelScope.launch {
936− runCatchingCancellable { conn.api.revert(sessionId, messageId) }
937− .onSuccess { _reverted.value = it.isReverted }
938− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
939− }
940− }
941−
942− /** Load a previously-sent user prompt back into the composer to edit and re-send.
943− * Reverts the conversation to just before [messageId] (hiding it and everything after)
944− * and prefills the draft with [text]. The next Send continues from that checkpoint,
945− * effectively replacing the edited message; the reverted banner's Undo restores it.
946− * Overwrites the current draft — this is an explicit edit action on that message. */
947− fun editMessage(messageId: String, text: String) {
948− revertTo(messageId)
949− _draft.value = text
950− }
951−
952− /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
953− * specific message inline. Appends to any existing draft (with a blank line) rather than
954− * overwriting, so quoting doesn't discard text already being typed. */
955− fun quoteReply(text: String) {
956− val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
957− if (quoted.isBlank()) return
958− val current = _draft.value
959− _draft.value = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
960− }
961−
962− /** Fork the conversation by creating a brand-new session seeded with [text] as its first
963− * prompt (using the currently-selected model/agent). Emits the new session id via
964− * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
965− * so this starts a fresh session from the chosen prompt rather than copying history. */
966− fun branchFrom(text: String) {
967− val conn = connection ?: return
968− val prompt = text.trim()
969− if (prompt.isEmpty()) return
970− viewModelScope.launch {
971− runCatchingCancellable {
972− val session = conn.repository.createSession(title = null)
973− conn.repository.sendPrompt(
974− session.id,
975− prompt,
976− model = _selectedModel.value?.ref,
977− agent = _selectedAgent.value,
978− )
979− session.id
980− }
981− .onSuccess { newId -> _branchEvents.trySend(newId) }
982− .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
983− }
984− }
985−
986− /** Undo the active revert checkpoint, restoring the hidden messages. */
987− fun unrevert() {
988− val conn = connection ?: return
989− viewModelScope.launch {
990− runCatchingCancellable { conn.api.unrevert(sessionId) }
991− .onSuccess { _reverted.value = it.isReverted }
992− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
993− }
994− }
995−
996− /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
997− * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
998− fun shareSession() {
999− val conn = connection ?: return
1000− viewModelScope.launch {
1001− runCatchingCancellable { conn.api.shareSession(sessionId) }
1002− .onSuccess { session ->
1003− val url = session.share?.url?.takeIf { it.isNotBlank() }
1004− _shareUrl.value = url
1005− if (url != null) _shareLinkEvents.trySend(url)
1006− }
1007− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1008− }
1009− }
1010−
1011− /** Revoke the session's public share link. */
1012− fun unshareSession() {
1013− val conn = connection ?: return
1014− viewModelScope.launch {
1015− runCatchingCancellable { conn.api.unshareSession(sessionId) }
1016− .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1017− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1018− }
1019− }
1020−
1021− /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1022− * Uses the currently-selected model (required by the endpoint). */
1023− fun summarize() {
1024− val conn = connection ?: return
1025− val model = _selectedModel.value?.ref ?: run {
1026− _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1027− return
1028− }
1029− if (!_running.compareAndSet(false, true)) {
1030− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1031− return
1032− }
1033− viewModelScope.launch {
1034− runCatchingCancellable { conn.api.summarize(sessionId, model) }
1035− .onFailure {
1036− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1037− _running.value = false
1038− }
1039− }
1040− }
1041−
1042− /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1043− fun initProject() {
1044− val conn = connection ?: return
1045− if (!_running.compareAndSet(false, true)) {
1046− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1047− return
1048− }
1049− viewModelScope.launch {
1050− runCatchingCancellable { conn.api.initSession(sessionId) }
1051− .onFailure {
1052− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1053− _running.value = false
1054− }
1055− }
1056− }
1057−
1058− /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1059− * The server requires an agent to scope the run — use the selected one, else the primary. */
1060− fun runShell(command: String) {
1061− val conn = connection ?: return
1062− val cmd = command.trim()
1063− if (cmd.isEmpty()) return
1064− val agent = _selectedAgent.value
1065− ?: _agents.value.firstOrNull { it.isPrimary }?.name
1066− ?: _agents.value.firstOrNull()?.name
1067− ?: DEFAULT_SHELL_AGENT
1068− if (!_running.compareAndSet(false, true)) {
1069− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1070− return
1071− }
1072− viewModelScope.launch {
1073− runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1074− .onFailure {
1075− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1076− _running.value = false
1077− }
1078− }
1079− }
1080−
1081− /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1082− * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1083− * the caller keeps the dialog open so the user can retry without retyping. */
1084− fun renameSession(newTitle: String) {
1085− val conn = connection ?: return
1086− val title = newTitle.trim()
1087− if (title.isEmpty() || title == _sessionTitle.value) return
1088− viewModelScope.launch {
1089− runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1090− .onSuccess { _sessionTitle.value = it.displayTitle }
1091− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1092− }
1093− }
1094−
1095− /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1096− * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1097− * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1098− * already viewing the session, so a confirmation dialog guards the action instead. */
1099− fun deleteSession() {
1100− val conn = connection ?: return
1101− viewModelScope.launch {
1102− runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1103− .onSuccess {
1104− container.draftStore.remove(sessionId)
1105− container.attachmentDraftStore.remove(sessionId)
1106− container.messageCacheStore.remove(sessionId)
1107− container.outboxStore.removeForSession(sessionId)
1108− _sessionDeleted.value = true
1109− }
1110− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1111− }
1112− }
1113−
1114− /** Reconnect to the most recently used server profile (used when the connection is gone). */
1115− fun reconnect() {
1116− if (container.activeConnection.value != null) return
1117− // Guard against concurrent reconnect calls: the user can tap the button
1118− // multiple times while _reconnecting is true, which would launch parallel
1119− // connect() coroutines that race to set the active connection, leaking the
1120− // intermediate connections.
1121− if (!_reconnecting.compareAndSet(false, true)) return
1122− viewModelScope.launch {
1123− try {
1124− val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1125− .getOrDefault(emptyList())
1126− .firstOrNull()
1127− if (recent == null) {
1128− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1129− return@launch
1130− }
1131− runCatchingCancellable {
1132− val conn = container.connect(recent)
1133− conn.api.ping()
1134− }.onFailure {
1135− container.disconnect()
1136− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1137− }
1138− } finally {
1139− _reconnecting.value = false
1140− }
1141− }
1142− }
1143−
1144− fun respondPermission(permission: Permission, response: PermissionResponse) {
1145− val conn = connection ?: return
1146− // Clear optimistically; permission.replied will confirm. If the call fails we
1147− // restore the pending permission so the user can retry instead of being stuck
1148− // with a dismissed dialog and a paused tool run.
1149− _pendingPermission.value = null
1150− viewModelScope.launch {
1151− runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1152− .onFailure {
1153− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1154− // Only restore if no new permission has arrived in the meantime.
1155− if (_pendingPermission.value == null) _pendingPermission.value = permission
1156− }
1157− }
1158− }
1159−
1160− private companion object {
1161− /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1162− * loaded. opencode's default primary agent is "build". */
1163− const val DEFAULT_SHELL_AGENT = "build"
1164− }
1165−
1166− override fun onCleared() {
1167− super.onCleared()
1168− // Flush any pending debounced draft so it survives navigation. Uses an
1169− // asynchronous apply() (not a synchronous commit) so the main thread isn't
1170− // blocked on disk I/O — Android's SharedPreferences framework guarantees
1171− // pending apply() writes are flushed before the process exits.
1172− // Always flush — including an empty draft. Persistence is otherwise debounced, so if
1173− // the user cleared the input and navigated back within the debounce window, the
1174− // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1175− // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
1176− // unconditional flush commits the clear instead of resurrecting the deleted text.
1177− container.draftStore.flushDraft(sessionId, _draft.value)
1178− }
1179−}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktdeleted−202
@@ -1,202 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.foundation.lazy.LazyColumn
9−import androidx.compose.foundation.lazy.items
10−import androidx.compose.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.ktdeleted−124
@@ -1,124 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.MessageWithParts
5−import soy.iko.opencode.data.model.ReasoningPart
6−import soy.iko.opencode.data.model.TextPart
7−import soy.iko.opencode.data.model.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.ktdeleted−421
@@ -1,421 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.layout.Arrangement
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.material.icons.Icons
11−import androidx.compose.material.icons.automirrored.filled.HelpOutline
12−import androidx.compose.material.icons.automirrored.filled.VolumeUp
13−import androidx.compose.material.icons.filled.CallSplit
14−import androidx.compose.material.icons.filled.ContentCopy
15−import androidx.compose.material.icons.filled.Edit
16−import androidx.compose.material.icons.filled.FormatQuote
17−import androidx.compose.material.icons.filled.MoreVert
18−import androidx.compose.material.icons.filled.Restore
19−import androidx.compose.material.icons.filled.StopCircle
20−import androidx.compose.material3.DropdownMenu
21−import androidx.compose.material3.DropdownMenuItem
22−import androidx.compose.material3.Icon
23−import androidx.compose.material3.IconButton
24−import androidx.compose.material3.MaterialTheme
25−import androidx.compose.material3.Text
26−import androidx.compose.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.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.draw.clip
34−import androidx.compose.ui.platform.LocalContext
35−import androidx.compose.ui.res.stringResource
36−import androidx.compose.ui.unit.dp
37−import soy.iko.opencode.R
38−import soy.iko.opencode.data.model.AssistantMessage
39−import soy.iko.opencode.data.model.MessageWithParts
40−import soy.iko.opencode.data.model.ModelOption
41−import soy.iko.opencode.data.model.TextPart
42−import soy.iko.opencode.data.model.Tokens
43−import soy.iko.opencode.data.model.UnknownMessage
44−import soy.iko.opencode.data.model.UserMessage
45−import soy.iko.opencode.data.network.NetworkConfig
46−import soy.iko.opencode.ui.components.ImageLoadContext
47−import soy.iko.opencode.ui.components.copyToClipboard
48−import soy.iko.opencode.ui.components.RelativeTimeText
49−
50−/**
51− * Resolve an assistant message's model id to a friendly label from the loaded catalog,
52− * falling back to the raw id (so a model not in the catalog still shows something
53− * meaningful instead of being hidden). Returns null only when the message has no model.
54− */
55−fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
56− val id = info.modelID ?: return null
57− if (models.isEmpty()) return id
58− val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
59− return byPair?.modelLabel
60− ?: models.firstOrNull { it.modelID == id }?.modelLabel
61− ?: id
62−}
63−
64−/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
65−@Composable
66−fun MessageBubble(
67− message: MessageWithParts,
68− modifier: Modifier = Modifier,
69− isRunning: Boolean = false,
70− imageContext: ImageLoadContext? = null,
71− modelLabel: String? = null,
72− onOpenFile: ((String) -> Unit)? = null,
73− onRevert: (() -> Unit)? = null,
74− onEdit: ((String) -> Unit)? = null,
75− onSpeak: ((String) -> Unit)? = null,
76− isSpeaking: Boolean = false,
77− onQuote: ((String) -> Unit)? = null,
78− onBranch: ((String) -> Unit)? = null,
79−) {
80− when (message.info) {
81− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
82− is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
83− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
84− }
85−}
86−
87−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
88− * Shown when at least one action is available and there's text to act on. Keeps the
89− * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
90−@Composable
91−private fun MessageOverflow(
92− text: String,
93− onQuote: ((String) -> Unit)?,
94− onBranch: ((String) -> Unit)?,
95−) {
96− if (onQuote == null && onBranch == null) return
97− var expanded by remember { mutableStateOf(false) }
98− val moreLabel = stringResource(R.string.message_actions)
99− androidx.compose.foundation.layout.Box {
100− IconButton(onClick = { expanded = true }) {
101− Icon(
102− Icons.Filled.MoreVert,
103− contentDescription = moreLabel,
104− modifier = Modifier.size(18.dp),
105− tint = MaterialTheme.colorScheme.onSurfaceVariant,
106− )
107− }
108− DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
109− onQuote?.let { quote ->
110− DropdownMenuItem(
111− leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
112− text = { Text(stringResource(R.string.quote_reply)) },
113− onClick = { expanded = false; quote(text) },
114− )
115− }
116− onBranch?.let { branch ->
117− DropdownMenuItem(
118− leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
119− text = { Text(stringResource(R.string.branch_session)) },
120− onClick = { expanded = false; branch(text) },
121− )
122− }
123− }
124− }
125−}
126−
127−/** A small "revert to before this message" icon button, shown next to Copy when a revert
128− * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
129− * immediately without a confirmation dialog. */
130−@Composable
131−private fun RevertButton(onRevert: () -> Unit) {
132− val label = stringResource(R.string.revert_to_here)
133− IconButton(onClick = onRevert) {
134− Icon(
135− Icons.Filled.Restore,
136− contentDescription = label,
137− modifier = Modifier.size(18.dp),
138− tint = MaterialTheme.colorScheme.onSurfaceVariant,
139− )
140− }
141−}
142−
143−@Composable
144−private fun UnknownMessageBlock(
145− message: MessageWithParts,
146− imageContext: ImageLoadContext?,
147− modifier: Modifier,
148− onOpenFile: ((String) -> Unit)? = null,
149−) {
150− // Forward-compat: a role the client doesn't model. Render a muted note so the user
151− // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
152− // server attached, so content isn't silently dropped.
153− Column(
154− modifier = modifier.fillMaxWidth(),
155− verticalArrangement = Arrangement.spacedBy(8.dp),
156− ) {
157− Row(verticalAlignment = Alignment.CenterVertically) {
158− Icon(
159− Icons.AutoMirrored.Filled.HelpOutline,
160− contentDescription = null,
161− modifier = Modifier.size(14.dp),
162− tint = MaterialTheme.colorScheme.onSurfaceVariant,
163− )
164− Text(
165− stringResource(R.string.unknown_message),
166− style = MaterialTheme.typography.labelSmall,
167− color = MaterialTheme.colorScheme.onSurfaceVariant,
168− modifier = Modifier.padding(start = 6.dp),
169− )
170− }
171− for (part in message.parts) {
172− PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
173− }
174− MessageTimestampText(message.info)
175− }
176−}
177−
178−@Composable
179−private fun UserBubble(
180− message: MessageWithParts,
181− imageContext: ImageLoadContext?,
182− modifier: Modifier,
183− onOpenFile: ((String) -> Unit)? = null,
184− onRevert: (() -> Unit)? = null,
185− onEdit: ((String) -> Unit)? = null,
186− onQuote: ((String) -> Unit)? = null,
187− onBranch: ((String) -> Unit)? = null,
188−) {
189− val context = LocalContext.current
190− val copyLabel = stringResource(R.string.copy)
191− val editLabel = stringResource(R.string.edit_message)
192− // Collect text from all TextParts for copying, so a user can reuse/repost their
193− // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
194− val textToCopy = remember(message.parts) {
195− message.parts
196− .filterIsInstance<TextPart>()
197− .joinToString("\n\n") { it.text }
198− .takeIf { it.isNotBlank() }
199− }
200− Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
201− Column(
202− modifier = Modifier
203− .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
204− .clip(MaterialTheme.shapes.medium)
205− .background(MaterialTheme.colorScheme.primaryContainer)
206− .padding(12.dp),
207− verticalArrangement = Arrangement.spacedBy(6.dp),
208− ) {
209− for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
210− Row(
211− modifier = Modifier.fillMaxWidth(),
212− horizontalArrangement = Arrangement.SpaceBetween,
213− verticalAlignment = Alignment.CenterVertically,
214− ) {
215− MessageTimestampText(message.info)
216− Row(verticalAlignment = Alignment.CenterVertically) {
217− // Edit affordance: reload this prompt into the composer, reverting the
218− // conversation to before it so a Send replaces it. Only when there's text
219− // to edit (an image-only prompt has nothing to reload).
220− if (onEdit != null && textToCopy != null) {
221− IconButton(onClick = { onEdit(textToCopy) }) {
222− Icon(
223− Icons.Filled.Edit,
224− contentDescription = editLabel,
225− modifier = Modifier.size(18.dp),
226− tint = MaterialTheme.colorScheme.onSurfaceVariant,
227− )
228− }
229− }
230− onRevert?.let { RevertButton(it) }
231− // Copy affordance for user prompts, mirroring the assistant block.
232− // Without it, reusing a prior prompt requires discovering the
233− // long-press on the markdown text (and only TextParts support that).
234− if (textToCopy != null) {
235− IconButton(
236− onClick = { copyToClipboard(context, "message", textToCopy) },
237− ) {
238− Icon(
239− Icons.Filled.ContentCopy,
240− contentDescription = copyLabel,
241− modifier = Modifier.size(18.dp),
242− tint = MaterialTheme.colorScheme.onSurfaceVariant,
243− )
244− }
245− }
246− // Quote-reply / branch-a-new-session, tucked behind an overflow so the
247− // inline row stays compact. Only when there's prompt text to act on.
248− if (textToCopy != null) {
249− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
250− }
251− }
252− }
253− }
254− }
255−}
256−
257−@Composable
258−private fun AssistantBlock(
259− message: MessageWithParts,
260− isRunning: Boolean,
261− imageContext: ImageLoadContext?,
262− modifier: Modifier,
263− modelLabel: String? = null,
264− onOpenFile: ((String) -> Unit)? = null,
265− onRevert: (() -> Unit)? = null,
266− onSpeak: ((String) -> Unit)? = null,
267− isSpeaking: Boolean = false,
268− onQuote: ((String) -> Unit)? = null,
269−) {
270− Column(
271− modifier = modifier.fillMaxWidth(),
272− verticalArrangement = Arrangement.spacedBy(8.dp),
273− ) {
274− val info = message.info
275− if (info is AssistantMessage) {
276− val label = modelLabel ?: info.modelID
277− if (label != null) {
278− Text(
279− label,
280− style = MaterialTheme.typography.labelSmall,
281− color = MaterialTheme.colorScheme.onSurfaceVariant,
282− )
283− }
284− }
285− message.parts.forEachIndexed { index, part ->
286− // Only the final part of a running message is actively streaming; earlier parts
287− // (a finished reasoning block, a completed tool call) are already done, so passing
288− // isRunning to all of them would keep a completed reasoning block showing the
289− // "Thinking…" spinner until the whole message finishes.
290− val partStreaming = isRunning && index == message.parts.lastIndex
291− PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
292− }
293− if (info is AssistantMessage) {
294− val cost = info.cost
295− val tokens = info.tokens
296− val tokenFormat = stringResource(R.string.tokens_in_out)
297− val costShort = stringResource(R.string.cost_format_short)
298− val costLong = stringResource(R.string.cost_format_long)
299− // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
300− // state-flip recomposition doesn't re-run NumberFormat + buildList +
301− // joinToString for every visible assistant bubble.
302− val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
303− if (!info.isComplete || (cost == null && tokens == null)) null
304− else buildList {
305− // Skip an all-zero token count (e.g. a completed message that reported no
306− // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
307− tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
308− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
309− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
310− }
311− Row(
312− modifier = Modifier.fillMaxWidth(),
313− horizontalArrangement = Arrangement.SpaceBetween,
314− verticalAlignment = Alignment.CenterVertically,
315− ) {
316− Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
317− costSummary?.let {
318− Text(
319− it,
320− style = MaterialTheme.typography.labelSmall,
321− color = MaterialTheme.colorScheme.onSurfaceVariant,
322− )
323− }
324− MessageTimestampText(message.info)
325− }
326− // Collect text from all TextParts for copy/read-aloud. Memoized so a
327− // scroll-induced recomposition doesn't re-scan the parts list.
328− val textToCopy = remember(message.parts) {
329− message.parts
330− .filterIsInstance<TextPart>()
331− .joinToString("\n\n") { it.text }
332− .takeIf { it.isNotBlank() }
333− }
334− AssistantActions(
335− textToCopy = textToCopy,
336− isSpeaking = isSpeaking,
337− onSpeak = onSpeak,
338− onRevert = onRevert,
339− onQuote = onQuote,
340− )
341− }
342− } else {
343− MessageTimestampText(message.info)
344− }
345− }
346−}
347−
348−/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
349− * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
350−@Composable
351−private fun AssistantActions(
352− textToCopy: String?,
353− isSpeaking: Boolean,
354− onSpeak: ((String) -> Unit)?,
355− onRevert: (() -> Unit)?,
356− onQuote: ((String) -> Unit)? = null,
357−) {
358− val context = LocalContext.current
359− val copyLabel = stringResource(R.string.copy)
360− Row(verticalAlignment = Alignment.CenterVertically) {
361− // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
362− // icon while this message is the one being spoken.
363− if (onSpeak != null && textToCopy != null) {
364− val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
365− IconButton(onClick = { onSpeak(textToCopy) }) {
366− Icon(
367− if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
368− contentDescription = speakLabel,
369− modifier = Modifier.size(18.dp),
370− tint = if (isSpeaking) MaterialTheme.colorScheme.primary
371− else MaterialTheme.colorScheme.onSurfaceVariant,
372− )
373− }
374− }
375− onRevert?.let { RevertButton(it) }
376− if (textToCopy != null) {
377− IconButton(onClick = { copyToClipboard(context, "message", textToCopy) }) {
378− Icon(
379− Icons.Filled.ContentCopy,
380− contentDescription = copyLabel,
381− modifier = Modifier.size(18.dp),
382− tint = MaterialTheme.colorScheme.onSurfaceVariant,
383− )
384− }
385− }
386− // Assistant replies can be quoted into the composer (branching is offered only on
387− // user prompts, where seeding a new session with the prompt makes sense).
388− if (textToCopy != null && onQuote != null) {
389− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
390− }
391− }
392−}
393−
394−@Composable
395−private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
396− // Compact relative label by default; long-press reveals the full absolute timestamp
397− // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
398− RelativeTimeText(info.time?.created)
399−}
400−
401−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
402−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
403−// when the message list re-seeds after a reconnect and every visible assistant bubble
404−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
405−// not thread-safe.
406−private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
407− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
408−}
409−
410−private fun formatTokens(tokens: Tokens, format: String): String {
411− val nf = tokenNumberFormat.get()!!
412− return format.format(nf.format(tokens.input), nf.format(tokens.output))
413−}
414−
415−private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
416− // Locale.US so the formatting is stable regardless of device locale (avoids
417− // non-ASCII digits or comma decimal separators in a dollar amount). The format
418− // string itself is localized via strings.xml so the currency symbol can be
419− // adapted by translators.
420− if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
421− else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktdeleted−180
@@ -1,180 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.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.ktdeleted−498
@@ -1,498 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.foundation.background
5−import androidx.compose.foundation.clickable
6−import androidx.compose.foundation.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.TODO_WRITE_TOOL
63−import soy.iko.opencode.data.model.UnknownPart
64−import soy.iko.opencode.data.model.inputElement
65−import soy.iko.opencode.data.model.parseTodos
66−import soy.iko.opencode.ui.components.DiffView
67−import soy.iko.opencode.ui.components.ImageLoadContext
68−import soy.iko.opencode.ui.components.MarkdownText
69−import soy.iko.opencode.ui.components.RemoteImage
70−import soy.iko.opencode.ui.components.copyToClipboard
71−import soy.iko.opencode.ui.components.isImage
72−import soy.iko.opencode.ui.components.looksLikeDiff
73−import soy.iko.opencode.ui.components.showToast
74−import soy.iko.opencode.R
75−
76−private const val COLLAPSED_LIMIT = 600
77−
78−// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
79−// resilient decoding) so this stays human-readable; constructed lazily and memoized.
80−private val prettyJson: Json = Json { prettyPrint = true }
81−
82−/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
83−private fun ToolState.titleText(): String? = when (this) {
84− is ToolRunning -> title
85− is ToolCompleted -> title
86− else -> null
87−}
88−
89−/**
90− * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
91− * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
92− *
93− * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
94− * source path the viewer can open; null leaves the chip as a copy-path affordance
95− * (used when no file navigation is wired, e.g. in tests).
96− */
97−@Composable
98−fun PartView(
99− part: Part,
100− modifier: Modifier = Modifier,
101− isRunning: Boolean = false,
102− imageContext: ImageLoadContext? = null,
103− onOpenFile: ((path: String) -> Unit)? = null,
104−) {
105− when (part) {
106− is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
107− MarkdownText(part.text, modifier = modifier, streaming = isRunning)
108− }
109− is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
110− is ToolPart -> ToolCallView(part, modifier)
111− is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
112− RemoteImage(part, imageContext, modifier)
113− } else {
114− FileChip(part, modifier, onOpenFile)
115− }
116− is StepStartPart -> {} // boundary marker — nothing to draw
117− is StepFinishPart -> {} // metrics handled at message level
118− is UnknownPart -> UnknownPartNote(modifier)
119− }
120−}
121−
122−@Composable
123−private fun UnknownPartNote(modifier: Modifier) {
124− // Forward-compat: a part type the client doesn't model. Render a muted note (matching
125− // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
126− // the part vanishing silently — important when a new server release adds a part type.
127− Row(
128− modifier = modifier.fillMaxWidth(),
129− verticalAlignment = Alignment.CenterVertically,
130− ) {
131− Icon(
132− Icons.AutoMirrored.Filled.HelpOutline,
133− contentDescription = null,
134− modifier = Modifier.size(14.dp),
135− tint = MaterialTheme.colorScheme.onSurfaceVariant,
136− )
137− Text(
138− stringResource(R.string.unknown_part),
139− style = MaterialTheme.typography.labelSmall,
140− color = MaterialTheme.colorScheme.onSurfaceVariant,
141− modifier = Modifier.padding(start = 6.dp),
142− )
143− }
144−}
145−
146−@Composable
147−private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
148− if (text.isBlank()) return
149− val context = LocalContext.current
150− // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
151− // inherit the positional saveable key of a prior block. A content-prefix key would
152− // change on nearly every token while streaming and snap the block back to collapsed;
153− // fall back to a content prefix only when the part has no id.
154− val saveableKey = keyId.ifBlank { text.take(64) }
155− var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
156− val expandedState = stringResource(R.string.state_expanded)
157− val collapsedState = stringResource(R.string.state_collapsed)
158− val thinkingLabel = stringResource(R.string.thinking)
159− val thoughtsLabel = stringResource(R.string.thoughts)
160− Column(modifier = modifier.fillMaxWidth()) {
161− Row(
162− modifier = Modifier
163− .fillMaxWidth()
164− .defaultMinSize(minHeight = 48.dp)
165− .clickable(role = Role.Button) { expanded = !expanded }
166− .padding(vertical = 4.dp)
167− .semantics {
168− stateDescription = if (expanded) expandedState else collapsedState
169− },
170− verticalAlignment = Alignment.CenterVertically,
171− ) {
172− if (streaming) {
173− CircularProgressIndicator(
174− Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
175− strokeWidth = 2.dp,
176− color = MaterialTheme.colorScheme.onSurfaceVariant,
177− )
178− } else {
179− Icon(
180− Icons.Filled.Psychology,
181− contentDescription = null,
182− modifier = Modifier.size(16.dp),
183− tint = MaterialTheme.colorScheme.onSurfaceVariant,
184− )
185− }
186− Text(
187− if (streaming) thinkingLabel else thoughtsLabel,
188− style = MaterialTheme.typography.labelMedium,
189− color = MaterialTheme.colorScheme.onSurfaceVariant,
190− modifier = Modifier.padding(start = 6.dp),
191− )
192− }
193− AnimatedVisibility(visible = expanded) {
194− Column {
195− // Wrap in SelectionContainer so the user can select a portion of the
196− // reasoning (e.g. a single step) instead of the all-or-nothing copy
197− // button below. Matches the markdown text's selectability.
198− SelectionContainer {
199− Text(
200− text,
201− style = MaterialTheme.typography.bodySmall,
202− color = MaterialTheme.colorScheme.onSurfaceVariant,
203− )
204− }
205− TextButton(
206− onClick = { copyToClipboard(context, "reasoning", text) },
207− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
208− modifier = Modifier.semantics(mergeDescendants = true) {},
209− ) {
210− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
211− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
212− }
213− }
214− }
215− }
216−}
217−
218−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
219−@Composable
220−private fun ToolCallView(part: ToolPart, modifier: Modifier) {
221− val context = LocalContext.current
222− Column(
223− modifier = modifier
224− .fillMaxWidth()
225− .clip(MaterialTheme.shapes.small)
226− .background(MaterialTheme.colorScheme.surfaceVariant)
227− .padding(10.dp),
228− ) {
229− Row(verticalAlignment = Alignment.CenterVertically) {
230− ToolStatusIcon(part.state)
231− Text(
232− part.tool,
233− style = MaterialTheme.typography.labelLarge,
234− fontFamily = FontFamily.Monospace,
235− modifier = Modifier.padding(start = 6.dp),
236− )
237− }
238− // A human-readable summary of what the tool was asked to do (e.g. "Reading
239− // src/main.kt"), when the server provides one. Sits directly under the tool
240− // name so the user understands the call before diving into input/output.
241− part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
242− Text(
243− title,
244− style = MaterialTheme.typography.bodySmall,
245− color = MaterialTheme.colorScheme.onSurfaceVariant,
246− modifier = Modifier.padding(start = 22.dp, top = 2.dp),
247− )
248− }
249− // A todowrite call carries the agent's task plan as its input; render it as a
250− // checklist rather than raw JSON so each step's progress reads at a glance.
251− val planTodos = remember(part) {
252− if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
253− }
254− if (planTodos.isNotEmpty()) {
255− TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
256− } else {
257− // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
258− // output so the call reads top-to-bottom: name → what → input → result.
259− part.state.inputElement()?.let { input ->
260− val inputLabel = stringResource(R.string.tool_input)
261− val pretty = remember(input) {
262− runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
263− }
264− CollapsibleDetail(
265− label = inputLabel,
266− detail = pretty,
267− isDiff = false,
268− keySuffix = "input",
269− // The input block has a label row whose collapse toggle announces its state
270− // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
271− // rather than an empty state (the defaults are "").
272− expandedState = stringResource(R.string.state_expanded),
273− collapsedState = stringResource(R.string.state_collapsed),
274− )
275− }
276− }
277− val detail = when (val s = part.state) {
278− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
279− is ToolError -> s.error ?: stringResource(R.string.error_generic)
280− else -> null
281− }
282− if (detail != null) {
283− val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
284− var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
285− val expandedState = stringResource(R.string.state_expanded)
286− val collapsedState = stringResource(R.string.state_collapsed)
287− val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
288− // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
289− // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
290− val isDiff = remember(display) { looksLikeDiff(display) }
291− // How many lines are hidden while collapsed. detail.lines() splits the whole
292− // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
293− // every recomposition, including each streaming update of a running tool.
294− // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
295− // 0 (a single long line cut mid-way) falls back to the generic label.
296− val moreLines = remember(detail, collapsed) {
297− if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
298− }
299− CollapsibleDetail(
300− label = null,
301− detail = display,
302− isDiff = isDiff,
303− keySuffix = "output",
304− expanded = expanded || detail.length <= COLLAPSED_LIMIT,
305− onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
306− { expanded = !expanded }
307− } else null,
308− moreLines = moreLines,
309− expandedState = expandedState,
310− collapsedState = collapsedState,
311− onCopy = { copyToClipboard(context, "output", detail) },
312− )
313− }
314− }
315−}
316−
317−/**
318− * A collapsible monospace detail block shared by tool input and output. [label] is an
319− * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
320− * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
321− * with an expand/collapse affordance when [onToggleExpand] is supplied.
322− */
323−@Composable
324−private fun CollapsibleDetail(
325− label: String?,
326− detail: String,
327− isDiff: Boolean,
328− keySuffix: String,
329− expanded: Boolean = false,
330− onToggleExpand: (() -> Unit)? = null,
331− moreLines: Int = 0,
332− expandedState: String = "",
333− collapsedState: String = "",
334− onCopy: (() -> Unit)? = null,
335−) {
336− Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
337− if (label != null) {
338− var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
339− Row(
340− modifier = Modifier
341− .fillMaxWidth()
342− .defaultMinSize(minHeight = 48.dp)
343− .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
344− .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
345− verticalAlignment = Alignment.CenterVertically,
346− ) {
347− Icon(
348− if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
349− contentDescription = null,
350− modifier = Modifier.size(16.dp),
351− tint = MaterialTheme.colorScheme.onSurfaceVariant,
352− )
353− Text(
354− label,
355− style = MaterialTheme.typography.labelSmall,
356− color = MaterialTheme.colorScheme.onSurfaceVariant,
357− modifier = Modifier.padding(start = 4.dp),
358− )
359− }
360− if (!inputExpanded) return@Column
361− }
362− if (isDiff) {
363− DiffView(detail)
364− } else {
365− // SelectionContainer so the user can select a portion of the output (e.g.
366− // a single line of stdout) instead of only copy-all via the button below.
367− SelectionContainer {
368− Text(
369− detail,
370− style = MaterialTheme.typography.bodySmall,
371− fontFamily = FontFamily.Monospace,
372− )
373− }
374− }
375− if (onToggleExpand != null) {
376− TextButton(
377− onClick = onToggleExpand,
378− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
379− modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
380− ) {
381− Icon(
382− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
383− contentDescription = null,
384− modifier = Modifier.size(16.dp),
385− )
386− Text(
387− when {
388− expanded -> stringResource(R.string.show_less)
389− moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
390− else -> stringResource(R.string.show_more)
391− },
392− style = MaterialTheme.typography.labelSmall,
393− modifier = Modifier.padding(start = 4.dp),
394− )
395− }
396− }
397− if (onCopy != null) {
398− TextButton(
399− onClick = onCopy,
400− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
401− modifier = Modifier.semantics(mergeDescendants = true) {},
402− ) {
403− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
404− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
405− }
406− }
407− }
408−}
409−
410−@Composable
411−private fun ToolStatusIcon(state: ToolState) {
412− when (state) {
413− is ToolPending, is ToolRunning -> {
414− val label = stringResource(R.string.tool_running)
415− CircularProgressIndicator(
416− modifier = Modifier.size(16.dp).semantics { contentDescription = label },
417− strokeWidth = 2.dp,
418− )
419− }
420− is ToolCompleted ->
421− Icon(
422− Icons.Filled.CheckCircle,
423− contentDescription = stringResource(R.string.tool_completed),
424− modifier = Modifier.size(16.dp),
425− tint = MaterialTheme.colorScheme.primary,
426− )
427− is ToolError ->
428− Icon(
429− Icons.Filled.Error,
430− contentDescription = stringResource(R.string.tool_error),
431− modifier = Modifier.size(16.dp),
432− tint = MaterialTheme.colorScheme.error,
433− )
434− is ToolUnknown ->
435− Icon(
436− Icons.Filled.Bolt,
437− contentDescription = stringResource(R.string.tool_unknown),
438− modifier = Modifier.size(16.dp),
439− )
440− }
441−}
442−
443−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
444−@Composable
445−private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
446− val context = LocalContext.current
447− val path = part.source ?: part.url ?: part.filename
448− val copyLabel = stringResource(R.string.copy_path)
449− val openLabel = stringResource(R.string.open_file)
450− // When a source path is available and a navigation callback is wired, tapping the
451− // chip opens the file in the viewer (the action a user tapping a file reference
452− // most likely expects). Long-press still copies the path to the clipboard, so the
453− // copy affordance is preserved without being the default tap action. When no
454− // navigation is wired (e.g. tests), falls back to copy-path on tap.
455− val source = part.source
456− val opener = onOpenFile
457− val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
458− val canOpen = openPath != null
459− val semanticsLabel = if (canOpen) openLabel else copyLabel
460− Row(
461− modifier = modifier
462− .clip(MaterialTheme.shapes.small)
463− .background(MaterialTheme.colorScheme.surfaceVariant)
464− .combinedClickable(
465− role = Role.Button,
466− onClick = {
467− val toOpen = openPath
468− if (toOpen != null) {
469− opener?.invoke(toOpen)
470− } else if (!path.isNullOrBlank()) {
471− copyToClipboard(context, "path", path)
472− showToast(context, context.getString(R.string.path_copied))
473− }
474− },
475− onLongClick = {
476− if (!path.isNullOrBlank()) {
477− copyToClipboard(context, "path", path)
478− showToast(context, context.getString(R.string.path_copied))
479− }
480− },
481− )
482− .semantics { contentDescription = semanticsLabel }
483− .padding(horizontal = 12.dp, vertical = 6.dp),
484− verticalAlignment = Alignment.CenterVertically,
485− ) {
486− Icon(
487− Icons.Filled.Description,
488− contentDescription = null,
489− modifier = Modifier.size(16.dp),
490− tint = MaterialTheme.colorScheme.onSurfaceVariant,
491− )
492− Text(
493− part.filename ?: part.url ?: stringResource(R.string.file),
494− style = MaterialTheme.typography.bodyMedium,
495− modifier = Modifier.padding(start = 6.dp),
496− )
497− }
498−}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktdeleted−118
@@ -1,118 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.padding
8−import androidx.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/chat/TodoPlan.ktdeleted−165
@@ -1,165 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.foundation.clickable
5−import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.Spacer
9−import androidx.compose.foundation.layout.defaultMinSize
10−import androidx.compose.foundation.layout.fillMaxWidth
11−import androidx.compose.foundation.layout.heightIn
12−import androidx.compose.foundation.layout.padding
13−import androidx.compose.foundation.layout.size
14−import androidx.compose.foundation.rememberScrollState
15−import androidx.compose.foundation.verticalScroll
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.filled.Autorenew
18−import androidx.compose.material.icons.filled.Cancel
19−import androidx.compose.material.icons.filled.CheckCircle
20−import androidx.compose.material.icons.filled.Checklist
21−import androidx.compose.material.icons.filled.ExpandLess
22−import androidx.compose.material.icons.filled.ExpandMore
23−import androidx.compose.material.icons.filled.RadioButtonUnchecked
24−import androidx.compose.material3.Icon
25−import androidx.compose.material3.MaterialTheme
26−import androidx.compose.material3.Surface
27−import androidx.compose.material3.Text
28−import androidx.compose.runtime.Composable
29−import androidx.compose.runtime.getValue
30−import androidx.compose.runtime.mutableStateOf
31−import androidx.compose.runtime.remember
32−import androidx.compose.runtime.saveable.rememberSaveable
33−import androidx.compose.runtime.setValue
34−import androidx.compose.ui.Alignment
35−import androidx.compose.ui.Modifier
36−import androidx.compose.ui.res.stringResource
37−import androidx.compose.ui.semantics.Role
38−import androidx.compose.ui.semantics.semantics
39−import androidx.compose.ui.semantics.stateDescription
40−import androidx.compose.ui.text.font.FontWeight
41−import androidx.compose.ui.text.style.TextDecoration
42−import androidx.compose.ui.unit.dp
43−import soy.iko.opencode.R
44−import soy.iko.opencode.data.model.TodoItem
45−import soy.iko.opencode.data.model.TodoStatus
46−import soy.iko.opencode.data.model.statusEnum
47−
48−/** Renders a task plan as a checklist: a status icon per row plus its description, with
49− * completed/cancelled items struck through and the in-progress item emphasized. */
50−@Composable
51−fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
52− Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
53− for (todo in todos) TodoRow(todo)
54− }
55−}
56−
57−@Composable
58−private fun TodoRow(todo: TodoItem) {
59− val status = todo.statusEnum()
60− val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
61− Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
62− TodoStatusIcon(status)
63− Text(
64− todo.content,
65− style = MaterialTheme.typography.bodySmall,
66− color = when {
67− status == TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
68− struck -> MaterialTheme.colorScheme.onSurfaceVariant
69− else -> MaterialTheme.colorScheme.onSurface
70− },
71− fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
72− textDecoration = if (struck) TextDecoration.LineThrough else null,
73− modifier = Modifier.padding(start = 8.dp),
74− )
75− }
76−}
77−
78−@Composable
79−private fun TodoStatusIcon(status: TodoStatus) {
80− val icon = when (status) {
81− TodoStatus.COMPLETED -> Icons.Filled.CheckCircle
82− TodoStatus.IN_PROGRESS -> Icons.Filled.Autorenew
83− TodoStatus.CANCELLED -> Icons.Filled.Cancel
84− else -> Icons.Filled.RadioButtonUnchecked
85− }
86− val tint = when (status) {
87− TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
88− else -> MaterialTheme.colorScheme.onSurfaceVariant
89− }
90− val desc = when (status) {
91− TodoStatus.COMPLETED -> R.string.todo_completed
92− TodoStatus.IN_PROGRESS -> R.string.todo_in_progress
93− TodoStatus.CANCELLED -> R.string.todo_cancelled
94− else -> R.string.todo_pending
95− }
96− Icon(
97− icon,
98− contentDescription = stringResource(desc),
99− tint = tint,
100− modifier = Modifier.size(16.dp),
101− )
102−}
103−
104−/** A collapsible plan bar pinned above the composer: the header shows completed/total and
105− * a chevron; expanding reveals the full (scrollable, height-capped) checklist. Renders
106− * nothing when there's no plan, so the caller can place it unconditionally. */
107−@Composable
108−fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
109− if (todos.isEmpty()) return
110− var expanded by rememberSaveable { mutableStateOf(false) }
111− val completed = remember(todos) { todos.count { it.statusEnum() == TodoStatus.COMPLETED } }
112− val expandedState = stringResource(R.string.state_expanded)
113− val collapsedState = stringResource(R.string.state_collapsed)
114− Surface(
115− color = MaterialTheme.colorScheme.surfaceVariant,
116− tonalElevation = 2.dp,
117− modifier = modifier.fillMaxWidth(),
118− ) {
119− Column {
120− Row(
121− modifier = Modifier
122− .fillMaxWidth()
123− .defaultMinSize(minHeight = 44.dp)
124− .clickable(role = Role.Button) { expanded = !expanded }
125− .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126− .padding(horizontal = 12.dp, vertical = 6.dp),
127− verticalAlignment = Alignment.CenterVertically,
128− ) {
129− Icon(
130− Icons.Filled.Checklist,
131− contentDescription = null,
132− tint = MaterialTheme.colorScheme.onSurfaceVariant,
133− modifier = Modifier.size(18.dp),
134− )
135− Text(
136− stringResource(R.string.plan),
137− style = MaterialTheme.typography.labelLarge,
138− modifier = Modifier.padding(start = 8.dp),
139− )
140− Text(
141− stringResource(R.string.plan_progress, completed, todos.size),
142− style = MaterialTheme.typography.labelMedium,
143− color = MaterialTheme.colorScheme.onSurfaceVariant,
144− modifier = Modifier.padding(start = 8.dp),
145− )
146− Spacer(Modifier.weight(1f))
147− Icon(
148− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
149− contentDescription = null,
150− tint = MaterialTheme.colorScheme.onSurfaceVariant,
151− modifier = Modifier.size(20.dp),
152− )
153− }
154− AnimatedVisibility(visible = expanded) {
155− TodoPlanChecklist(
156− todos,
157− modifier = Modifier
158− .heightIn(max = 240.dp)
159− .verticalScroll(rememberScrollState())
160− .padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
161− )
162− }
163− }
164− }
165−}
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktdeleted−79
@@ -1,79 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import android.content.Context
4−import android.os.Handler
5−import android.os.Looper
6−import android.speech.tts.TextToSpeech
7−import android.speech.tts.UtteranceProgressListener
8−import androidx.compose.runtime.Composable
9−import androidx.compose.runtime.DisposableEffect
10−import androidx.compose.runtime.State
11−import androidx.compose.runtime.mutableStateOf
12−import androidx.compose.runtime.remember
13−import androidx.compose.ui.platform.LocalContext
14−
15−/**
16− * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17− * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
18− */
19−@Composable
20−fun rememberTtsController(): TtsController {
21− val context = LocalContext.current
22− val controller = remember { TtsController(context.applicationContext) }
23− DisposableEffect(Unit) { onDispose { controller.shutdown() } }
24− return controller
25−}
26−
27−/**
28− * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
29− * [speakingId] — the id of the message currently being spoken, or null — so the UI can show
30− * a play/stop toggle. [toggle] plays a message or stops it if it's already the one speaking.
31− *
32− * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
33− * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
34− * so state is cleared on the main thread via [mainHandler].
35− */
36−class TtsController(context: Context) {
37−
38− private val mainHandler = Handler(Looper.getMainLooper())
39− private var ready = false
40−
41− private val _speakingId = mutableStateOf<String?>(null)
42− val speakingId: State<String?> = _speakingId
43−
44− private val tts: TextToSpeech = TextToSpeech(context) { status ->
45− ready = status == TextToSpeech.SUCCESS
46− }.apply {
47− setOnUtteranceProgressListener(object : UtteranceProgressListener() {
48− override fun onStart(utteranceId: String?) { /* speakingId already set by toggle() */ }
49− override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
50− @Deprecated("Deprecated in Java", ReplaceWith(""))
51− override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
52− override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
53− })
54− }
55−
56− /** Clear the speaking state when the utterance that finished is the one we're tracking. */
57− private fun clearIfCurrent(utteranceId: String?) {
58− mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
59− }
60−
61− /** Speak [text] for message [id], or stop if [id] is already the one being spoken. */
62− fun toggle(id: String, text: String) {
63− if (_speakingId.value == id) { stop(); return }
64− if (!ready || text.isBlank()) return
65− tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, id)
66− _speakingId.value = id
67− }
68−
69− fun stop() {
70− tts.stop()
71− _speakingId.value = null
72− }
73−
74− fun shutdown() {
75− tts.stop()
76− tts.shutdown()
77− _speakingId.value = null
78− }
79−}
app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktdeleted−51
@@ -1,51 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.runtime.compositionLocalOf
4−import androidx.compose.material3.Typography
5−
6−/**
7− * Chat presentation preferences, threaded through the composition so the markdown and
8− * code renderers can honor them without every call site plumbing them explicitly.
9− *
10− * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
11− * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
12− *
13− * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
14− * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
15− */
16−val LocalChatTextScale = compositionLocalOf { 1f }
17−val LocalCodeWrap = compositionLocalOf { false }
18−
19−/**
20− * Return a copy of this [Typography] with every text role's font size and line height
21− * multiplied by [scale]. Used to scale a whole markdown subtree (headings, lists, code,
22− * body) from a single user preference. Returns the receiver unchanged when [scale] is 1.
23− */
24−fun Typography.scaledBy(scale: Float): Typography {
25− if (scale == 1f) return this
26− fun androidx.compose.ui.text.TextStyle.scaled() = copy(
27− fontSize = if (fontSize.isSp) fontSize * scale else fontSize,
28− lineHeight = if (lineHeight.isSp) lineHeight * scale else lineHeight,
29− )
30− return copy(
31− displayLarge = displayLarge.scaled(),
32− displayMedium = displayMedium.scaled(),
33− displaySmall = displaySmall.scaled(),
34− headlineLarge = headlineLarge.scaled(),
35− headlineMedium = headlineMedium.scaled(),
36− headlineSmall = headlineSmall.scaled(),
37− titleLarge = titleLarge.scaled(),
38− titleMedium = titleMedium.scaled(),
39− titleSmall = titleSmall.scaled(),
40− bodyLarge = bodyLarge.scaled(),
41− bodyMedium = bodyMedium.scaled(),
42− bodySmall = bodySmall.scaled(),
43− labelLarge = labelLarge.scaled(),
44− labelMedium = labelMedium.scaled(),
45− labelSmall = labelSmall.scaled(),
46− )
47−}
48−
49−/** True when this [androidx.compose.ui.unit.TextUnit] is expressed in sp (safe to scale). */
50−private val androidx.compose.ui.unit.TextUnit.isSp: Boolean
51− get() = type == androidx.compose.ui.unit.TextUnitType.Sp
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktdeleted−286
@@ -1,286 +0,0 @@
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/CommandPalette.ktdeleted−102
@@ -1,102 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.foundation.lazy.LazyColumn
9−import androidx.compose.foundation.lazy.items
10−import androidx.compose.material3.MaterialTheme
11−import androidx.compose.material3.OutlinedTextField
12−import androidx.compose.material3.Surface
13−import androidx.compose.material3.Text
14−import androidx.compose.runtime.Composable
15−import androidx.compose.runtime.LaunchedEffect
16−import androidx.compose.runtime.getValue
17−import androidx.compose.runtime.mutableStateOf
18−import androidx.compose.runtime.remember
19−import androidx.compose.runtime.setValue
20−import androidx.compose.ui.Modifier
21−import androidx.compose.ui.focus.focusRequester
22−import androidx.compose.ui.input.key.Key
23−import androidx.compose.ui.input.key.KeyEventType
24−import androidx.compose.ui.input.key.key
25−import androidx.compose.ui.input.key.onPreviewKeyEvent
26−import androidx.compose.ui.input.key.type
27−import androidx.compose.ui.res.stringResource
28−import androidx.compose.ui.unit.dp
29−import androidx.compose.ui.window.Dialog
30−import soy.iko.opencode.R
31−
32−/**
33− * A keyboard-first command palette: a search field over a list of [actions], filtered as the
34− * user types. Enter runs the first match, tap runs any entry; the field auto-focuses so a
35− * hardware-keyboard user can type immediately after opening it. Dismissed via back/scrim, or
36− * Escape (handled by the opener). Selecting an action dismisses first, then runs it, so a
37− * follow-on dialog isn't immediately covered by the palette.
38− */
39−@Composable
40−fun CommandPalette(
41− actions: List<PaletteAction>,
42− onDismiss: () -> Unit,
43−) {
44− var query by remember { mutableStateOf("") }
45− val filtered = remember(query, actions) {
46− if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
47− }
48− val focus = remember { androidx.compose.ui.focus.FocusRequester() }
49− fun run(action: PaletteAction) {
50− onDismiss()
51− action.onSelect()
52− }
53− Dialog(onDismissRequest = onDismiss) {
54− Surface(
55− shape = MaterialTheme.shapes.large,
56− tonalElevation = 6.dp,
57− color = MaterialTheme.colorScheme.surface,
58− ) {
59− Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
60− OutlinedTextField(
61− value = query,
62− onValueChange = { query = it },
63− placeholder = { Text(stringResource(R.string.palette_hint)) },
64− singleLine = true,
65− modifier = Modifier
66− .fillMaxWidth()
67− .focusRequester(focus)
68− .onPreviewKeyEvent { ev ->
69− if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) {
70− filtered.firstOrNull()?.let { run(it) }
71− true
72− } else {
73− false
74− }
75− },
76− )
77− LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
78− if (filtered.isEmpty()) {
79− Text(
80− stringResource(R.string.palette_no_results),
81− style = MaterialTheme.typography.bodyMedium,
82− color = MaterialTheme.colorScheme.onSurfaceVariant,
83− modifier = Modifier.padding(16.dp),
84− )
85− } else {
86− LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
87− items(filtered, key = { it.id }) { action ->
88− Text(
89− action.label,
90− style = MaterialTheme.typography.bodyLarge,
91− modifier = Modifier
92− .fillMaxWidth()
93− .clickable { run(action) }
94− .padding(horizontal = 12.dp, vertical = 14.dp),
95− )
96− }
97− }
98− }
99− }
100− }
101− }
102−}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktdeleted−121
@@ -1,121 +0,0 @@
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.ktdeleted−222
@@ -1,222 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.horizontalScroll
5−import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.Spacer
9−import androidx.compose.foundation.layout.fillMaxWidth
10−import androidx.compose.foundation.layout.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.ktdeleted−294
@@ -1,294 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.foundation.background
8−import androidx.compose.foundation.horizontalScroll
9−import androidx.compose.foundation.layout.Arrangement
10−import androidx.compose.foundation.layout.Box
11−import androidx.compose.foundation.layout.Column
12−import androidx.compose.foundation.layout.Row
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.rememberScrollState
17−import androidx.compose.foundation.text.selection.SelectionContainer
18−import androidx.compose.material.icons.Icons
19−import androidx.compose.material.icons.filled.ContentCopy
20−import androidx.compose.material.icons.automirrored.filled.WrapText
21−import androidx.compose.material3.Icon
22−import androidx.compose.material3.IconButton
23−import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.Surface
25−import androidx.compose.material3.Text
26−import androidx.compose.material3.Typography
27−import androidx.compose.runtime.Composable
28−import androidx.compose.runtime.LaunchedEffect
29−import androidx.compose.runtime.getValue
30−import androidx.compose.runtime.mutableStateOf
31−import androidx.compose.runtime.remember
32−import androidx.compose.runtime.rememberUpdatedState
33−import androidx.compose.runtime.setValue
34−import androidx.compose.runtime.snapshotFlow
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.draw.clip
38−import androidx.compose.ui.platform.LocalContext
39−import androidx.compose.ui.text.TextStyle
40−import androidx.compose.ui.text.font.FontFamily
41−import androidx.compose.ui.unit.dp
42−import kotlinx.coroutines.flow.conflate
43−import kotlinx.coroutines.delay
44−import com.mikepenz.markdown.compose.components.MarkdownComponentModel
45−import com.mikepenz.markdown.compose.components.markdownComponents
46−import com.mikepenz.markdown.m3.Markdown
47−import org.intellij.markdown.MarkdownElementTypes
48−import org.intellij.markdown.ast.getTextInNode
49−import soy.iko.opencode.R
50−import soy.iko.opencode.data.network.NetworkConfig
51−
52−/**
53− * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
54− * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
55− * Code blocks/fences get an inline copy button.
56− *
57− * The rendered text is wrapped in a [SelectionContainer] so the user can select and
58− * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
59− * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
60− * copies all TextParts; this complements it with partial selection.
61− *
62− * During streaming, the full markdown is re-parsed on every token (the library re-parses
63− * whenever the content string changes). To avoid O(n²) work during long responses, the
64− * rendered content is throttled — the latest [markdown] is committed to the renderer at
65− * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
66− *
67− * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
68− * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
69− * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
70− * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
71− * starving the render and showing stale content until the stream pauses. The conflated
72− * `collect` lets the delay run to completion, then picks up the most recent buffered value,
73− * so rendering always progresses even under continuous fast streaming.
74− *
75− * When [streaming] is false (the common case: every non-active message in the chat list),
76− * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
77− * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
78− */
79−@Composable
80−fun MarkdownText(
81− markdown: String,
82− modifier: Modifier = Modifier,
83− @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
84− streaming: Boolean = false,
85−) {
86− val scale = LocalChatTextScale.current
87− if (scale == 1f) {
88− MarkdownBody(markdown, modifier, streaming)
89− } else {
90− // Scale the entire markdown subtree (headings, lists, code, body) from the user's
91− // chat text-size preference by nesting a MaterialTheme with a scaled typography:
92− // the renderer derives its typography from MaterialTheme, so this reaches every
93− // element without plumbing a scale through the library. colorScheme/shapes default
94− // to the current theme's, so only sizes change. Memoized so a recomposition that
95− // doesn't change the scale doesn't rebuild the 15-role Typography.
96− val base = MaterialTheme.typography
97− val scaled = remember(scale, base) { base.scaledBy(scale) }
98− MaterialTheme(typography = scaled) {
99− MarkdownBody(markdown, modifier, streaming)
100− }
101− }
102−}
103−
104−@Composable
105−private fun MarkdownBody(
106− markdown: String,
107− modifier: Modifier = Modifier,
108− streaming: Boolean = false,
109−) {
110− val components = remember {
111− markdownComponents(
112− codeFence = { CodeWithCopy(it) },
113− codeBlock = { CodeWithCopy(it) },
114− )
115− }
116− if (streaming) {
117− // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
118− val markdownState = rememberUpdatedState(markdown)
119− // Keyed to a content-prefix so switching to a different message resets
120− // immediately instead of showing the old message for one frame.
121− val prefix = markdown.take(32)
122− var renderedContent by remember(prefix) { mutableStateOf(markdown) }
123− // conflate() coalesces a burst of token updates into a single emission so the
124− // collector sees only the latest value after the previous delay completes. Plain
125− // collect (not collectLatest) is critical: collectLatest would cancel the delay
126− // on every new value, reintroducing the same starvation the keyed effect had.
127− LaunchedEffect(prefix) {
128− snapshotFlow { markdownState.value }
129− .conflate()
130− .collect { md ->
131− // Only throttle when the content is growing incrementally (streaming):
132− // a content switch or initial render proceeds immediately. Checking
133− // length is O(1) vs. startsWith which is O(n) in the rendered content
134− // length — during a long streaming response this runs every throttle
135− // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
136− // content switches; within one keyed cycle, growing length means
137− // appending (streaming), not a rewrite.
138− if (renderedContent.isNotEmpty() &&
139− md.length > renderedContent.length
140− ) {
141− delay(NetworkConfig.streamingThrottleMs)
142− }
143− renderedContent = md
144− }
145− }
146− // SelectionContainer is intentionally omitted during streaming: the content is
147− // changing every ~50ms, and an active selection would be invalidated (and the
148− // selection handles would flicker) on each throttle commit. Partial selection
149− // becomes available once the stream finishes; the per-message Copy button in
150− // MessageBubble copies all TextParts at any time.
151− Markdown(
152− content = renderedContent,
153− modifier = modifier,
154− components = components,
155− )
156− } else {
157− SelectionContainer {
158− Markdown(
159− content = markdown,
160− modifier = modifier,
161− components = components,
162− )
163− }
164− }
165−}
166−
167−/**
168− * Renders a fenced or indented code block with a header toolbar: the fence's language
169− * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
170− * copy button. Self-contained so it doesn't depend on the library's internal code-block
171− * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
172− * be flipped per-block.
173− */
174−@Composable
175−private fun CodeWithCopy(model: MarkdownComponentModel) {
176− val context = LocalContext.current
177− // Key on the content string + the AST node's offset range (stable ints) instead of
178− // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
179− // ASTNode), so remembering on it re-executes extract* on every recomposition.
180− val node = model.node
181− val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
182− val raw = remember(model.content, node.startOffset, node.endOffset) {
183− node.getTextInNode(model.content).toString()
184− }
185− val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
186− val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
187− val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
188− // Default per-block wrap from the global preference; re-seed if the preference changes
189− // while this block is composed (rememberSaveable would over-persist across blocks).
190− val defaultWrap = LocalCodeWrap.current
191− var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
192− Column(
193− modifier = Modifier
194− .fillMaxWidth()
195− .clip(MaterialTheme.shapes.small)
196− .background(MaterialTheme.colorScheme.surfaceVariant),
197− ) {
198− Row(
199− modifier = Modifier
200− .fillMaxWidth()
201− .padding(start = 12.dp, end = 2.dp),
202− verticalAlignment = Alignment.CenterVertically,
203− ) {
204− Text(
205− language ?: context.getString(R.string.code),
206− style = MaterialTheme.typography.labelSmall,
207− color = MaterialTheme.colorScheme.onSurfaceVariant,
208− modifier = Modifier.weight(1f),
209− )
210− val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
211− IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
212− Icon(
213− Icons.AutoMirrored.Filled.WrapText,
214− contentDescription = wrapLabel,
215− modifier = Modifier.size(16.dp),
216− tint = if (wrap) MaterialTheme.colorScheme.primary
217− else MaterialTheme.colorScheme.onSurfaceVariant,
218− )
219− }
220− IconButton(
221− onClick = { copyToClipboard(context, "code", code) },
222− modifier = Modifier.size(36.dp),
223− ) {
224− Icon(
225− Icons.Filled.ContentCopy,
226− contentDescription = context.getString(R.string.copy),
227− modifier = Modifier.size(14.dp),
228− tint = MaterialTheme.colorScheme.onSurfaceVariant,
229− )
230− }
231− }
232− // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
233− // scroll so long lines keep their formatting. Selectable so a portion can be copied.
234− SelectionContainer {
235− Text(
236− text = code,
237− style = codeStyle,
238− modifier = if (wrap) {
239− Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
240− } else {
241− Modifier
242− .horizontalScroll(rememberScrollState())
243− .fillMaxWidth()
244− .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
245− },
246− )
247− }
248− }
249−}
250−
251−/**
252− * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
253− * label itself. Returns null for indented blocks or fences with no info string. Extracted
254− * as a pure function for testability.
255− */
256−internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
257− if (!isFenced) return null
258− val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
259− val info = first.removePrefix("```").removePrefix("~~~").trim()
260− // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
261− // first whitespace-delimited token is the language.
262− return info.substringBefore(' ').takeIf { it.isNotBlank() }
263−}
264−
265−/**
266− * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
267− * only the code content is returned. Extracted as a pure function for testability.
268− *
269− * @param raw the raw text of the code node (including fence markers for fenced blocks)
270− * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
271− */
272−internal fun extractCodeText(raw: String, isFenced: Boolean): String {
273− if (!isFenced) return raw.trimIndent()
274− val lines = raw.lines()
275− val body = lines.drop(1).toMutableList()
276− if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
277− body.removeAt(body.lastIndex)
278− }
279− return body.joinToString("\n").trimEnd()
280−}
281−
282−/** Copy [text] to the system clipboard and show a confirmation toast. */
283−internal fun copyToClipboard(context: Context, label: String, text: String = label) {
284− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
285− clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
286− showToast(context, context.getString(R.string.copied))
287−}
288−
289−private var lastToast: Toast? = null
290−
291−internal fun showToast(context: Context, message: String) {
292− lastToast?.cancel()
293− lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }
294−}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktnew file+479
@@ -0,0 +1,479 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.content.Intent
7+import android.net.Uri
8+import android.widget.Toast
9+import androidx.compose.foundation.ExperimentalFoundationApi
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.clickable
12+import androidx.compose.foundation.combinedClickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.fillMaxWidth
17+import androidx.compose.foundation.layout.padding
18+import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.selection.SelectionContainer
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Build
24+import androidx.compose.material.icons.filled.Check
25+import androidx.compose.material.icons.filled.ErrorOutline
26+import androidx.compose.material.icons.filled.ExpandLess
27+import androidx.compose.material.icons.filled.ExpandMore
28+import androidx.compose.material.icons.filled.Schedule
29+import androidx.compose.material.icons.filled.SmartToy
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.DropdownMenuItem
32+import androidx.compose.material3.Icon
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.Text
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.graphics.Color
44+import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
47+import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
49+import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
51+import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.withLink
54+import androidx.compose.ui.text.withStyle
55+import androidx.compose.ui.text.style.TextDecoration
56+import androidx.compose.ui.text.style.TextOverflow
57+import androidx.compose.ui.unit.dp
58+import androidx.compose.ui.unit.sp
59+import soy.iko.opencode.core.MessageStatus
60+import soy.iko.opencode.core.MessageView
61+import soy.iko.opencode.core.ToolView
62+import soy.iko.opencode.ui.theme.MonoStyle
63+
64+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65+
66+@OptIn(ExperimentalFoundationApi::class)
67+@Composable
68+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69+ val isUser = message.isUser
70+ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71+ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73+ val shape = RoundedCornerShape(
74+ topStart = 16.dp,
75+ topEnd = 16.dp,
76+ bottomStart = if (isUser) 16.dp else 4.dp,
77+ bottomEnd = if (isUser) 4.dp else 16.dp,
78+ )
79+
80+ val context = LocalContext.current
81+ var showMenu by remember { mutableStateOf(false) }
82+
83+ Row(
84+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86+ ) {
87+ if (!isUser) {
88+ Icon(
89+ Icons.Filled.SmartToy,
90+ contentDescription = null,
91+ tint = MaterialTheme.colorScheme.primary,
92+ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93+ )
94+ }
95+ Column(
96+ modifier = Modifier
97+ .fillMaxWidth(0.85f)
98+ .widthIn(max = 320.dp)
99+ .clip(shape)
100+ .background(bubbleColor)
101+ .combinedClickable(
102+ onClick = {},
103+ onLongClick = {
104+ if (message.text.isNotEmpty()) {
105+ showMenu = true
106+ }
107+ },
108+ )
109+ .padding(horizontal = 14.dp, vertical = 10.dp),
110+ ) {
111+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112+ DropdownMenuItem(
113+ text = { Text("Copy") },
114+ onClick = {
115+ copyToClipboard(context, message.text)
116+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117+ showMenu = false
118+ },
119+ )
120+ DropdownMenuItem(
121+ text = { Text("Share") },
122+ onClick = {
123+ shareText(context, message.text)
124+ showMenu = false
125+ },
126+ )
127+ }
128+
129+ message.reasoning?.let { ReasoningBlock(it, context) }
130+
131+ // Give a quick overview of how many tools ran before listing them.
132+ if (message.tools.size > 1) {
133+ Text(
134+ text = "${message.tools.size} tool calls",
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(bottom = 4.dp),
138+ )
139+ }
140+
141+ message.tools.forEach { ToolRow(it) }
142+
143+ SelectionContainer {
144+ if (message.text.isNotEmpty()) {
145+ val linkColor = MaterialTheme.colorScheme.primary
146+ // Tint the code background off the bubble's own foreground colour so it
147+ // contrasts on both bubble types (user bubbles use primaryContainer).
148+ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151+ }
152+ Text(
153+ text = annotatedText,
154+ color = textColor,
155+ // Give assistant replies (often long, code-heavy) extra line
156+ // spacing for readability; user bubbles keep the default.
157+ style = if (isUser) {
158+ MaterialTheme.typography.bodyLarge
159+ } else {
160+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161+ },
162+ )
163+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165+ }
166+ }
167+
168+ if (message.time > 0) {
169+ Text(
170+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171+ style = MaterialTheme.typography.labelSmall,
172+ color = mutedColor,
173+ modifier = Modifier.padding(top = 4.dp),
174+ )
175+ }
176+
177+ if (isUser && message.status != MessageStatus.Sent) {
178+ StatusLine(message.status, onRetry = onRetry)
179+ }
180+ }
181+ }
182+}
183+
184+private fun copyToClipboard(context: Context, text: String) {
185+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187+}
188+
189+private val urlRegex = Regex("""https?://[^\s]+""")
190+
191+/**
192+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193+ * prose and code segments: code segments are rendered monospace on a tinted
194+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196+ */
197+private fun buildLinkedText(
198+ text: String,
199+ streaming: Boolean,
200+ linkColor: Color,
201+ codeBackground: Color,
202+ context: Context,
203+): AnnotatedString = buildAnnotatedString {
204+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206+ val segments = text.split("```")
207+ segments.forEachIndexed { index, segment ->
208+ if (index % 2 == 1) {
209+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210+ append(segment)
211+ }
212+ } else {
213+ appendLinkedText(segment, linkColor, codeBackground, context)
214+ }
215+ }
216+ if (streaming) append(" ▌")
217+}
218+
219+/**
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224+ * inline spans reach here.
225+ */
226+private fun AnnotatedString.Builder.appendLinkedText(
227+ text: String,
228+ linkColor: Color,
229+ codeBackground: Color,
230+ context: Context,
231+) {
232+ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233+ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234+ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235+ // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236+ // segment on "*" would misfire on the leftover asterisks from the ** markers.
237+ val segments = text.split("**")
238+ // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239+ // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240+ val unclosed = segments.size % 2 == 0
241+ segments.forEachIndexed { index, segment ->
242+ val isUnclosedTail = unclosed && index == segments.size - 1
243+ if (index % 2 == 1 && !isUnclosedTail) {
244+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245+ appendInlineCode(segment, linkColor, codeBackground, context)
246+ }
247+ } else {
248+ if (isUnclosedTail) append("**")
249+ appendItalicText(segment, linkColor, codeBackground, context)
250+ }
251+ }
252+}
253+
254+/**
255+ * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256+ * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257+ * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258+ */
259+private fun AnnotatedString.Builder.appendItalicText(
260+ text: String,
261+ linkColor: Color,
262+ codeBackground: Color,
263+ context: Context,
264+) {
265+ // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266+ // (markers dropped); even segments are ordinary prose.
267+ val segments = text.split("*")
268+ // An even segment count means an odd number of * markers, so the final * is unclosed:
269+ // its trailing segment isn't italic. Restore the literal * and render it as prose.
270+ val unclosed = segments.size % 2 == 0
271+ segments.forEachIndexed { index, segment ->
272+ val isUnclosedTail = unclosed && index == segments.size - 1
273+ if (index % 2 == 1 && !isUnclosedTail) {
274+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275+ appendInlineCode(segment, linkColor, codeBackground, context)
276+ }
277+ } else {
278+ if (isUnclosedTail) append("*")
279+ appendInlineCode(segment, linkColor, codeBackground, context)
280+ }
281+ }
282+}
283+
284+/**
285+ * Append [text] to the builder, rendering single-backtick pairs as inline code
286+ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287+ * into a clickable link.
288+ */
289+private fun AnnotatedString.Builder.appendInlineCode(
290+ text: String,
291+ linkColor: Color,
292+ codeBackground: Color,
293+ context: Context,
294+) {
295+ // Odd-indexed segments sit between a pair of single backticks, so they're inline
296+ // code. Even segments are ordinary prose and get URL linking applied.
297+ val segments = text.split("`")
298+ // An even segment count means an odd number of backtick markers, so the final backtick
299+ // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300+ val unclosed = segments.size % 2 == 0
301+ segments.forEachIndexed { index, segment ->
302+ val isUnclosedTail = unclosed && index == segments.size - 1
303+ if (index % 2 == 1 && !isUnclosedTail) {
304+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305+ append(segment)
306+ }
307+ } else {
308+ if (isUnclosedTail) append("`")
309+ appendUrls(segment, linkColor, context)
310+ }
311+ }
312+}
313+
314+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315+private fun AnnotatedString.Builder.appendUrls(
316+ text: String,
317+ linkColor: Color,
318+ context: Context,
319+) {
320+ val matches = urlRegex.findAll(text).toList()
321+ var cursor = 0
322+ for (match in matches) {
323+ append(text.substring(cursor, match.range.first))
324+ val url = match.value
325+ withLink(
326+ LinkAnnotation.Clickable(
327+ tag = url,
328+ styles = TextLinkStyles(
329+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330+ ),
331+ linkInteractionListener = {
332+ runCatching {
333+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334+ }
335+ },
336+ ),
337+ ) {
338+ append(url)
339+ }
340+ cursor = match.range.last + 1
341+ }
342+ append(text.substring(cursor))
343+}
344+
345+private fun shareText(context: Context, text: String) {
346+ val intent = Intent(Intent.ACTION_SEND).apply {
347+ type = "text/plain"
348+ putExtra(Intent.EXTRA_TEXT, text)
349+ }
350+ context.startActivity(Intent.createChooser(intent, null))
351+}
352+
353+@Composable
354+private fun ReasoningBlock(text: String, context: Context) {
355+ var expanded by remember { mutableStateOf(false) }
356+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
357+ Row(
358+ verticalAlignment = Alignment.CenterVertically,
359+ modifier = Modifier.clickable { expanded = !expanded },
360+ ) {
361+ Text(
362+ text = if (expanded) "Show less" else "Reasoning",
363+ style = MaterialTheme.typography.bodySmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ )
366+ Icon(
367+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
370+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371+ )
372+ }
373+ if (expanded) {
374+ Text(
375+ text = text,
376+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377+ color = MaterialTheme.colorScheme.onSurfaceVariant,
378+ modifier = Modifier.padding(top = 4.dp),
379+ )
380+ Text(
381+ text = "Copy",
382+ style = MaterialTheme.typography.labelSmall,
383+ color = MaterialTheme.colorScheme.primary,
384+ modifier = Modifier
385+ .padding(top = 4.dp)
386+ .clickable {
387+ copyToClipboard(context, text)
388+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389+ },
390+ )
391+ }
392+ }
393+}
394+
395+@Composable
396+private fun ToolRow(tool: ToolView) {
397+ var expanded by remember { mutableStateOf(false) }
398+ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399+ val statusColor = when (tool.status) {
400+ "completed", "success" -> MaterialTheme.colorScheme.primary
401+ "error", "failed" -> MaterialTheme.colorScheme.error
402+ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403+ else -> MaterialTheme.colorScheme.onSurfaceVariant
404+ }
405+ Row(
406+ verticalAlignment = Alignment.CenterVertically,
407+ modifier = Modifier
408+ .fillMaxWidth()
409+ .clickable { expanded = !expanded }
410+ .padding(bottom = 6.dp),
411+ ) {
412+ Icon(
413+ Icons.Filled.Build,
414+ contentDescription = null,
415+ tint = MaterialTheme.colorScheme.primary,
416+ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417+ )
418+ Text(
419+ text = buildAnnotatedString {
420+ withStyle(SpanStyle(color = baseColor)) {
421+ append(tool.name)
422+ append(" · ")
423+ }
424+ withStyle(SpanStyle(color = statusColor)) {
425+ append(tool.status)
426+ }
427+ if (expanded) tool.title?.let {
428+ withStyle(SpanStyle(color = baseColor)) {
429+ append(" — ")
430+ append(it)
431+ }
432+ }
433+ },
434+ style = MonoStyle,
435+ color = baseColor,
436+ maxLines = if (expanded) Int.MAX_VALUE else 1,
437+ overflow = TextOverflow.Ellipsis,
438+ modifier = Modifier.weight(1f),
439+ )
440+ Icon(
441+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442+ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
444+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445+ )
446+ }
447+}
448+
449+@Composable
450+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451+ val (icon, label, tint) = when (status) {
452+ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453+ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454+ MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455+ }
456+ Row(
457+ verticalAlignment = Alignment.CenterVertically,
458+ horizontalArrangement = Arrangement.End,
459+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460+ ) {
461+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462+ Text(
463+ text = label,
464+ style = MaterialTheme.typography.labelSmall,
465+ color = tint,
466+ modifier = Modifier.padding(start = 4.dp),
467+ )
468+ if (status == MessageStatus.Failed) {
469+ Text(
470+ text = "Retry",
471+ style = MaterialTheme.typography.labelSmall,
472+ color = MaterialTheme.colorScheme.primary,
473+ modifier = Modifier
474+ .padding(start = 12.dp)
475+ .clickable { onRetry() },
476+ )
477+ }
478+ }
479+}
app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktdeleted−11
@@ -1,11 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.runtime.Immutable
4−
5−/** One entry in the [CommandPalette]: a label the user can search for and an action to run. */
6−@Immutable
7−data class PaletteAction(
8− val id: String,
9− val label: String,
10− val onSelect: () -> Unit,
11−)
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktdeleted−140
@@ -1,140 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.util.Base64
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.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.ktdeleted−216
@@ -1,216 +0,0 @@
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.ktdeleted−542
@@ -1,542 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.horizontalScroll
5−import androidx.compose.foundation.layout.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.defaultMinSize
11−import androidx.compose.foundation.layout.fillMaxSize
12−import androidx.compose.foundation.layout.fillMaxWidth
13−import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.foundation.lazy.LazyColumn
16−import androidx.compose.foundation.lazy.items
17−import androidx.compose.foundation.lazy.itemsIndexed
18−import androidx.compose.foundation.rememberScrollState
19−import androidx.compose.foundation.text.KeyboardActions
20−import androidx.compose.foundation.text.KeyboardOptions
21−import androidx.compose.material.icons.Icons
22−import androidx.compose.material.icons.automirrored.filled.ArrowBack
23−import androidx.compose.material.icons.filled.ChevronRight
24−import androidx.compose.material.icons.filled.Close
25−import androidx.compose.material.icons.filled.Description
26−import androidx.compose.material.icons.filled.Folder
27−import androidx.compose.material.icons.filled.Home
28−import androidx.compose.material.icons.filled.Search
29−import androidx.compose.material3.CircularProgressIndicator
30−import androidx.compose.material3.ExperimentalMaterial3Api
31−import androidx.compose.material3.FilterChip
32−import androidx.compose.material3.HorizontalDivider
33−import androidx.compose.material3.Icon
34−import androidx.compose.material3.IconButton
35−import androidx.compose.material3.MaterialTheme
36−import androidx.compose.material3.OutlinedTextField
37−import androidx.compose.material3.Scaffold
38−import androidx.compose.material3.SnackbarHost
39−import androidx.compose.material3.SnackbarHostState
40−import androidx.compose.material3.Text
41−import androidx.compose.material3.TopAppBar
42−import androidx.compose.runtime.Composable
43−import androidx.compose.runtime.LaunchedEffect
44−import androidx.compose.runtime.getValue
45−import androidx.compose.runtime.remember
46−import androidx.compose.ui.Alignment
47−import androidx.compose.ui.Modifier
48−import androidx.compose.ui.graphics.Color
49−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50−import androidx.compose.ui.platform.testTag
51−import androidx.compose.ui.semantics.Role
52−import androidx.compose.ui.semantics.contentDescription
53−import androidx.compose.ui.semantics.role
54−import androidx.compose.ui.semantics.semantics
55−import androidx.compose.ui.text.SpanStyle
56−import androidx.compose.ui.text.buildAnnotatedString
57−import androidx.compose.ui.text.font.FontFamily
58−import androidx.compose.ui.text.font.FontWeight
59−import androidx.compose.ui.text.input.ImeAction
60−import androidx.compose.ui.text.style.TextOverflow
61−import androidx.compose.ui.text.withStyle
62−import androidx.compose.ui.unit.dp
63−import androidx.compose.ui.res.stringResource
64−import androidx.lifecycle.compose.collectAsStateWithLifecycle
65−import androidx.lifecycle.viewmodel.compose.viewModel
66−import soy.iko.opencode.data.model.FileStatusEntry
67−import soy.iko.opencode.data.model.FindMatch
68−import soy.iko.opencode.data.model.SymbolResult
69−import soy.iko.opencode.data.model.symbolKindLabel
70−import soy.iko.opencode.di.AppContainer
71−import soy.iko.opencode.R
72−import soy.iko.opencode.ui.vmFactory
73−
74−@OptIn(ExperimentalMaterial3Api::class)
75−@Composable
76−fun FileBrowserScreen(
77− container: AppContainer,
78− onOpenFile: (path: String, line: Int?) -> Unit,
79− onBack: () -> Unit,
80−) {
81− val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
82− val state by vm.state.collectAsStateWithLifecycle()
83− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
84− val snackbar = remember { SnackbarHostState() }
85−
86− LaunchedEffect(Unit) {
87− vm.transientErrors.collect { msg ->
88− snackbar.showSnackbar(msg)
89− }
90− }
91−
92− Scaffold(
93− topBar = {
94− TopAppBar(
95− title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
96− navigationIcon = {
97− IconButton(onClick = onBack) {
98− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
99− }
100− },
101− )
102− },
103− snackbarHost = { SnackbarHost(snackbar) },
104− ) { padding ->
105− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
106− // Tappable breadcrumb trail so deep paths are navigable.
107− Breadcrumbs(
108− path = state.path,
109− onNavigate = vm::open,
110− modifier = Modifier.fillMaxWidth(),
111− )
112− val keyboardController = LocalSoftwareKeyboardController.current
113− OutlinedTextField(
114− value = state.query,
115− onValueChange = vm::setQuery,
116− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
117− label = {
118− Text(
119− stringResource(
120− when (state.mode) {
121− SearchMode.FILES -> R.string.search_files
122− SearchMode.TEXT -> R.string.search_in_files
123− SearchMode.SYMBOL -> R.string.search_symbols_label
124− },
125− ),
126− )
127− },
128− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
129− trailingIcon = if (state.query.isNotEmpty()) {
130− {
131− IconButton(onClick = { vm.setQuery("") }) {
132− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
133− }
134− }
135− } else null,
136− singleLine = true,
137− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
138− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
139− )
140− // Mode selector: file names / contents / symbols.
141− Row(
142− modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
143− horizontalArrangement = Arrangement.spacedBy(8.dp),
144− ) {
145− SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
146− SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
147− SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
148− }
149− Spacer(Modifier.size(8.dp))
150−
151− androidx.compose.material3.pulltorefresh.PullToRefreshBox(
152− isRefreshing = refreshing,
153− onRefresh = { vm.refresh() },
154− modifier = Modifier.fillMaxSize(),
155− ) {
156− Box(modifier = Modifier.fillMaxSize()) {
157− when {
158− state.loading || state.searching -> {
159− val loadingLabel = stringResource(R.string.loading)
160− CircularProgressIndicator(
161− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
162− )
163− }
164− state.error != null -> Column(
165− modifier = Modifier.align(Alignment.Center).padding(24.dp),
166− horizontalAlignment = Alignment.CenterHorizontally,
167− ) {
168− Text(
169− state.error ?: "",
170− color = MaterialTheme.colorScheme.error,
171− )
172− androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
173− // Retry the operation that actually failed: re-run the search
174− // when one was active (open() would reset the query and drop the
175− // user back into the directory listing), otherwise reload the dir.
176− androidx.compose.material3.TextButton(
177− onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
178− ) {
179− Text(stringResource(R.string.retry))
180− }
181− }
182− state.mode == SearchMode.TEXT ->
183− TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
184− state.mode == SearchMode.SYMBOL ->
185− SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
186− state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187− else -> DirectoryListing(
188− state = state,
189− onOpenDir = vm::open,
190− onUp = vm::up,
191− onOpenFile = { onOpenFile(it, null) },
192− )
193− }
194− }
195− }
196− }
197− }
198−}
199−
200−@Composable
201−private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202− val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
203− Row(
204− modifier = modifier
205− .horizontalScroll(rememberScrollState())
206− .padding(horizontal = 16.dp, vertical = 8.dp),
207− verticalAlignment = Alignment.CenterVertically,
208− ) {
209− IconButton(onClick = { onNavigate("") }) {
210− Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
211− }
212− var acc = ""
213− segments.forEachIndexed { index, segment ->
214− acc = if (acc.isEmpty()) segment else "$acc/$segment"
215− Icon(
216− Icons.Filled.ChevronRight,
217− contentDescription = null,
218− modifier = Modifier.size(16.dp),
219− tint = MaterialTheme.colorScheme.onSurfaceVariant,
220− )
221− val target = acc
222− Text(
223− segment,
224− style = MaterialTheme.typography.bodyMedium,
225− color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
226− modifier = Modifier
227− .clickable(role = Role.Button) { onNavigate(target) }
228− .defaultMinSize(minHeight = 48.dp)
229− .padding(horizontal = 6.dp, vertical = 14.dp),
230− )
231− }
232− }
233−}
234−
235−@Composable
236−private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
237− if (results.isEmpty()) {
238− EmptyFileState(
239− icon = Icons.Filled.Search,
240− message = stringResource(R.string.no_matches),
241− modifier = Modifier.fillMaxWidth().padding(24.dp),
242− )
243− return
244− }
245− LazyColumn(modifier = Modifier.fillMaxSize()) {
246− items(results, key = { it }) { path ->
247− // Split the path into directory (muted) + filename (emphasized) so results
248− // are scannable instead of a wall of identical-looking full paths.
249− val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
250− val name = path.substringAfterLast('/')
251− FileRow(
252− icon = false,
253− label = name,
254− sublabel = dir.takeIf { it.isNotEmpty() },
255− onClick = { onOpenFile(path) },
256− modifier = Modifier.animateItem(),
257− )
258− HorizontalDivider()
259− }
260− }
261−}
262−
263−@OptIn(ExperimentalMaterial3Api::class)
264−@Composable
265−private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
266− FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
267−}
268−
269−/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
270−@Composable
271−private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
272− if (results.isEmpty()) {
273− EmptyFileState(
274− icon = Icons.Filled.Search,
275− message = stringResource(R.string.no_matches),
276− modifier = Modifier.fillMaxWidth().padding(24.dp),
277− )
278− return
279− }
280− LazyColumn(modifier = Modifier.fillMaxSize()) {
281− itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
282− val name = match.filePath.substringAfterLast('/')
283− val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
284− val highlighted = remember(match) { highlightMatchLine(match) }
285− Column(
286− modifier = Modifier
287− .fillMaxWidth()
288− .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
289− .padding(horizontal = 16.dp, vertical = 10.dp),
290− ) {
291− Row(verticalAlignment = Alignment.CenterVertically) {
292− Text(
293− name,
294− style = MaterialTheme.typography.bodyMedium,
295− maxLines = 1,
296− overflow = TextOverflow.Ellipsis,
297− modifier = Modifier.weight(1f, fill = false),
298− )
299− Text(
300− ":${match.lineNumber}",
301− style = MaterialTheme.typography.labelSmall,
302− color = MaterialTheme.colorScheme.onSurfaceVariant,
303− fontFamily = FontFamily.Monospace,
304− modifier = Modifier.padding(start = 4.dp),
305− )
306− }
307− if (dir.isNotEmpty()) {
308− Text(
309− dir,
310− style = MaterialTheme.typography.labelSmall,
311− color = MaterialTheme.colorScheme.onSurfaceVariant,
312− maxLines = 1,
313− overflow = TextOverflow.Ellipsis,
314− )
315− }
316− Text(
317− highlighted,
318− style = MaterialTheme.typography.bodySmall,
319− fontFamily = FontFamily.Monospace,
320− maxLines = 2,
321− overflow = TextOverflow.Ellipsis,
322− modifier = Modifier.padding(top = 2.dp),
323− )
324− }
325− HorizontalDivider()
326− }
327− }
328−}
329−
330−/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
331−@Composable
332−private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
333− if (results.isEmpty()) {
334− EmptyFileState(
335− icon = Icons.Filled.Search,
336− message = stringResource(R.string.no_matches),
337− modifier = Modifier.fillMaxWidth().padding(24.dp),
338− )
339− return
340− }
341− LazyColumn(modifier = Modifier.fillMaxSize()) {
342− itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
343− val fileName = symbol.filePath.substringAfterLast('/')
344− Column(
345− modifier = Modifier
346− .fillMaxWidth()
347− .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
348− .padding(horizontal = 16.dp, vertical = 10.dp),
349− ) {
350− Row(verticalAlignment = Alignment.CenterVertically) {
351− Text(
352− symbol.name,
353− style = MaterialTheme.typography.bodyMedium,
354− maxLines = 1,
355− overflow = TextOverflow.Ellipsis,
356− modifier = Modifier.weight(1f, fill = false),
357− )
358− Text(
359− symbolKindLabel(symbol.kind),
360− style = MaterialTheme.typography.labelSmall,
361− color = MaterialTheme.colorScheme.primary,
362− modifier = Modifier.padding(start = 8.dp),
363− )
364− }
365− Text(
366− "$fileName:${symbol.displayLine}",
367− style = MaterialTheme.typography.labelSmall,
368− color = MaterialTheme.colorScheme.onSurfaceVariant,
369− fontFamily = FontFamily.Monospace,
370− maxLines = 1,
371− overflow = TextOverflow.Ellipsis,
372− )
373− }
374− HorizontalDivider()
375− }
376− }
377−}
378−
379−/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
380− * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
381−private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
382− val line = match.lineText
383− return buildAnnotatedString {
384− var cursor = 0
385− for (sub in match.submatches.sortedBy { it.start }) {
386− val start = sub.start.coerceIn(0, line.length)
387− val end = sub.end.coerceIn(start, line.length)
388− if (start > cursor) append(line.substring(cursor, start))
389− withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
390− cursor = end
391− }
392− if (cursor < line.length) append(line.substring(cursor))
393− }
394−}
395−
396−@Composable
397−private fun DirectoryListing(
398− state: FileBrowserState,
399− onOpenDir: (String) -> Unit,
400− onUp: () -> Unit,
401− onOpenFile: (String) -> Unit,
402−) {
403− if (state.entries.isEmpty()) {
404− EmptyFileState(
405− icon = Icons.Filled.Folder,
406− message = stringResource(R.string.empty_folder),
407− modifier = Modifier.fillMaxWidth().padding(24.dp),
408− )
409− return
410− }
411− LazyColumn(modifier = Modifier.fillMaxSize()) {
412− if (state.path.isNotBlank()) {
413− item(key = "__up") {
414− FileRow(icon = true, label = "..", onClick = onUp)
415− HorizontalDivider()
416− }
417− }
418− items(state.entries, key = { it.path + "_" + it.name }) { node ->
419− FileRow(
420− icon = node.isDirectory,
421− label = node.name,
422− onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
423− status = state.statusMap[node.path],
424− modifier = Modifier.animateItem(),
425− )
426− HorizontalDivider()
427− }
428− }
429−}
430−
431−/** Shared empty-state for the file browser: icon + message, matching the icon+text
432− * pattern used by the session and server lists (the prior versions were bare Text
433− * lines that read as status messages rather than intentional empty states). */
434−@Composable
435−private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
436− Column(
437− modifier = modifier,
438− horizontalAlignment = Alignment.CenterHorizontally,
439− ) {
440− Icon(
441− icon,
442− contentDescription = null,
443− modifier = Modifier.size(48.dp),
444− tint = MaterialTheme.colorScheme.onSurfaceVariant,
445− )
446− Spacer(Modifier.size(12.dp))
447− Text(
448− message,
449− style = MaterialTheme.typography.bodyMedium,
450− color = MaterialTheme.colorScheme.onSurfaceVariant,
451− )
452− }
453−}
454−
455−@Composable
456−private fun FileRow(
457− icon: Boolean,
458− label: String,
459− onClick: () -> Unit,
460− status: FileStatusEntry? = null,
461− sublabel: String? = null,
462− modifier: Modifier = Modifier,
463−) {
464− val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
465− else if (icon) stringResource(R.string.folder, label)
466− else stringResource(R.string.file_label, label)
467− // Build a combined description so TalkBack announces both the file name and its git
468− // status. The parent sets an explicit contentDescription (mergeDescendants), which
469− // would otherwise suppress the StatusBadge's own description.
470− val statusDesc = status?.let {
471− when (it.status) {
472− "added" -> stringResource(R.string.git_added)
473− "modified" -> stringResource(R.string.git_modified)
474− "deleted" -> stringResource(R.string.git_deleted)
475− else -> ""
476− }
477− }.orEmpty()
478− val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
479− Row(
480− modifier = modifier
481− .fillMaxWidth()
482− .defaultMinSize(minHeight = 48.dp)
483− .clickable(role = Role.Button, onClick = onClick)
484− .semantics(mergeDescendants = true) { contentDescription = fullDesc }
485− .padding(horizontal = 16.dp, vertical = 14.dp),
486− verticalAlignment = Alignment.CenterVertically,
487− ) {
488− Icon(
489− if (icon) Icons.Filled.Folder else Icons.Filled.Description,
490− contentDescription = null,
491− modifier = Modifier.size(20.dp),
492− tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
493− )
494− Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
495− Text(
496− label,
497− style = MaterialTheme.typography.bodyLarge,
498− maxLines = 1,
499− overflow = TextOverflow.Ellipsis,
500− )
501− if (sublabel != null) {
502− Text(
503− sublabel,
504− style = MaterialTheme.typography.labelSmall,
505− color = MaterialTheme.colorScheme.onSurfaceVariant,
506− maxLines = 1,
507− overflow = TextOverflow.Ellipsis,
508− )
509− }
510− }
511− if (status != null) StatusBadge(status)
512− }
513−}
514−
515−@Composable
516−private fun StatusBadge(status: FileStatusEntry) {
517− val (letter, color) = when (status.status) {
518− "added" -> "A" to MaterialTheme.colorScheme.primary
519− "modified" -> "M" to MaterialTheme.colorScheme.tertiary
520− "deleted" -> "D" to MaterialTheme.colorScheme.error
521− else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
522− }
523− Row(
524− verticalAlignment = Alignment.CenterVertically,
525− ) {
526− Text(
527− letter,
528− style = MaterialTheme.typography.labelMedium,
529− fontFamily = FontFamily.Monospace,
530− color = color,
531− )
532− if (status.added > 0 || status.removed > 0) {
533− Text(
534− "+${status.added} −${status.removed}",
535− style = MaterialTheme.typography.labelSmall,
536− fontFamily = FontFamily.Monospace,
537− color = MaterialTheme.colorScheme.onSurfaceVariant,
538− modifier = Modifier.padding(start = 4.dp),
539− )
540− }
541− }
542−}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktdeleted−266
@@ -1,266 +0,0 @@
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.model.FindMatch
9−import soy.iko.opencode.data.model.SymbolResult
10−import soy.iko.opencode.data.network.NetworkConfig
11−import soy.iko.opencode.di.AppContainer
12−import soy.iko.opencode.R
13−import soy.iko.opencode.util.runCatchingCancellable
14−import kotlinx.coroutines.ExperimentalCoroutinesApi
15−import kotlinx.coroutines.Job
16−import kotlinx.coroutines.delay
17−import kotlinx.coroutines.channels.BufferOverflow
18−import kotlinx.coroutines.flow.MutableSharedFlow
19−import kotlinx.coroutines.flow.MutableStateFlow
20−import kotlinx.coroutines.flow.SharedFlow
21−import kotlinx.coroutines.flow.StateFlow
22−import kotlinx.coroutines.flow.asSharedFlow
23−import kotlinx.coroutines.flow.asStateFlow
24−import kotlinx.coroutines.flow.collectLatest
25−import kotlinx.coroutines.flow.update
26−import kotlinx.coroutines.launch
27−
28−/** What a search query targets: file names, file contents (ripgrep), or workspace symbols. */
29−enum class SearchMode { FILES, TEXT, SYMBOL }
30−
31−@Immutable
32−data class FileBrowserState(
33− val path: String = "",
34− val entries: List<FileNode> = emptyList(),
35− val statusMap: Map<String, FileStatusEntry> = emptyMap(),
36− val mode: SearchMode = SearchMode.FILES,
37− val query: String = "",
38− val results: List<String> = emptyList(),
39− val textResults: List<FindMatch> = emptyList(),
40− val symbolResults: List<SymbolResult> = emptyList(),
41− val searching: Boolean = false,
42− val loading: Boolean = true,
43− val error: String? = null,
44−) {
45− /** True when the results view (rather than the directory listing) should be shown:
46− * any content/symbol mode, or file-name mode with a non-blank query. */
47− val isSearching: Boolean get() = mode != SearchMode.FILES || query.isNotBlank()
48−}
49−
50−@OptIn(ExperimentalCoroutinesApi::class)
51−class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
52−
53− private val api get() = container.activeConnection.value?.api
54−
55− private val _state = MutableStateFlow(FileBrowserState())
56− val state: StateFlow<FileBrowserState> = _state.asStateFlow()
57−
58− /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
59− private val _refreshing = MutableStateFlow(false)
60− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
61−
62− /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
63− * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
64− private val _transientErrors = MutableSharedFlow<String>(
65− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
66− onBufferOverflow = BufferOverflow.DROP_OLDEST,
67− )
68− val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
69−
70− private var searchJob: Job? = null
71− private var openJob: Job? = null
72−
73− init {
74− open("")
75− // Observe the active connection so VCS status loads (or reloads) when a connection
76− // becomes available — including when the view opens during a reconnect window where
77− // activeConnection.value was momentarily null. Without this, loadStatus() would be
78− // called once from init with a null api and never retried.
79− viewModelScope.launch {
80− container.activeConnection.collectLatest { conn ->
81− if (conn == null) return@collectLatest
82− loadStatus()
83− // Re-open the current directory when a connection becomes available
84− // so the initial open("") (which may have run before auto-reconnect
85− // finished and found no connection) doesn't leave a stale error. But if
86− // the user is mid-search, re-run the search instead — open() resets
87− // query/results and would silently discard their in-progress search.
88− if (_state.value.isSearching) setQuery(_state.value.query)
89− else open(_state.value.path)
90− }
91− }
92− }
93−
94− /** Tracks the in-flight VCS status load so a server switch can cancel it. */
95− private var statusJob: Job? = null
96−
97− /** Fetch the repo-wide VCS status once so file rows can show git badges. */
98− private fun loadStatus() {
99− val client = api ?: return
100− statusJob?.cancel()
101− statusJob = viewModelScope.launch {
102− runCatchingCancellable { client.fileStatus() }
103− .onSuccess { entries ->
104− _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
105− }
106− .onFailure {
107− // VCS status is best-effort: don't block the file browser, but surface
108− // a transient error so the user knows why badges are missing.
109− _transientErrors.tryEmit(container.friendlyError(it))
110− }
111− }
112− }
113−
114− /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
115− fun refresh() {
116− _refreshing.value = true
117− val searching = _state.value.isSearching
118− if (searching) {
119− // Re-run the active search rather than open(), which would reset query/results
120− // and drop the user back into the directory listing — discarding their search.
121− setQuery(_state.value.query)
122− } else {
123− open(_state.value.path)
124− }
125− viewModelScope.launch {
126− // Clear the spinner once the in-flight load settles by joining whichever job
127− // the refresh kicked off (search or directory open).
128− (if (searching) searchJob else openJob)?.join()
129− _refreshing.value = false
130− }
131− }
132−
133− fun open(path: String) {
134− val client = api ?: run {
135− _state.update { it.copy(
136− path = path,
137− loading = false,
138− error = container.string(R.string.not_connected),
139− ) }
140− return
141− }
142− _state.update { it.copy(
143− path = path,
144− mode = SearchMode.FILES,
145− query = "",
146− results = emptyList(),
147− textResults = emptyList(),
148− symbolResults = emptyList(),
149− searching = false,
150− loading = true,
151− error = null,
152− ) }
153− openJob?.cancel()
154− searchJob?.cancel()
155− openJob = viewModelScope.launch {
156− runCatchingCancellable { client.listDirectory(path) }
157− .onSuccess { entries ->
158− _state.update { it.copy(
159− entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
160− loading = false,
161− ) }
162− }
163− .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
164− }
165− }
166−
167− /** Navigate up one path segment. */
168− fun up() {
169− val current = _state.value.path
170− if (current.isBlank()) return
171− val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
172− open(parent)
173− }
174−
175− /** Switch what the query targets (file names / contents / symbols) and re-run it. */
176− fun setMode(mode: SearchMode) {
177− if (_state.value.mode == mode) return
178− searchJob?.cancel()
179− _state.update {
180− it.copy(
181− mode = mode,
182− results = emptyList(),
183− textResults = emptyList(),
184− symbolResults = emptyList(),
185− searching = false,
186− error = null,
187− )
188− }
189− val query = _state.value.query
190− if (mode == SearchMode.FILES && query.isBlank()) open(_state.value.path) else setQuery(query)
191− }
192−
193− fun setQuery(query: String) {
194− _state.update { it.copy(query = query, error = null) }
195− searchJob?.cancel()
196− openJob?.cancel()
197− when (_state.value.mode) {
198− SearchMode.FILES -> searchFiles(query)
199− SearchMode.TEXT -> searchContent(query) { it.findText(query) }
200− SearchMode.SYMBOL -> searchSymbols(query)
201− }
202− }
203−
204− private fun searchFiles(query: String) {
205− if (query.isBlank()) {
206− _state.update { it.copy(results = emptyList(), searching = false) }
207− // Cancelling openJob above may have interrupted an in-flight directory load,
208− // leaving loading=true stuck. Re-open the current directory so the listing
209− // re-populates instead of showing a perpetual spinner after clearing search.
210− open(_state.value.path)
211− return
212− }
213− // The search view replaces the directory listing; clear loading so the spinner
214− // from a cancelled open() doesn't persist into the search results view.
215− // Set searching=true immediately (not after the debounce) so the spinner shows
216− // during the debounce delay — otherwise the UI briefly renders SearchResults
217− // with an empty list, flashing "no matches" before the search even starts.
218− _state.update { it.copy(loading = false, searching = true) }
219− val client = api ?: run {
220− _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
221− return
222− }
223− searchJob = viewModelScope.launch {
224− delay(NetworkConfig.fileSearchDebounceMs) // debounce
225− runCatchingCancellable { client.findFiles(query) }
226− .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
227− .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
228− }
229− }
230−
231− private fun searchContent(query: String, fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> List<FindMatch>) {
232− if (query.isBlank()) {
233− _state.update { it.copy(textResults = emptyList(), searching = false, loading = false) }
234− return
235− }
236− _state.update { it.copy(loading = false, searching = true) }
237− val client = api ?: run {
238− _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
239− return
240− }
241− searchJob = viewModelScope.launch {
242− delay(NetworkConfig.fileSearchDebounceMs)
243− runCatchingCancellable { fetch(client) }
244− .onSuccess { results -> _state.update { it.copy(textResults = results, searching = false) } }
245− .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
246− }
247− }
248−
249− private fun searchSymbols(query: String) {
250− if (query.isBlank()) {
251− _state.update { it.copy(symbolResults = emptyList(), searching = false, loading = false) }
252− return
253− }
254− _state.update { it.copy(loading = false, searching = true) }
255− val client = api ?: run {
256− _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
257− return
258− }
259− searchJob = viewModelScope.launch {
260− delay(NetworkConfig.fileSearchDebounceMs)
261− runCatchingCancellable { client.findSymbol(query) }
262− .onSuccess { results -> _state.update { it.copy(symbolResults = results, searching = false) } }
263− .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
264− }
265− }
266−}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktdeleted−59
@@ -1,59 +0,0 @@
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.ktdeleted−574
@@ -1,574 +0,0 @@
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− initialLine: Int? = null,
96−) {
97− val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
98− val state by vm.state.collectAsStateWithLifecycle()
99− val context = LocalContext.current
100− val filename = remember(path) {
101− val trimmed = path.trimEnd('/')
102− trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
103− }
104− val shareLabel = stringResource(R.string.share)
105− // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
106− // switch to the raw new content. Persisted via rememberSaveable across rotation. When the
107− // viewer is opened at a specific line (from a search hit), start on the raw view so the
108− // targeted line is actually rendered (the diff view has no line list to scroll).
109− val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
110− var showDiff by rememberSaveable(path) { mutableStateOf(initialLine == null) }
111− val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
112− // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
113− // user's query/mode.
114− var findActive by rememberSaveable(path) { mutableStateOf(false) }
115− var findQuery by rememberSaveable(path) { mutableStateOf("") }
116− var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
117− var wrap by rememberSaveable(path) { mutableStateOf(false) }
118− val listState = rememberLazyListState()
119− val rawText = state.content?.content.orEmpty()
120− val lines = remember(rawText) { rawText.split("\n") }
121− // Debounce the query so each keystroke doesn't trigger a full-file scan, and run the
122− // scan on Dispatchers.Default so a large file doesn't jank the keyboard while typing.
123− var debouncedFind by remember { mutableStateOf("") }
124− LaunchedEffect(findQuery) {
125− val q = findQuery.trim()
126− if (q.isEmpty()) debouncedFind = "" else { delay(150); debouncedFind = q }
127− }
128− val matchIndices by produceState(emptyList<Int>(), lines, debouncedFind) {
129− val q = debouncedFind
130− value = if (q.isEmpty()) emptyList()
131− else withContext(Dispatchers.Default) {
132− lines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
133− }
134− }
135− // Keep the current match index in range when the match set shrinks (e.g. the file
136− // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
137− LaunchedEffect(matchIndices.size) {
138− matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
139− }
140− // Scroll to the current match whenever it (or the match set) changes.
141− LaunchedEffect(matchPos, matchIndices) {
142− matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
143− }
144− // Opened at a specific line (from a search hit): scroll there once the raw content loads.
145− // One-shot so a later find-in-file navigation isn't overridden.
146− var didInitialLineScroll by rememberSaveable(path) { mutableStateOf(false) }
147− LaunchedEffect(state.content, initialLine) {
148− val line = initialLine ?: return@LaunchedEffect
149− if (didInitialLineScroll) return@LaunchedEffect
150− val content = state.content ?: return@LaunchedEffect
151− if (content.isBinary) return@LaunchedEffect
152− val target = (line - 1).coerceIn(0, (lines.size - 1).coerceAtLeast(0))
153− runCatchingCancellable { listState.scrollToItem(target) }
154− didInitialLineScroll = true
155− }
156−
157− Scaffold(
158− topBar = {
159− FileViewTopBar(
160− filename = filename,
161− loading = state.loading,
162− content = rawText,
163− wrap = wrap,
164− inDiffMode = showToggle && showDiff,
165− onBack = onBack,
166− onReload = { vm.reload() },
167− onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
168− onToggleWrap = { wrap = !wrap },
169− onCopy = { copyToClipboard(context, filename, rawText) },
170− onShare = {
171− val send = Intent(Intent.ACTION_SEND).apply {
172− type = "text/plain"
173− putExtra(Intent.EXTRA_SUBJECT, filename)
174− putExtra(Intent.EXTRA_TEXT, rawText)
175− }
176− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
177− .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
178− },
179− )
180− },
181− ) { padding ->
182− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
183− // AnimatedContent cross-fades between loading / error / content states so the
184− // viewer doesn't snap abruptly when a reload finishes or fails.
185− AnimatedContent(
186− targetState = state,
187− transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
188− label = "fileViewState",
189− ) { s ->
190− // Derive match indices from THIS layer's content (s), not the outer/newest
191− // state. During the crossfade the exiting layer still renders the old
192− // content; passing the outer matchIndices (computed from the new content)
193− // would highlight and scroll the wrong lines until the animation settles.
194− val sMatchIndices = remember(s.content?.content, debouncedFind, lines, rawText) {
195− val q = debouncedFind
196− if (q.isEmpty()) emptyList()
197− else {
198− // Reuse the already-split `lines` when this layer shows the current
199− // content (the common case); only re-split for the exiting layer's
200− // stale content during the crossfade.
201− val sContent = s.content?.content
202− val sLines = if (sContent == rawText) lines else sContent.orEmpty().split("\n")
203− sLines.mapIndexedNotNull { index, line -> index.takeIf { line.contains(q, ignoreCase = true) } }
204− }
205− }
206− FileViewStateContent(
207− state = s,
208− filename = filename,
209− showToggle = showToggle,
210− showDiff = showDiff,
211− onSetShowDiff = {
212− showDiff = it
213− // Close any open find bar when entering diff mode: find searches
214− // the raw content and scrolls the line LazyColumn, neither of
215− // which is visible in diff mode, so leaving it open would be a
216− // confusing no-op.
217− if (it) { findActive = false; findQuery = "" }
218− },
219− findActive = findActive,
220− findQuery = findQuery,
221− onQueryChange = { findQuery = it; matchPos = 0 },
222− matchIndices = sMatchIndices,
223− matchPos = matchPos,
224− onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
225− onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
226− onCloseFind = { findActive = false; findQuery = "" },
227− wrap = wrap,
228− listState = listState,
229− onRetry = { vm.reload() },
230− )
231− }
232− }
233− }
234−}
235−
236−@OptIn(ExperimentalMaterial3Api::class)
237−@Composable
238−private fun FileViewTopBar(
239− filename: String,
240− loading: Boolean,
241− content: String,
242− wrap: Boolean,
243− inDiffMode: Boolean,
244− onBack: () -> Unit,
245− onReload: () -> Unit,
246− onToggleFind: () -> Unit,
247− onToggleWrap: () -> Unit,
248− onCopy: () -> Unit,
249− onShare: () -> Unit,
250−) {
251− TopAppBar(
252− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
253− navigationIcon = {
254− IconButton(onClick = onBack) {
255− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
256− }
257− },
258− actions = {
259− // In-place refresh: a file's content can change on the server (the agent may
260− // be editing it), so reload without requiring a back-out.
261− IconButton(onClick = onReload, enabled = !loading) {
262− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
263− }
264− if (content.isNotEmpty()) {
265− // Find-in-file: toggles a search bar over the raw content. Disabled only when
266− // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
267− // raw text and scrolls the line LazyColumn, neither of which is visible under a
268− // diff, so find would silently no-op there. A plain file with no diff (the common
269− // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
270− IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
271− Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
272− }
273− // Wrap long lines instead of horizontal-scrolling, useful for prose.
274− IconButton(onClick = onToggleWrap) {
275− Icon(
276− Icons.AutoMirrored.Filled.WrapText,
277− contentDescription = stringResource(R.string.wrap_lines),
278− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
279− )
280− }
281− IconButton(onClick = onCopy) {
282− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
283− }
284− IconButton(onClick = onShare) {
285− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
286− }
287− }
288− },
289− )
290−}
291−
292−@Composable
293−private fun BoxScope.FileViewStateContent(
294− state: FileViewState,
295− filename: String,
296− showToggle: Boolean,
297− showDiff: Boolean,
298− onSetShowDiff: (Boolean) -> Unit,
299− findActive: Boolean,
300− findQuery: String,
301− onQueryChange: (String) -> Unit,
302− matchIndices: List<Int>,
303− matchPos: Int,
304− onPrev: () -> Unit,
305− onNext: () -> Unit,
306− onCloseFind: () -> Unit,
307− wrap: Boolean,
308− listState: LazyListState,
309− onRetry: () -> Unit,
310−) {
311− when {
312− state.loading -> {
313− val loadingLabel = stringResource(R.string.loading)
314− CircularProgressIndicator(
315− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
316− )
317− }
318− state.error != null -> Column(
319− modifier = Modifier.align(Alignment.Center).padding(24.dp),
320− horizontalAlignment = Alignment.CenterHorizontally,
321− ) {
322− Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
323− Spacer(Modifier.size(12.dp))
324− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
325− }
326− state.content?.isBinary == true -> Text(
327− stringResource(R.string.binary_file),
328− modifier = Modifier.align(Alignment.Center).padding(24.dp),
329− color = MaterialTheme.colorScheme.onSurfaceVariant,
330− )
331− else -> FileViewContentBody(
332− content = state.content,
333− filename = filename,
334− showToggle = showToggle,
335− showDiff = showDiff,
336− onSetShowDiff = onSetShowDiff,
337− findActive = findActive,
338− findQuery = findQuery,
339− onQueryChange = onQueryChange,
340− matchIndices = matchIndices,
341− matchPos = matchPos,
342− onPrev = onPrev,
343− onNext = onNext,
344− onCloseFind = onCloseFind,
345− wrap = wrap,
346− listState = listState,
347− )
348− }
349−}
350−
351−@Composable
352−private fun BoxScope.FileViewContentBody(
353− content: soy.iko.opencode.data.model.FileContent?,
354− filename: String,
355− showToggle: Boolean,
356− showDiff: Boolean,
357− onSetShowDiff: (Boolean) -> Unit,
358− findActive: Boolean,
359− findQuery: String,
360− onQueryChange: (String) -> Unit,
361− matchIndices: List<Int>,
362− matchPos: Int,
363− onPrev: () -> Unit,
364− onNext: () -> Unit,
365− onCloseFind: () -> Unit,
366− wrap: Boolean,
367− listState: LazyListState,
368−) {
369− // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
370− // reachable while scrolling. Each contributes a top inset so the content below
371− // isn't hidden behind the overlay. The inset is measured from the actual overlay
372− // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
373− // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
374− // cause the first lines of content to hide behind the overlay.
375− // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
376− // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
377− // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
378− // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
379− // would keep the find bar — and therefore find-in-file — permanently hidden.
380− val rawOnScreen = !(showToggle && showDiff)
381− val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
382− var overlayHeightPx by remember { mutableIntStateOf(0) }
383− // Only apply the measured inset while an overlay is actually composed. When overlayRows
384− // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
385− // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
386− // a phantom top gap on the content below — e.g. after closing find on a plain file.
387− val topInset = if (overlayRows > 0) {
388− with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
389− } else 0.dp
390− if (overlayRows > 0) {
391− Column(
392− modifier = Modifier
393− .align(Alignment.TopCenter)
394− .fillMaxWidth()
395− .onSizeChanged { overlayHeightPx = it.height }
396− .padding(top = 4.dp),
397− verticalArrangement = Arrangement.spacedBy(4.dp),
398− ) {
399− if (showToggle) {
400− Row(
401− modifier = Modifier.fillMaxWidth(),
402− horizontalArrangement = Arrangement.Center,
403− ) {
404− FilterChip(
405− selected = showDiff,
406− onClick = { onSetShowDiff(true) },
407− label = { Text(stringResource(R.string.show_diff)) },
408− )
409− Spacer(Modifier.size(8.dp))
410− FilterChip(
411− selected = !showDiff,
412− onClick = { onSetShowDiff(false) },
413− label = { Text(stringResource(R.string.show_raw)) },
414− )
415− }
416− }
417− if (findActive && rawOnScreen) {
418− FindBar(
419− query = findQuery,
420− onQueryChange = onQueryChange,
421− matchCount = matchIndices.size,
422− matchPos = matchPos,
423− onPrev = onPrev,
424− onNext = onNext,
425− onClose = onCloseFind,
426− )
427− }
428− }
429− }
430− if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
431− DiffView(
432− diff = content.diff,
433− modifier = Modifier
434− .fillMaxSize()
435− .padding(top = topInset)
436− .verticalScroll(rememberScrollState())
437− .padding(8.dp),
438− )
439− } else {
440− val text = content?.content.orEmpty()
441− if (text.isEmpty()) {
442− Text(
443− stringResource(R.string.empty_file),
444− modifier = Modifier.align(Alignment.Center).padding(24.dp),
445− color = MaterialTheme.colorScheme.onSurfaceVariant,
446− )
447− } else {
448− FileTextContent(
449− text = text,
450− filename = filename,
451− topInset = topInset,
452− wrap = wrap,
453− findQuery = findQuery,
454− matchIndices = matchIndices,
455− listState = listState,
456− )
457− }
458− }
459−}
460−
461−@Composable
462−private fun FindBar(
463− query: String,
464− onQueryChange: (String) -> Unit,
465− matchCount: Int,
466− matchPos: Int,
467− onPrev: () -> Unit,
468− onNext: () -> Unit,
469− onClose: () -> Unit,
470−) {
471− val keyboardController = LocalSoftwareKeyboardController.current
472− Row(
473− modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
474− verticalAlignment = Alignment.CenterVertically,
475− ) {
476− OutlinedTextField(
477− value = query,
478− onValueChange = onQueryChange,
479− modifier = Modifier.weight(1f),
480− placeholder = { Text(stringResource(R.string.find_hint)) },
481− singleLine = true,
482− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
483− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
484− trailingIcon = {
485− val countText = if (query.isBlank()) ""
486− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
487− else stringResource(R.string.match_count, matchPos + 1, matchCount)
488− if (countText.isNotEmpty()) {
489− // Use error color for "no matches" so the zero-result state is
490− // distinguishable from a positive match count at a glance.
491− val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
492− else MaterialTheme.colorScheme.onSurfaceVariant
493− Text(
494− countText,
495− style = MaterialTheme.typography.labelSmall,
496− color = color,
497− )
498− }
499− },
500− )
501− IconButton(onClick = onPrev, enabled = matchCount > 0) {
502− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
503− }
504− IconButton(onClick = onNext, enabled = matchCount > 0) {
505− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
506− }
507− IconButton(onClick = onClose) {
508− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
509− }
510− }
511−}
512−
513−@Composable
514−private fun FileTextContent(
515− text: String,
516− filename: String,
517− topInset: androidx.compose.ui.unit.Dp,
518− wrap: Boolean,
519− findQuery: String,
520− matchIndices: List<Int>,
521− listState: LazyListState,
522−) {
523− val lines = remember(text) { text.split("\n") }
524− // Gutter width scales with the digit count and the font scale so accessibility
525− // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
526− // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
527− // proportionally for larger font scales.
528− val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
529− val gutterWidth = remember(lines.size, fontScale) {
530− ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
531− }
532− val hScrollState = rememberScrollState()
533− val palette = rememberHighlightPalette()
534− // Resolve the file's language once instead of re-parsing the extension for every line.
535− val syntax = remember(filename) { syntaxFor(filename) }
536− val q = findQuery.trim()
537− val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
538− LazyColumn(
539− state = listState,
540− modifier = Modifier
541− .fillMaxSize()
542− .padding(top = topInset)
543− .padding(8.dp),
544− ) {
545− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
546− val isMatch = q.isNotEmpty() && index in matchSet
547− Row(
548− modifier = Modifier
549− .fillMaxWidth()
550− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
551− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
552− ) {
553− Text(
554− "${index + 1}",
555− modifier = Modifier.width(gutterWidth),
556− style = MaterialTheme.typography.bodySmall,
557− fontFamily = FontFamily.Monospace,
558− color = MaterialTheme.colorScheme.onSurfaceVariant,
559− )
560− // Heuristic syntax highlighting per line. Falls back to plain text for
561− // unknown extensions, so non-code files render unchanged. Memoized so the
562− // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
563− // file, or palette actually changes — not on every recomposition (e.g. every
564− // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
565− val highlighted = remember(line, syntax, palette) { highlightLine(line, syntax, palette) }
566− Text(
567− highlighted,
568− modifier = Modifier.padding(start = 8.dp),
569− style = MaterialTheme.typography.bodySmall,
570− )
571− }
572− }
573− }
574−}
app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktdeleted−140
@@ -1,140 +0,0 @@
1−package soy.iko.opencode.ui.mcp
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.BoxScope
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.PaddingValues
8−import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.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.automirrored.filled.ArrowBack
18−import androidx.compose.material.icons.filled.CloudQueue
19−import androidx.compose.material.icons.filled.Computer
20−import androidx.compose.material.icons.filled.Refresh
21−import androidx.compose.material3.Button
22−import androidx.compose.material3.Card
23−import androidx.compose.material3.CircularProgressIndicator
24−import androidx.compose.material3.ExperimentalMaterial3Api
25−import androidx.compose.material3.Icon
26−import androidx.compose.material3.IconButton
27−import androidx.compose.material3.MaterialTheme
28−import androidx.compose.material3.Scaffold
29−import androidx.compose.material3.Text
30−import androidx.compose.material3.TopAppBar
31−import androidx.compose.runtime.Composable
32−import androidx.compose.runtime.getValue
33−import androidx.compose.ui.Alignment
34−import androidx.compose.ui.Modifier
35−import androidx.compose.ui.res.stringResource
36−import androidx.compose.ui.text.style.TextOverflow
37−import androidx.compose.ui.unit.dp
38−import androidx.lifecycle.compose.collectAsStateWithLifecycle
39−import androidx.lifecycle.viewmodel.compose.viewModel
40−import soy.iko.opencode.R
41−import soy.iko.opencode.di.AppContainer
42−import soy.iko.opencode.ui.vmFactory
43−
44−@OptIn(ExperimentalMaterial3Api::class)
45−@Composable
46−fun McpScreen(container: AppContainer, onBack: () -> Unit) {
47− val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
48− val state by vm.state.collectAsStateWithLifecycle()
49−
50− Scaffold(
51− topBar = {
52− TopAppBar(
53− title = { Text(stringResource(R.string.mcp_servers)) },
54− navigationIcon = {
55− IconButton(onClick = onBack) {
56− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
57− }
58− },
59− actions = {
60− IconButton(onClick = { vm.load() }) {
61− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
62− }
63− },
64− )
65− },
66− ) { padding ->
67− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
68− when (val s = state) {
69− is McpViewModel.State.Loading ->
70− CircularProgressIndicator(Modifier.align(Alignment.Center))
71− is McpViewModel.State.Disconnected -> CenteredText(stringResource(R.string.not_connected))
72− is McpViewModel.State.Error -> Column(
73− modifier = Modifier.align(Alignment.Center).padding(24.dp),
74− horizontalAlignment = Alignment.CenterHorizontally,
75− ) {
76− Text(stringResource(R.string.mcp_failed), color = MaterialTheme.colorScheme.error)
77− Spacer(Modifier.size(12.dp))
78− Button(onClick = { vm.load() }) { Text(stringResource(R.string.retry)) }
79− }
80− is McpViewModel.State.Ready ->
81− if (s.servers.isEmpty()) {
82− CenteredText(stringResource(R.string.mcp_empty))
83− } else {
84− LazyColumn(
85− modifier = Modifier.fillMaxSize(),
86− contentPadding = PaddingValues(16.dp),
87− verticalArrangement = Arrangement.spacedBy(12.dp),
88− ) {
89− items(s.servers, key = { it.name }) { McpServerCard(it) }
90− }
91− }
92− }
93− }
94− }
95−}
96−
97−@Composable
98−private fun BoxScope.CenteredText(text: String) {
99− Text(
100− text,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.align(Alignment.Center).padding(24.dp),
103− )
104−}
105−
106−@Composable
107−private fun McpServerCard(server: McpServerInfo) {
108− Card(modifier = Modifier.fillMaxWidth()) {
109− Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
110− val remote = server.type.equals("remote", ignoreCase = true)
111− Icon(
112− if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
113− contentDescription = null,
114− tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
115− )
116− Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
117− Text(server.name, style = MaterialTheme.typography.titleSmall)
118− server.target?.let {
119− Text(
120− it,
121− style = MaterialTheme.typography.bodySmall,
122− color = MaterialTheme.colorScheme.onSurfaceVariant,
123− maxLines = 2,
124− overflow = TextOverflow.Ellipsis,
125− )
126− }
127− }
128− val stateLabel = when {
129− !server.enabled -> stringResource(R.string.mcp_disabled)
130− server.type != null -> server.type
131− else -> stringResource(R.string.mcp_connected)
132− }
133− Text(
134− stateLabel,
135− style = MaterialTheme.typography.labelMedium,
136− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error,
137− )
138− }
139− }
140−}
app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktdeleted−45
@@ -1,45 +0,0 @@
1−package soy.iko.opencode.ui.mcp
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.json.JsonArray
5−import kotlinx.serialization.json.JsonObject
6−import kotlinx.serialization.json.JsonPrimitive
7−import kotlinx.serialization.json.booleanOrNull
8−import kotlinx.serialization.json.contentOrNull
9−
10−/**
11− * A configured MCP (Model Context Protocol) server, as read from the server's `config.mcp`
12− * map. [type] is "local" (spawns a command) or "remote" (connects to a URL); [target] is the
13− * command line or URL, whichever applies. Best-effort — fields the server omits are null.
14− */
15−@Immutable
16−data class McpServerInfo(
17− val name: String,
18− val type: String?,
19− val enabled: Boolean,
20− val target: String?,
21−)
22−
23−/**
24− * Extract the configured MCP servers from a resolved opencode `config` object. Tolerant of
25− * shape drift: a non-object entry, or missing fields, degrade gracefully rather than throwing,
26− * so a server-side config addition never breaks the viewer. Pure for testability.
27− */
28−fun parseMcpServers(config: JsonObject): List<McpServerInfo> {
29− val mcp = config["mcp"] as? JsonObject ?: return emptyList()
30− return mcp.entries.map { (name, value) ->
31− val obj = value as? JsonObject
32− if (obj == null) {
33− McpServerInfo(name = name, type = null, enabled = true, target = null)
34− } else {
35− val type = (obj["type"] as? JsonPrimitive)?.contentOrNull
36− val enabled = (obj["enabled"] as? JsonPrimitive)?.booleanOrNull ?: true
37− val url = (obj["url"] as? JsonPrimitive)?.contentOrNull
38− val command = (obj["command"] as? JsonArray)
39− ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
40− ?.joinToString(" ")
41− ?.takeIf { it.isNotBlank() }
42− McpServerInfo(name = name, type = type, enabled = enabled, target = url ?: command)
43− }
44− }.sortedBy { it.name.lowercase() }
45−}
app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktdeleted−44
@@ -1,44 +0,0 @@
1−package soy.iko.opencode.ui.mcp
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
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.launch
10−import kotlinx.coroutines.withContext
11−import soy.iko.opencode.di.AppContainer
12−import soy.iko.opencode.util.runCatchingCancellable
13−
14−/** Loads the configured MCP servers from the active connection's `config` (read-only view). */
15−class McpViewModel(private val container: AppContainer) : ViewModel() {
16−
17− sealed interface State {
18− data object Loading : State
19− data object Disconnected : State
20− data object Error : State
21− data class Ready(val servers: List<McpServerInfo>) : State
22− }
23−
24− private val _state = MutableStateFlow<State>(State.Loading)
25− val state: StateFlow<State> = _state.asStateFlow()
26−
27− init { load() }
28−
29− fun load() {
30− val conn = container.activeConnection.value ?: run {
31− _state.value = State.Disconnected
32− return
33− }
34− _state.value = State.Loading
35− viewModelScope.launch {
36− runCatchingCancellable {
37− val config = conn.api.config()
38− withContext(Dispatchers.Default) { parseMcpServers(config) }
39− }
40− .onSuccess { _state.value = State.Ready(it) }
41− .onFailure { _state.value = State.Error }
42− }
43− }
44−}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+590

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktnew file+222

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktnew file+402

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktdeleted−161

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktdeleted−127

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktdeleted−470

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktdeleted−315

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktdeleted−455

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktdeleted−133

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktdeleted−237

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktdeleted−929

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktdeleted−721

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktdeleted−160

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktdeleted−403

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktdeleted−684

This diff is too big to show here.

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

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktdeleted−19

This diff is too big to show here.

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

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktdeleted−87

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktdeleted−106

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktdeleted−224

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktdeleted−70

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktdeleted−35

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−13

This diff is too big to show here.

app/src/main/res/drawable/widget_background.xmldeleted−6

This diff is too big to show here.

app/src/main/res/layout/widget_session_item.xmldeleted−21

This diff is too big to show here.

app/src/main/res/layout/widget_sessions.xmldeleted−64

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmldeleted−6

This diff is too big to show here.

app/src/main/res/values-es/strings.xmldeleted−473

This diff is too big to show here.

app/src/main/res/values-night/colors.xmlnew file+4

This diff is too big to show here.

app/src/main/res/values-night/themes.xmlnew file+8

This diff is too big to show here.

app/src/main/res/values/colors.xml+1−2

This diff is too big to show here.

app/src/main/res/values/strings.xml−471

This diff is too big to show here.

app/src/main/res/values/themes.xml+9−10

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/file_paths.xmldeleted−6

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/sessions_widget_info.xmldeleted−10

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktdeleted−144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktdeleted−102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktdeleted−88

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktdeleted−91

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktnew file+69

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktdeleted−90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktdeleted−37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktdeleted−42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktdeleted−66

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktdeleted−238

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktdeleted−46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktdeleted−60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktdeleted−385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktdeleted−44

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktdeleted−73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktdeleted−598

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktdeleted−90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktdeleted−55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktdeleted−28

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktdeleted−219

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktdeleted−70

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktdeleted−52

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktdeleted−120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktdeleted−32

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktdeleted−464

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktdeleted−634

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktdeleted−456

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktdeleted−48

This diff is too big to show here.

build.gradle.kts+1−1

This diff is too big to show here.

config/detekt/detekt.ymldeleted−68

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+27−17

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javanew file+38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javanew file+33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javanew file+12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javanew file+195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javanew file+146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javanew file+54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javanew file+56

This diff is too big to show here.

generated/com/novi/serde/Int128.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javanew file+58

This diff is too big to show here.

generated/com/novi/serde/Slice.javanew file+40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javanew file+40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javanew file+46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javanew file+54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javanew file+61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javanew file+67

This diff is too big to show here.

generated/com/novi/serde/Unit.javanew file+20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javanew file+11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javanew file+150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javanew file+131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javanew file+27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javanew file+150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javanew file+77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javanew file+86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javanew file+71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javanew file+185

This diff is too big to show here.

gradle.properties+5−2

This diff is too big to show here.

gradle/libs.versions.toml+25−52

This diff is too big to show here.

renovate.jsondeleted−32

This diff is too big to show here.

settings.gradle.kts+3

This diff is too big to show here.

shared/Cargo.locknew file+1358

This diff is too big to show here.

shared/Cargo.tomlnew file+31

This diff is too big to show here.

shared/src/app.rsnew file+1051

This diff is too big to show here.

shared/src/bin/codegen.rsnew file+35

This diff is too big to show here.

shared/src/lib.rsnew file+17

This diff is too big to show here.

shared/src/protocol.rsnew file+248

This diff is too big to show here.