git.iko.soy/opencode-android
on 0881fcd help sign in
169 files changed+20968−8092
since splitsnapshots
unifiedsplit
.direnv/bin/nix-direnv-reload+19 .direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source+1 .direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-source+1 .direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source+1 .direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source+1 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa+1 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc+2250 .github/workflows/build.yml+16−18 .github/workflows/release.yml−113 .gitignore−20 AGENTS.md+197 CLAUDE.md−40 README.md+64−126 app/build.gradle.kts+92−44 app/detekt-baseline.xml+15 app/proguard-rules.pro+44−13 app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.kt+36 app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.kt+67 app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt+49 app/src/main/AndroidManifest.xml+35−7 app/src/main/java/soy/iko/opencode/CoreViewModel.kt−66 app/src/main/java/soy/iko/opencode/MainActivity.kt+115−13 app/src/main/java/soy/iko/opencode/OpencodeApp.kt+59 app/src/main/java/soy/iko/opencode/core/HttpClient.kt−75 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt−668 app/src/main/java/soy/iko/opencode/core/Protocol.kt−148 app/src/main/java/soy/iko/opencode/core/SseClient.kt−63 app/src/main/java/soy/iko/opencode/core/ViewModel.kt−77 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+32 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt+109 app/src/main/java/soy/iko/opencode/data/model/Common.kt+28 app/src/main/java/soy/iko/opencode/data/model/Files.kt+38 app/src/main/java/soy/iko/opencode/data/model/Message.kt+65 app/src/main/java/soy/iko/opencode/data/model/Part.kt+99 app/src/main/java/soy/iko/opencode/data/model/Permission.kt+49 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt+26 app/src/main/java/soy/iko/opencode/data/model/Provider.kt+72 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt+21 app/src/main/java/soy/iko/opencode/data/model/Session.kt+30 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt+53 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+259 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+114 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+151 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+325 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt+37 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+216 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt+266 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt+78 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+237 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+460 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt+50 app/src/main/java/soy/iko/opencode/di/AppContainer.kt+643 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt+41 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt+34 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt+101 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt+95 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+215−100 app/src/main/java/soy/iko/opencode/ui/Routes.kt+23 app/src/main/java/soy/iko/opencode/ui/VmFactory.kt+16 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+213 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1173 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+767 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+202 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt+124 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt+278 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt+180 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+492 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt+118 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt+266 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt+121 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+218 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+227 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt−479 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+140 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt+216 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+375 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+191 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt+59 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+539 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt−590 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt−222 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt−402 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+356 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+286 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt+455 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt+133 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+751 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+601 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt+157 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+403 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+411 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt−19 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt+19 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+110−38 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt−15 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt+87 app/src/main/java/soy/iko/opencode/util/Coroutines.kt+35 app/src/main/res/drawable/ic_launcher_foreground.xml+13 app/src/main/res/mipmap-anydpi/ic_launcher.xml+6 app/src/main/res/values-night/colors.xml−4 app/src/main/res/values-night/themes.xml−8 app/src/main/res/values/colors.xml+2−1 app/src/main/res/values/strings.xml+273 app/src/main/res/values/themes.xml+10−9 app/src/main/res/xml/data_extraction_rules.xml+15 app/src/main/res/xml/network_security_config.xml+15 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt+107 app/src/test/java/soy/iko/opencode/M6Test.kt+102 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt+88 app/src/test/java/soy/iko/opencode/UxTest.kt+91 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt−69 app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.kt+37 app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt+42 app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt+66 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+238 app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.kt+46 app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.kt+60 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+385 app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.kt+73 app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.kt+56 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt+549 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt+90 app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.kt+55 app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.kt+56 app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt+219 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+464 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+526 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+370 build.gradle.kts+1−1 config/detekt/detekt.yml+60 flake.lock+3−3 flake.nix+17−27 generated/com/novi/bincode/BincodeDeserializer.java−38 generated/com/novi/bincode/BincodeSerializer.java−33 generated/com/novi/serde/ArrayLen.java−12 generated/com/novi/serde/BinaryDeserializer.java−195 generated/com/novi/serde/BinarySerializer.java−146 generated/com/novi/serde/Bytes.java−54 generated/com/novi/serde/DeserializationError.java−11 generated/com/novi/serde/Deserializer.java−56 generated/com/novi/serde/Int128.java−11 generated/com/novi/serde/SerializationError.java−11 generated/com/novi/serde/Serializer.java−58 generated/com/novi/serde/Slice.java−40 generated/com/novi/serde/Tuple2.java−40 generated/com/novi/serde/Tuple3.java−46 generated/com/novi/serde/Tuple4.java−54 generated/com/novi/serde/Tuple5.java−61 generated/com/novi/serde/Tuple6.java−67 generated/com/novi/serde/Unit.java−20 generated/com/novi/serde/Unsigned.java−11 generated/soy/iko/opencode/shared/MessageStatus.java−150 generated/soy/iko/opencode/shared/MessageView.java−131 generated/soy/iko/opencode/shared/Requests.java−27 generated/soy/iko/opencode/shared/Screen.java−150 generated/soy/iko/opencode/shared/SessionView.java−77 generated/soy/iko/opencode/shared/ToolView.java−86 generated/soy/iko/opencode/shared/TraitHelpers.java−71 generated/soy/iko/opencode/shared/ViewModel.java−185 gradle.properties+2−5 gradle/libs.versions.toml+50−25 renovate.json+32 settings.gradle.kts−3 shared/Cargo.lock−1358 shared/Cargo.toml−31 shared/src/app.rs−1051 shared/src/bin/codegen.rs−35 shared/src/lib.rs−17 shared/src/protocol.rs−248
.direnv/bin/nix-direnv-reloadnew file+19
@@ -0,0 +1,19 @@
1+#!/usr/bin/env bash
2+set -e
3+if [[ ! -d "/home/iko/opencode-android" ]]; then
4+ echo "Cannot find source directory; Did you move it?"
5+ echo "(Looking for "/home/iko/opencode-android")"
6+ echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
7+ exit 1
8+fi
9+
10+# rebuild the cache forcefully
11+_nix_direnv_force_reload=1 direnv exec "/home/iko/opencode-android" true
12+
13+# Update the mtime for .envrc.
14+# This will cause direnv to reload again - but without re-building.
15+touch "/home/iko/opencode-android/.envrc"
16+
17+# Also update the timestamp of whatever profile_rc we have.
18+# This makes sure that we know we are up to date.
19+touch -r "/home/iko/opencode-android/.envrc" "/home/iko/opencode-android/.direnv"/*.rc
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfanew file+1
@@ -0,0 +1 @@
1+/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcnew file+2250
@@ -0,0 +1,2250 @@
1+unset shellHook
2+PATH=${PATH:-}
3+nix_saved_PATH="$PATH"
4+XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
5+nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
6+ANDROID_HOME='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
7+export ANDROID_HOME
8+ANDROID_SDK_ROOT='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
9+export ANDROID_SDK_ROOT
10+AR='ar'
11+export AR
12+AS='as'
13+export AS
14+BASH='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
15+CC='gcc'
16+export CC
17+CLASSPATH=''
18+export CLASSPATH
19+CONFIG_SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
20+export CONFIG_SHELL
21+CXX='g++'
22+export CXX
23+GRADLE_OPTS='-Dorg.gradle.project.android.aapt2FromMavenOverride=/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2'
24+export GRADLE_OPTS
25+HOSTTYPE='x86_64'
26+HOST_PATH='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
27+export HOST_PATH
28+IFS='
29+'
30+IN_NIX_SHELL='impure'
31+export IN_NIX_SHELL
32+JAVA_HOME='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2'
33+export JAVA_HOME
34+LD='ld'
35+export LD
36+LINENO='80'
37+MACHTYPE='x86_64-pc-linux-gnu'
38+NIX_BINTOOLS='/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46'
39+export NIX_BINTOOLS
40+NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
41+export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
42+NIX_BUILD_CORES='4'
43+export NIX_BUILD_CORES
44+NIX_CC='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
45+export NIX_CC
46+NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
47+export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
48+NIX_CFLAGS_COMPILE=' -frandom-seed=n53brq0w7p -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include'
49+export NIX_CFLAGS_COMPILE
50+NIX_ENFORCE_NO_NATIVE='1'
51+export NIX_ENFORCE_NO_NATIVE
52+NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
53+export NIX_HARDENING_ENABLE
54+NIX_LDFLAGS='-rpath /home/iko/opencode-android/outputs/out/lib '
55+export NIX_LDFLAGS
56+NIX_NO_SELF_RPATH='1'
57+NIX_STORE='/nix/store'
58+export NIX_STORE
59+NM='nm'
60+export NM
61+OBJCOPY='objcopy'
62+export OBJCOPY
63+OBJDUMP='objdump'
64+export OBJDUMP
65+OLDPWD=''
66+export OLDPWD
67+OPTERR='1'
68+OSTYPE='linux-gnu'
69+PATH='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/bin:/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0/bin:/nix/store/sq0nrnfwhkc5ljvklnrk5ps4358g4nbj-gcc-15.2.0/bin:/nix/store/521dd0054ifhzmjfmpx9mz0hr7wh7sig-glibc-2.42-67-bin/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46/bin:/nix/store/39lyf5g2iz4jd2r6q4g5whk9vw09zglj-binutils-2.46/bin:/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
70+export PATH
71+PS4='+ '
72+RANLIB='ranlib'
73+export RANLIB
74+READELF='readelf'
75+export READELF
76+SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
77+export SHELL
78+SIZE='size'
79+export SIZE
80+SOURCE_DATE_EPOCH='315532800'
81+export SOURCE_DATE_EPOCH
82+STRINGS='strings'
83+export STRINGS
84+STRIP='strip'
85+export STRIP
86+XDG_DATA_DIRS='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/share'
87+export XDG_DATA_DIRS
88+__structuredAttrs=''
89+export __structuredAttrs
90+_substituteStream_has_warned_replace_deprecation='false'
91+buildInputs='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2 /nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4 /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk'
92+export buildInputs
93+buildPhase='{ echo "------------------------------------------------------------";
94+ echo " WARNING: the existence of this path is not guaranteed.";
95+ echo " It is an internal implementation detail for pkgs.mkShell.";
96+ echo "------------------------------------------------------------";
97+ echo;
98+ # Record all build inputs as runtime dependencies
99+ export;
100+} >> "$out"
101+'
102+export buildPhase
103+builder='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
104+export builder
105+checkPhase='gradleCheckPhase'
106+cmakeFlags=''
107+export cmakeFlags
108+configureFlags=''
109+export configureFlags
110+defaultBuildInputs=''
111+defaultNativeBuildInputs='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2 /nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
112+depsBuildBuild=''
113+export depsBuildBuild
114+depsBuildBuildPropagated=''
115+export depsBuildBuildPropagated
116+depsBuildTarget=''
117+export depsBuildTarget
118+depsBuildTargetPropagated=''
119+export depsBuildTargetPropagated
120+depsHostHost=''
121+export depsHostHost
122+depsHostHostPropagated=''
123+export depsHostHostPropagated
124+depsTargetTarget=''
125+export depsTargetTarget
126+depsTargetTargetPropagated=''
127+export depsTargetTargetPropagated
128+doCheck=''
129+export doCheck
130+doInstallCheck=''
131+export doInstallCheck
132+dontAddDisableDepTrack='1'
133+export dontAddDisableDepTrack
134+declare -a envBuildBuildHooks=()
135+declare -a envBuildHostHooks=()
136+declare -a envBuildTargetHooks=()
137+declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
138+declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
139+declare -a envTargetTargetHooks=('addPkgToClassPath' )
140+declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
141+initialPath='/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11 /nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0 /nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12 /nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9 /nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12 /nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0 /nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35 /nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14 /nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin /nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1 /nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9 /nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8 /nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin /nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47'
142+mesonFlags=''
143+export mesonFlags
144+name='nix-shell-env'
145+export name
146+nativeBuildInputs=''
147+export nativeBuildInputs
148+out='/home/iko/opencode-android/outputs/out'
149+export out
150+outputBin='out'
151+outputDev='out'
152+outputDevdoc='REMOVE'
153+outputDevman='out'
154+outputDoc='out'
155+outputInclude='out'
156+outputInfo='out'
157+outputLib='out'
158+outputMan='out'
159+outputs='out'
160+export outputs
161+patches=''
162+export patches
163+phases='buildPhase'
164+export phases
165+pkg='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
166+declare -a pkgsBuildBuild=()
167+declare -a pkgsBuildHost=('/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2' '/nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0' '/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46' )
168+declare -a pkgsBuildTarget=()
169+declare -a pkgsHostHost=()
170+declare -a pkgsHostTarget=('/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2' '/nix/store/2dsv152lhz5rxl24fkb6sis3q165swv6-set-java-classpath-hook' '/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4' '/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk' )
171+declare -a pkgsTargetTarget=()
172+declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
173+declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
174+declare -a preConfigureHooks=('mitmCacheConfigureHook' '_multioutConfig' 'gradleConfigureHook' )
175+preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
176+declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
177+preferLocalBuild='1'
178+export preferLocalBuild
179+prefix='/home/iko/opencode-android/outputs/out'
180+declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
181+propagatedBuildInputs=''
182+export propagatedBuildInputs
183+declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
184+propagatedNativeBuildInputs=''
185+export propagatedNativeBuildInputs
186+declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
187+shell='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
188+export shell
189+shellHook='echo "opencode-android dev shell"
190+echo " JDK: $(java -version 2>&1 | head -1)"
191+echo " ANDROID_HOME: $ANDROID_HOME"
192+echo " aapt2: /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2"
193+echo ""
194+echo "Build: ./gradlew assembleDebug"
195+echo "Device: adb devices"
196+'
197+export shellHook
198+stdenv='/nix/store/xknwpg5yjxxsffmwdmpys269543wpdy5-stdenv-linux'
199+export stdenv
200+strictDeps=''
201+export strictDeps
202+system='x86_64-linux'
203+export system
204+declare -a unpackCmdHooks=('_defaultUnpack' )
205+_activatePkgs ()
206+{
207+
208+ local hostOffset targetOffset;
209+ local pkg;
210+ for hostOffset in "${allPlatOffsets[@]}";
211+ do
212+ local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
213+ for targetOffset in "${allPlatOffsets[@]}";
214+ do
215+ (( hostOffset <= targetOffset )) || continue;
216+ local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
217+ local pkgsSlice="${!pkgsRef}[@]";
218+ for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
219+ do
220+ activatePackage "$pkg" "$hostOffset" "$targetOffset";
221+ done;
222+ done;
223+ done
224+}
225+_addRpathPrefix ()
226+{
227+
228+ if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
229+ export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
230+ fi
231+}
232+_addToEnv ()
233+{
234+
235+ local depHostOffset depTargetOffset;
236+ local pkg;
237+ for depHostOffset in "${allPlatOffsets[@]}";
238+ do
239+ local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
240+ local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
241+ for depTargetOffset in "${allPlatOffsets[@]}";
242+ do
243+ (( depHostOffset <= depTargetOffset )) || continue;
244+ local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
245+ if [[ -z "${strictDeps-}" ]]; then
246+ local visitedPkgs="";
247+ for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
248+ do
249+ if [[ "$visitedPkgs" = *"$pkg"* ]]; then
250+ continue;
251+ fi;
252+ runHook "${!hookRef}" "$pkg";
253+ visitedPkgs+=" $pkg";
254+ done;
255+ else
256+ local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
257+ local pkgsSlice="${!pkgsRef}[@]";
258+ for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
259+ do
260+ runHook "${!hookRef}" "$pkg";
261+ done;
262+ fi;
263+ done;
264+ done
265+}
266+_allFlags ()
267+{
268+
269+ export system pname name version;
270+ while IFS='' read -r varName; do
271+ nixTalkativeLog "@${varName}@ -> ${!varName}";
272+ args+=("--subst-var" "$varName");
273+ done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
274+}
275+_assignFirst ()
276+{
277+
278+ local varName="$1";
279+ local _var;
280+ local REMOVE=REMOVE;
281+ shift;
282+ for _var in "$@";
283+ do
284+ if [ -n "${!_var-}" ]; then
285+ eval "${varName}"="${_var}";
286+ return;
287+ fi;
288+ done;
289+ echo;
290+ echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
291+ echo " The following variables were all unset or empty:";
292+ echo " $*";
293+ if [ -z "${out:-}" ]; then
294+ echo ' If you do not want an "out" output in your derivation, make sure to define';
295+ echo ' the other specific required outputs. This can be achieved by picking one';
296+ echo " of the above as an output.";
297+ echo ' You do not have to remove "out" if you want to have a different default';
298+ echo ' output, because the first output is taken as a default.';
299+ echo;
300+ fi;
301+ return 1
302+}
303+_callImplicitHook ()
304+{
305+
306+ local def="$1";
307+ local hookName="$2";
308+ if declare -F "$hookName" > /dev/null; then
309+ nixTalkativeLog "calling implicit '$hookName' function hook";
310+ "$hookName";
311+ else
312+ if type -p "$hookName" > /dev/null; then
313+ nixTalkativeLog "sourcing implicit '$hookName' script hook";
314+ source "$hookName";
315+ else
316+ if [ -n "${!hookName:-}" ]; then
317+ nixTalkativeLog "evaling implicit '$hookName' string hook";
318+ eval "${!hookName}";
319+ else
320+ return "$def";
321+ fi;
322+ fi;
323+ fi
324+}
325+_defaultUnpack ()
326+{
327+
328+ local fn="$1";
329+ local destination;
330+ if [ -d "$fn" ]; then
331+ destination="$(stripHash "$fn")";
332+ if [ -e "$destination" ]; then
333+ echo "Cannot copy $fn to $destination: destination already exists!";
334+ echo "Did you specify two \"srcs\" with the same \"name\"?";
335+ return 1;
336+ fi;
337+ cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
338+ else
339+ case "$fn" in
340+ *.tar.xz | *.tar.lzma | *.txz)
341+ ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
342+ true ) | tar xf - --mode=+w --warning=no-timestamp
343+ ;;
344+ *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
345+ tar xf "$fn" --mode=+w --warning=no-timestamp
346+ ;;
347+ *)
348+ return 1
349+ ;;
350+ esac;
351+ fi
352+}
353+_doStrip ()
354+{
355+
356+ local -ra flags=(dontStripHost dontStripTarget);
357+ local -ra debugDirs=(stripDebugList stripDebugListTarget);
358+ local -ra allDirs=(stripAllList stripAllListTarget);
359+ local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
360+ local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
361+ stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
362+ stripDebugListTarget=${stripDebugListTarget[*]:-};
363+ stripAllList=${stripAllList[*]:-};
364+ stripAllListTarget=${stripAllListTarget[*]:-};
365+ local i;
366+ for i in ${!stripCmds[@]};
367+ do
368+ local -n flag="${flags[$i]}";
369+ local -n debugDirList="${debugDirs[$i]}";
370+ local -n allDirList="${allDirs[$i]}";
371+ local -n stripCmd="${stripCmds[$i]}";
372+ local -n ranlibCmd="${ranlibCmds[$i]}";
373+ if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
374+ continue;
375+ fi;
376+ stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
377+ stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
378+ done
379+}
380+_eval ()
381+{
382+
383+ if declare -F "$1" > /dev/null 2>&1; then
384+ "$@";
385+ else
386+ eval "$1";
387+ fi
388+}
389+_logHook ()
390+{
391+
392+ if [[ -z ${NIX_LOG_FD-} ]]; then
393+ return;
394+ fi;
395+ local hookKind="$1";
396+ local hookExpr="$2";
397+ shift 2;
398+ if declare -F "$hookExpr" > /dev/null 2>&1; then
399+ nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
400+ else
401+ if type -p "$hookExpr" > /dev/null; then
402+ nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
403+ else
404+ if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
405+ local exprToOutput;
406+ if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
407+ exprToOutput="$hookExpr";
408+ else
409+ local hookExprLine;
410+ while IFS= read -r hookExprLine; do
411+ hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
412+ if [[ -n "$hookExprLine" ]]; then
413+ exprToOutput+="$hookExprLine\\n ";
414+ fi;
415+ done <<< "$hookExpr";
416+ exprToOutput="${exprToOutput%%\\n }";
417+ fi;
418+ nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
419+ fi;
420+ fi;
421+ fi
422+}
423+_makeSymlinksRelative ()
424+{
425+
426+ local prefixes;
427+ prefixes=();
428+ for output in $(getAllOutputNames);
429+ do
430+ [ ! -e "${!output}" ] && continue;
431+ prefixes+=("${!output}");
432+ done;
433+ find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
434+ output="$1"
435+ link="$2"
436+
437+ linkTarget=$(readlink "$link")
438+
439+ # only touch links that point inside the same output tree
440+ [[ $linkTarget == "$output"/* ]] || exit 0
441+
442+ if [ ! -e "$linkTarget" ]; then
443+ echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
444+ fi
445+
446+ echo "making symlink relative: $link"
447+ ln -snrf "$linkTarget" "$link"
448+ ' _
449+}
450+_moveLib64 ()
451+{
452+
453+ if [ "${dontMoveLib64-}" = 1 ]; then
454+ return;
455+ fi;
456+ if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
457+ return;
458+ fi;
459+ echo "moving $prefix/lib64/* to $prefix/lib";
460+ mkdir -p $prefix/lib;
461+ shopt -s dotglob;
462+ for i in $prefix/lib64/*;
463+ do
464+ mv --no-clobber "$i" $prefix/lib;
465+ done;
466+ shopt -u dotglob;
467+ rmdir $prefix/lib64;
468+ ln -s lib $prefix/lib64
469+}
470+_moveSbin ()
471+{
472+
473+ if [ "${dontMoveSbin-}" = 1 ]; then
474+ return;
475+ fi;
476+ if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
477+ return;
478+ fi;
479+ echo "moving $prefix/sbin/* to $prefix/bin";
480+ mkdir -p $prefix/bin;
481+ shopt -s dotglob;
482+ for i in $prefix/sbin/*;
483+ do
484+ mv "$i" $prefix/bin;
485+ done;
486+ shopt -u dotglob;
487+ rmdir $prefix/sbin;
488+ ln -s bin $prefix/sbin
489+}
490+_moveSystemdUserUnits ()
491+{
492+
493+ if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
494+ return;
495+ fi;
496+ if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
497+ return;
498+ fi;
499+ local source="$prefix/lib/systemd/user";
500+ local target="$prefix/share/systemd/user";
501+ echo "moving $source/* to $target";
502+ mkdir -p "$target";
503+ ( shopt -s dotglob;
504+ for i in "$source"/*;
505+ do
506+ mv "$i" "$target";
507+ done );
508+ rmdir "$source";
509+ ln -s "$target" "$source"
510+}
511+_moveToShare ()
512+{
513+
514+ if [ -n "$__structuredAttrs" ]; then
515+ if [ -z "${forceShare-}" ]; then
516+ forceShare=(man doc info);
517+ fi;
518+ else
519+ forceShare=(${forceShare:-man doc info});
520+ fi;
521+ if [[ -z "$out" ]]; then
522+ return;
523+ fi;
524+ for d in "${forceShare[@]}";
525+ do
526+ if [ -d "$out/$d" ]; then
527+ if [ -d "$out/share/$d" ]; then
528+ echo "both $d/ and share/$d/ exist!";
529+ else
530+ echo "moving $out/$d to $out/share/$d";
531+ mkdir -p $out/share;
532+ mv $out/$d $out/share/;
533+ fi;
534+ fi;
535+ done
536+}
537+_multioutConfig ()
538+{
539+
540+ if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
541+ return;
542+ fi;
543+ if [ -z "${shareDocName:-}" ]; then
544+ local confScript="${configureScript:-}";
545+ if [ -z "$confScript" ] && [ -x ./configure ]; then
546+ confScript=./configure;
547+ fi;
548+ if [ -f "$confScript" ]; then
549+ local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
550+ fi;
551+ if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
552+ shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
553+ fi;
554+ fi;
555+ prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
556+ prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
557+}
558+_multioutDevs ()
559+{
560+
561+ if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
562+ return;
563+ fi;
564+ moveToOutput include "${!outputInclude}";
565+ moveToOutput lib/pkgconfig "${!outputDev}";
566+ moveToOutput share/pkgconfig "${!outputDev}";
567+ moveToOutput lib/cmake "${!outputDev}";
568+ moveToOutput share/aclocal "${!outputDev}";
569+ for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
570+ do
571+ echo "Patching '$f' includedir to output ${!outputInclude}";
572+ sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
573+ done
574+}
575+_multioutDocs ()
576+{
577+
578+ local REMOVE=REMOVE;
579+ moveToOutput share/info "${!outputInfo}";
580+ moveToOutput share/doc "${!outputDoc}";
581+ moveToOutput share/gtk-doc "${!outputDevdoc}";
582+ moveToOutput share/devhelp/books "${!outputDevdoc}";
583+ moveToOutput share/man "${!outputMan}";
584+ moveToOutput share/man/man3 "${!outputDevman}"
585+}
586+_multioutPropagateDev ()
587+{
588+
589+ if [ "$(getAllOutputNames)" = "out" ]; then
590+ return;
591+ fi;
592+ local outputFirst;
593+ for outputFirst in $(getAllOutputNames);
594+ do
595+ break;
596+ done;
597+ local propagaterOutput="$outputDev";
598+ if [ -z "$propagaterOutput" ]; then
599+ propagaterOutput="$outputFirst";
600+ fi;
601+ if [ -z "${propagatedBuildOutputs+1}" ]; then
602+ local po_dirty="$outputBin $outputInclude $outputLib";
603+ set +o pipefail;
604+ propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
605+ set -o pipefail;
606+ fi;
607+ if [ -z "$propagatedBuildOutputs" ]; then
608+ return;
609+ fi;
610+ mkdir -p "${!propagaterOutput}"/nix-support;
611+ for output in $propagatedBuildOutputs;
612+ do
613+ echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
614+ done
615+}
616+_nixLogWithLevel ()
617+{
618+
619+ [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
620+ local logLevel;
621+ case "${1:?}" in
622+ 0)
623+ logLevel=ERROR
624+ ;;
625+ 1)
626+ logLevel=WARN
627+ ;;
628+ 2)
629+ logLevel=NOTICE
630+ ;;
631+ 3)
632+ logLevel=INFO
633+ ;;
634+ 4)
635+ logLevel=TALKATIVE
636+ ;;
637+ 5)
638+ logLevel=CHATTY
639+ ;;
640+ 6)
641+ logLevel=DEBUG
642+ ;;
643+ 7)
644+ logLevel=VOMIT
645+ ;;
646+ *)
647+ echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
648+ return 1
649+ ;;
650+ esac;
651+ local callerName="${FUNCNAME[2]}";
652+ if [[ $callerName == "_callImplicitHook" ]]; then
653+ callerName="${hookName:?}";
654+ fi;
655+ printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
656+}
657+_overrideFirst ()
658+{
659+
660+ if [ -z "${!1-}" ]; then
661+ _assignFirst "$@";
662+ fi
663+}
664+_pruneLibtoolFiles ()
665+{
666+
667+ if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
668+ return;
669+ fi;
670+ find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
671+}
672+_updateSourceDateEpochFromSourceRoot ()
673+{
674+
675+ if [ -n "$sourceRoot" ]; then
676+ updateSourceDateEpoch "$sourceRoot";
677+ fi
678+}
679+activatePackage ()
680+{
681+
682+ local pkg="$1";
683+ local -r hostOffset="$2";
684+ local -r targetOffset="$3";
685+ (( hostOffset <= targetOffset )) || exit 1;
686+ if [ -f "$pkg" ]; then
687+ nixTalkativeLog "sourcing setup hook '$pkg'";
688+ source "$pkg";
689+ fi;
690+ if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
691+ addToSearchPath _PATH "$pkg/bin";
692+ fi;
693+ if (( hostOffset <= -1 )); then
694+ addToSearchPath _XDG_DATA_DIRS "$pkg/share";
695+ fi;
696+ if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
697+ addToSearchPath _HOST_PATH "$pkg/bin";
698+ fi;
699+ if [[ -f "$pkg/nix-support/setup-hook" ]]; then
700+ nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
701+ source "$pkg/nix-support/setup-hook";
702+ fi
703+}
704+addEnvHooks ()
705+{
706+
707+ local depHostOffset="$1";
708+ shift;
709+ local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
710+ local pkgHookVar;
711+ for pkgHookVar in "${!pkgHookVarsSlice}";
712+ do
713+ eval "${pkgHookVar}s"'+=("$@")';
714+ done
715+}
716+addPkgToClassPath ()
717+{
718+
719+ local jar;
720+ for jar in $1/share/java/*.jar;
721+ do
722+ export CLASSPATH=''${CLASSPATH-}''${CLASSPATH:+:}''${jar};
723+ done
724+}
725+addToSearchPath ()
726+{
727+
728+ addToSearchPathWithCustomDelimiter ":" "$@"
729+}
730+addToSearchPathWithCustomDelimiter ()
731+{
732+
733+ local delimiter="$1";
734+ local varName="$2";
735+ local dir="$3";
736+ if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
737+ export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
738+ fi
739+}
740+appendToVar ()
741+{
742+
743+ local -n nameref="$1";
744+ local useArray type;
745+ if [ -n "$__structuredAttrs" ]; then
746+ useArray=true;
747+ else
748+ useArray=false;
749+ fi;
750+ if type=$(declare -p "$1" 2> /dev/null); then
751+ case "${type#* }" in
752+ -A*)
753+ echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
754+ return 1
755+ ;;
756+ -a*)
757+ useArray=true
758+ ;;
759+ *)
760+ useArray=false
761+ ;;
762+ esac;
763+ fi;
764+ shift;
765+ if $useArray; then
766+ nameref=(${nameref+"${nameref[@]}"} "$@");
767+ else
768+ nameref="${nameref-} $*";
769+ fi
770+}
771+auditTmpdir ()
772+{
773+
774+ local dir="$1";
775+ [ -e "$dir" ] || return 0;
776+ echo "checking for references to $TMPDIR/ in $dir...";
777+ local tmpdir elf_fifo script_fifo;
778+ tmpdir="$(mktemp -d)";
779+ elf_fifo="$tmpdir/elf";
780+ script_fifo="$tmpdir/script";
781+ mkfifo "$elf_fifo" "$script_fifo";
782+ ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
783+ if isELF "$file"; then
784+ printf '%s\0' "$file" 1>&3;
785+ else
786+ if isScript "$file"; then
787+ filename=${file##*/};
788+ dir=${file%/*};
789+ if [ -e "$dir/.$filename-wrapped" ]; then
790+ printf '%s\0' "$file" 1>&4;
791+ fi;
792+ fi;
793+ fi;
794+ done;
795+ exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
796+ if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
797+ echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
798+ exit 1
799+ fi
800+ ' _ < "$elf_fifo" ) & local pid_elf=$!;
801+ local pid_script;
802+ ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
803+ if grep -q -F "$TMPDIR/" "$1"; then
804+ echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
805+ exit 1
806+ fi
807+ ' _ < "$script_fifo" ) & local pid_script=$!;
808+ wait "$pid_elf" || {
809+ echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
810+ exit 1
811+ };
812+ wait "$pid_script" || {
813+ echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
814+ exit 1
815+ };
816+ rm -r "$tmpdir"
817+}
818+bintoolsWrapper_addLDVars ()
819+{
820+
821+ local role_post;
822+ getHostRoleEnvHook;
823+ if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
824+ export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
825+ fi;
826+ if [[ -d "$1/lib" ]]; then
827+ local -a glob=($1/lib/lib*);
828+ if [ "${#glob[*]}" -gt 0 ]; then
829+ export NIX_LDFLAGS${role_post}+=" -L$1/lib";
830+ fi;
831+ fi
832+}
833+buildPhase ()
834+{
835+
836+ runHook preBuild;
837+ if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
838+ echo "no Makefile or custom buildPhase, doing nothing";
839+ else
840+ foundMakefile=1;
841+ local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
842+ concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
843+ echoCmd 'build flags' "${flagsArray[@]}";
844+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
845+ unset flagsArray;
846+ fi;
847+ runHook postBuild
848+}
849+ccWrapper_addCVars ()
850+{
851+
852+ local role_post;
853+ getHostRoleEnvHook;
854+ local found=;
855+ if [ -d "$1/include" ]; then
856+ export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
857+ found=1;
858+ fi;
859+ if [ -d "$1/Library/Frameworks" ]; then
860+ export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
861+ found=1;
862+ fi;
863+ if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
864+ local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
865+ export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
866+ fi
867+}
868+checkPhase ()
869+{
870+
871+ runHook preCheck;
872+ if [[ -z "${foundMakefile:-}" ]]; then
873+ echo "no Makefile or custom checkPhase, doing nothing";
874+ runHook postCheck;
875+ return;
876+ fi;
877+ if [[ -z "${checkTarget:-}" ]]; then
878+ if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
879+ checkTarget="check";
880+ else
881+ if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
882+ checkTarget="test";
883+ fi;
884+ fi;
885+ fi;
886+ if [[ -z "${checkTarget:-}" ]]; then
887+ echo "no check/test target in ${makefile:-Makefile}, doing nothing";
888+ else
889+ local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
890+ concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
891+ echoCmd 'check flags' "${flagsArray[@]}";
892+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
893+ unset flagsArray;
894+ fi;
895+ runHook postCheck
896+}
897+compressManPages ()
898+{
899+
900+ local dir="$1";
901+ if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
902+ return;
903+ fi;
904+ echo "gzipping man pages under $dir/share/man/";
905+ find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
906+ find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
907+ local target;
908+ target="$(readlink -f "$f")";
909+ if [ -f "$target".gz ]; then
910+ ln -sf "$target".gz "$f".gz && rm "$f";
911+ fi;
912+ done
913+}
914+concatStringsSep ()
915+{
916+
917+ local sep="$1";
918+ local name="$2";
919+ local type oldifs;
920+ if type=$(declare -p "$name" 2> /dev/null); then
921+ local -n nameref="$name";
922+ case "${type#* }" in
923+ -A*)
924+ echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
925+ return 1
926+ ;;
927+ -a*)
928+ local IFS="$(printf '\036')"
929+ ;;
930+ *)
931+ local IFS=" "
932+ ;;
933+ esac;
934+ local ifs_separated="${nameref[*]}";
935+ echo -n "${ifs_separated//"$IFS"/"$sep"}";
936+ fi
937+}
938+concatTo ()
939+{
940+
941+ local -;
942+ set -o noglob;
943+ local -n targetref="$1";
944+ shift;
945+ local arg default name type;
946+ for arg in "$@";
947+ do
948+ IFS="=" read -r name default <<< "$arg";
949+ local -n nameref="$name";
950+ if [[ -z "${nameref[*]}" && -n "$default" ]]; then
951+ targetref+=("$default");
952+ else
953+ if type=$(declare -p "$name" 2> /dev/null); then
954+ case "${type#* }" in
955+ -A*)
956+ echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
957+ return 1
958+ ;;
959+ -a*)
960+ targetref+=("${nameref[@]}")
961+ ;;
962+ *)
963+ if [[ "$name" = *"Array" ]]; then
964+ nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
965+ targetref+=(${nameref+"${nameref[@]}"});
966+ else
967+ targetref+=(${nameref-});
968+ fi
969+ ;;
970+ esac;
971+ fi;
972+ fi;
973+ done
974+}
975+configurePhase ()
976+{
977+
978+ runHook preConfigure;
979+ : "${configureScript=}";
980+ if [[ -z "$configureScript" && -x ./configure ]]; then
981+ configureScript=./configure;
982+ fi;
983+ if [ -z "${dontFixLibtool:-}" ]; then
984+ export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
985+ local i;
986+ find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
987+ echo "fixing libtool script $i";
988+ fixLibtool "$i";
989+ done;
990+ CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
991+ find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
992+ rm -f "$CONFIGURE_MTIME_REFERENCE";
993+ fi;
994+ if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
995+ local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
996+ if [ "${prefixKeyOrDefault: -1}" = " " ]; then
997+ prependToVar configureFlags "$prefix";
998+ prependToVar configureFlags "${prefixKeyOrDefault::-1}";
999+ else
1000+ prependToVar configureFlags "$prefixKeyOrDefault$prefix";
1001+ fi;
1002+ fi;
1003+ if [[ -f "$configureScript" ]]; then
1004+ if [ -z "${dontAddDisableDepTrack:-}" ]; then
1005+ if grep -q dependency-tracking "$configureScript"; then
1006+ prependToVar configureFlags --disable-dependency-tracking;
1007+ fi;
1008+ fi;
1009+ if [ -z "${dontDisableStatic:-}" ]; then
1010+ if grep -q enable-static "$configureScript"; then
1011+ prependToVar configureFlags --disable-static;
1012+ fi;
1013+ fi;
1014+ if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
1015+ patchShebangs --build "$configureScript";
1016+ fi;
1017+ fi;
1018+ if [ -n "$configureScript" ]; then
1019+ local -a flagsArray;
1020+ concatTo flagsArray configureFlags configureFlagsArray;
1021+ echoCmd 'configure flags' "${flagsArray[@]}";
1022+ $configureScript "${flagsArray[@]}";
1023+ unset flagsArray;
1024+ else
1025+ echo "no configure script, doing nothing";
1026+ fi;
1027+ runHook postConfigure
1028+}
1029+consumeEntire ()
1030+{
1031+
1032+ if IFS='' read -r -d '' "$1"; then
1033+ echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
1034+ return 1;
1035+ fi
1036+}
1037+definePhases ()
1038+{
1039+
1040+ if [ -z "${phases[*]:-}" ]; then
1041+ phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
1042+ fi
1043+}
1044+distPhase ()
1045+{
1046+
1047+ runHook preDist;
1048+ local flagsArray=();
1049+ concatTo flagsArray distFlags distFlagsArray distTarget=dist;
1050+ echo 'dist flags: %q' "${flagsArray[@]}";
1051+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
1052+ if [ "${dontCopyDist:-0}" != 1 ]; then
1053+ mkdir -p "$out/tarballs";
1054+ cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
1055+ fi;
1056+ runHook postDist
1057+}
1058+dumpVars ()
1059+{
1060+
1061+ if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
1062+ local old_umask;
1063+ old_umask=$(umask);
1064+ umask 0077;
1065+ export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
1066+ umask "$old_umask";
1067+ fi
1068+}
1069+echoCmd ()
1070+{
1071+
1072+ printf "%s:" "$1";
1073+ shift;
1074+ printf ' %q' "$@";
1075+ echo
1076+}
1077+exitHandler ()
1078+{
1079+
1080+ exitCode="$?";
1081+ set +e;
1082+ if [ -n "${showBuildStats:-}" ]; then
1083+ read -r -d '' -a buildTimes < <(times);
1084+ echo "build times:";
1085+ echo "user time for the shell ${buildTimes[0]}";
1086+ echo "system time for the shell ${buildTimes[1]}";
1087+ echo "user time for all child processes ${buildTimes[2]}";
1088+ echo "system time for all child processes ${buildTimes[3]}";
1089+ fi;
1090+ if (( "$exitCode" != 0 )); then
1091+ runHook failureHook;
1092+ if [ -n "${succeedOnFailure:-}" ]; then
1093+ echo "build failed with exit code $exitCode (ignored)";
1094+ mkdir -p "$out/nix-support";
1095+ printf "%s" "$exitCode" > "$out/nix-support/failed";
1096+ exit 0;
1097+ fi;
1098+ else
1099+ runHook exitHook;
1100+ fi;
1101+ return "$exitCode"
1102+}
1103+findInputs ()
1104+{
1105+
1106+ local -r pkg="$1";
1107+ local -r hostOffset="$2";
1108+ local -r targetOffset="$3";
1109+ (( hostOffset <= targetOffset )) || exit 1;
1110+ local varVar="${pkgAccumVarVars[hostOffset + 1]}";
1111+ local varRef="$varVar[$((targetOffset - hostOffset))]";
1112+ local var="${!varRef}";
1113+ unset -v varVar varRef;
1114+ local varSlice="$var[*]";
1115+ case " ${!varSlice-} " in
1116+ *" $pkg "*)
1117+ return 0
1118+ ;;
1119+ esac;
1120+ unset -v varSlice;
1121+ eval "$var"'+=("$pkg")';
1122+ if ! [ -e "$pkg" ]; then
1123+ echo "build input $pkg does not exist" 1>&2;
1124+ exit 1;
1125+ fi;
1126+ function mapOffset ()
1127+ {
1128+ local -r inputOffset="$1";
1129+ local -n outputOffset="$2";
1130+ if (( inputOffset <= 0 )); then
1131+ outputOffset=$((inputOffset + hostOffset));
1132+ else
1133+ outputOffset=$((inputOffset - 1 + targetOffset));
1134+ fi
1135+ };
1136+ local relHostOffset;
1137+ for relHostOffset in "${allPlatOffsets[@]}";
1138+ do
1139+ local files="${propagatedDepFilesVars[relHostOffset + 1]}";
1140+ local hostOffsetNext;
1141+ mapOffset "$relHostOffset" hostOffsetNext;
1142+ (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1143+ local relTargetOffset;
1144+ for relTargetOffset in "${allPlatOffsets[@]}";
1145+ do
1146+ (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
1147+ local fileRef="${files}[$relTargetOffset - $relHostOffset]";
1148+ local file="${!fileRef}";
1149+ unset -v fileRef;
1150+ local targetOffsetNext;
1151+ mapOffset "$relTargetOffset" targetOffsetNext;
1152+ (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1153+ [[ -f "$pkg/nix-support/$file" ]] || continue;
1154+ local pkgNext;
1155+ read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
1156+ for pkgNext in $pkgNext;
1157+ do
1158+ findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
1159+ done;
1160+ done;
1161+ done
1162+}
1163+fixLibtool ()
1164+{
1165+
1166+ local search_path;
1167+ for flag in $NIX_LDFLAGS;
1168+ do
1169+ case $flag in
1170+ -L*)
1171+ search_path+=" ${flag#-L}"
1172+ ;;
1173+ esac;
1174+ done;
1175+ sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
1176+}
1177+fixupPhase ()
1178+{
1179+
1180+ local output;
1181+ for output in $(getAllOutputNames);
1182+ do
1183+ if [ -e "${!output}" ]; then
1184+ chmod -R u+w,u-s,g-s "${!output}";
1185+ fi;
1186+ done;
1187+ runHook preFixup;
1188+ local output;
1189+ for output in $(getAllOutputNames);
1190+ do
1191+ prefix="${!output}" runHook fixupOutput;
1192+ done;
1193+ recordPropagatedDependencies;
1194+ if [ -n "${setupHook:-}" ]; then
1195+ mkdir -p "${!outputDev}/nix-support";
1196+ substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
1197+ fi;
1198+ if [ -n "${setupHooks:-}" ]; then
1199+ mkdir -p "${!outputDev}/nix-support";
1200+ local hook;
1201+ for hook in ${setupHooks[@]};
1202+ do
1203+ local content;
1204+ consumeEntire content < "$hook";
1205+ substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
1206+ unset -v content;
1207+ done;
1208+ unset -v hook;
1209+ fi;
1210+ if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
1211+ mkdir -p "${!outputBin}/nix-support";
1212+ printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
1213+ fi;
1214+ runHook postFixup
1215+}
1216+genericBuild ()
1217+{
1218+
1219+ export GZIP_NO_TIMESTAMPS=1;
1220+ if [ -f "${buildCommandPath:-}" ]; then
1221+ source "$buildCommandPath";
1222+ return;
1223+ fi;
1224+ if [ -n "${buildCommand:-}" ]; then
1225+ eval "$buildCommand";
1226+ return;
1227+ fi;
1228+ definePhases;
1229+ for curPhase in ${phases[*]};
1230+ do
1231+ runPhase "$curPhase";
1232+ done
1233+}
1234+getAllOutputNames ()
1235+{
1236+
1237+ if [ -n "$__structuredAttrs" ]; then
1238+ echo "${!outputs[*]}";
1239+ else
1240+ echo "$outputs";
1241+ fi
1242+}
1243+getHostRole ()
1244+{
1245+
1246+ getRole "$hostOffset"
1247+}
1248+getHostRoleEnvHook ()
1249+{
1250+
1251+ getRole "$depHostOffset"
1252+}
1253+getRole ()
1254+{
1255+
1256+ case $1 in
1257+ -1)
1258+ role_post='_FOR_BUILD'
1259+ ;;
1260+ 0)
1261+ role_post=''
1262+ ;;
1263+ 1)
1264+ role_post='_FOR_TARGET'
1265+ ;;
1266+ *)
1267+ echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1268+ return 1
1269+ ;;
1270+ esac
1271+}
1272+getTargetRole ()
1273+{
1274+
1275+ getRole "$targetOffset"
1276+}
1277+getTargetRoleEnvHook ()
1278+{
1279+
1280+ getRole "$depTargetOffset"
1281+}
1282+getTargetRoleWrapper ()
1283+{
1284+
1285+ case $targetOffset in
1286+ -1)
1287+ export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
1288+ ;;
1289+ 0)
1290+ export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
1291+ ;;
1292+ 1)
1293+ export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
1294+ ;;
1295+ *)
1296+ echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1297+ return 1
1298+ ;;
1299+ esac
1300+}
1301+gradle ()
1302+{
1303+
1304+ local flagsArray=();
1305+ concatTo flagsArray gradleFlags gradleFlagsArray;
1306+ command gradle "${flagsArray[@]}" "$@"
1307+}
1308+gradleBuildPhase ()
1309+{
1310+
1311+ runHook preBuild;
1312+ gradle ${enableParallelBuilding:+--parallel} ${gradleBuildTask:-assemble};
1313+ runHook postBuild
1314+}
1315+gradleCheckPhase ()
1316+{
1317+
1318+ runHook preCheck;
1319+ gradle ${enableParallelChecking:+--parallel} ${gradleCheckTask:-test};
1320+ runHook postCheck
1321+}
1322+gradleConfigureHook ()
1323+{
1324+
1325+ if [ -z "${GRADLE_USER_HOME-}" ]; then
1326+ GRADLE_USER_HOME="$(mktemp -d)";
1327+ fi;
1328+ export GRADLE_USER_HOME;
1329+ export TERM=dumb;
1330+ gradleFlagsArray+=(--no-daemon -Dorg.gradle.console=plain --init-script "${gradleInitScript:-/nix/store/qyybvyq09wv36br9lfd8jac92fgwy8f2-init-build.gradle}");
1331+ if [ -n "${MITM_CACHE_CA-}" ]; then
1332+ if [ -z "${MITM_CACHE_KEYSTORE-}" ]; then
1333+ MITM_CACHE_KEYSTORE="$MITM_CACHE_CERT_DIR/keystore";
1334+ MITM_CACHE_KS_PWD="$(head -c10 /dev/random | base32)";
1335+ echo y | /nix/store/vs2j17dr25ph3h1k4w1ngxnn8y5br1sa-openjdk-21.0.12+2/bin/keytool -importcert -file "$MITM_CACHE_CA" -alias alias -keystore "$MITM_CACHE_KEYSTORE" -storepass "$MITM_CACHE_KS_PWD";
1336+ fi;
1337+ gradleFlagsArray+=(-Dhttp.proxyHost="$MITM_CACHE_HOST" -Dhttp.proxyPort="$MITM_CACHE_PORT");
1338+ gradleFlagsArray+=(-Dhttps.proxyHost="$MITM_CACHE_HOST" -Dhttps.proxyPort="$MITM_CACHE_PORT");
1339+ gradleFlagsArray+=(-Djavax.net.ssl.trustStore="$MITM_CACHE_KEYSTORE" -Djavax.net.ssl.trustStorePassword="$MITM_CACHE_KS_PWD");
1340+ else
1341+ gradleFlagsArray+=(--offline);
1342+ fi;
1343+ if ! [[ -v enableParallelBuilding ]]; then
1344+ enableParallelBuilding=1;
1345+ fi;
1346+ if ! [[ -v enableParallelChecking ]]; then
1347+ enableParallelChecking=1;
1348+ fi;
1349+ if ! [[ -v enableParallelUpdating ]]; then
1350+ enableParallelUpdating=1;
1351+ fi
1352+}
1353+gradleUpdateScript ()
1354+{
1355+
1356+ runHook preBuild;
1357+ runHook preGradleUpdate;
1358+ gradle ${enableParallelUpdating:+--parallel} ${gradleUpdateTask:-nixDownloadDeps};
1359+ runHook postGradleUpdate
1360+}
1361+installCheckPhase ()
1362+{
1363+
1364+ runHook preInstallCheck;
1365+ if [[ -z "${foundMakefile:-}" ]]; then
1366+ echo "no Makefile or custom installCheckPhase, doing nothing";
1367+ else
1368+ if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
1369+ echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
1370+ else
1371+ local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1372+ concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
1373+ echoCmd 'installcheck flags' "${flagsArray[@]}";
1374+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
1375+ unset flagsArray;
1376+ fi;
1377+ fi;
1378+ runHook postInstallCheck
1379+}
1380+installPhase ()
1381+{
1382+
1383+ runHook preInstall;
1384+ if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
1385+ echo "no Makefile or custom installPhase, doing nothing";
1386+ runHook postInstall;
1387+ return;
1388+ else
1389+ foundMakefile=1;
1390+ fi;
1391+ if [ -n "$prefix" ]; then
1392+ mkdir -p "$prefix";
1393+ fi;
1394+ local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1395+ concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
1396+ echoCmd 'install flags' "${flagsArray[@]}";
1397+ make ${makefile:+-f $makefile} "${flagsArray[@]}";
1398+ unset flagsArray;
1399+ runHook postInstall
1400+}
1401+isELF ()
1402+{
1403+
1404+ local fn="$1";
1405+ local fd;
1406+ local magic;
1407+ exec {fd}< "$fn";
1408+ LANG=C read -r -n 4 -u "$fd" magic;
1409+ exec {fd}>&-;
1410+ if [ "$magic" = 'ELF' ]; then
1411+ return 0;
1412+ else
1413+ return 1;
1414+ fi
1415+}
1416+isMachO ()
1417+{
1418+
1419+ local fn="$1";
1420+ local fd;
1421+ local magic;
1422+ exec {fd}< "$fn";
1423+ LANG=C read -r -n 4 -u "$fd" magic;
1424+ exec {fd}>&-;
1425+ if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
1426+ return 0;
1427+ else
1428+ if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
1429+ return 0;
1430+ else
1431+ if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
1432+ return 0;
1433+ else
1434+ return 1;
1435+ fi;
1436+ fi;
1437+ fi
1438+}
1439+isScript ()
1440+{
1441+
1442+ local fn="$1";
1443+ local fd;
1444+ local magic;
1445+ exec {fd}< "$fn";
1446+ LANG=C read -r -n 2 -u "$fd" magic;
1447+ exec {fd}>&-;
1448+ if [[ "$magic" =~ \#! ]]; then
1449+ return 0;
1450+ else
1451+ return 1;
1452+ fi
1453+}
1454+mapOffset ()
1455+{
1456+
1457+ local -r inputOffset="$1";
1458+ local -n outputOffset="$2";
1459+ if (( inputOffset <= 0 )); then
1460+ outputOffset=$((inputOffset + hostOffset));
1461+ else
1462+ outputOffset=$((inputOffset - 1 + targetOffset));
1463+ fi
1464+}
1465+mitmCacheConfigureHook ()
1466+{
1467+
1468+ if [ -d "$mitmCache" ] && [ -z "$MITM_CACHE_CERT_DIR" ]; then
1469+ MITM_CACHE_CERT_DIR="$(mktemp -d)";
1470+ pushd "$MITM_CACHE_CERT_DIR";
1471+ MITM_CACHE_CA="$MITM_CACHE_CERT_DIR/ca.cer";
1472+ /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl genrsa -out ca.key 2048;
1473+ /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 -out ca.cer -subj "/C=AL/ST=a/L=a/O=a/OU=a/CN=example.org";
1474+ MITM_CACHE_HOST="127.0.0.1";
1475+ MITM_CACHE_PORT="${mitmCachePort:-$(/nix/store/ibdb2kp2gkqqbicwxp663s39sd5824zg-python3.13-ephemeral-port-reserve-1.1.4/bin/ephemeral-port-reserve "$MITM_CACHE_HOST")}";
1476+ MITM_CACHE_ADDRESS="$MITM_CACHE_HOST:$MITM_CACHE_PORT";
1477+ export http_proxy="$MITM_CACHE_ADDRESS";
1478+ export https_proxy="$MITM_CACHE_ADDRESS";
1479+ export SSL_CERT_FILE="$MITM_CACHE_CA";
1480+ export NIX_SSL_CERT_FILE="$MITM_CACHE_CA";
1481+ mitm-cache -l"$MITM_CACHE_ADDRESS" replay "$mitmCache" > /dev/null 2> /dev/null & popd;
1482+ fi
1483+}
1484+moveToOutput ()
1485+{
1486+
1487+ local patt="$1";
1488+ local dstOut="$2";
1489+ local output;
1490+ for output in $(getAllOutputNames);
1491+ do
1492+ if [ "${!output}" = "$dstOut" ]; then
1493+ continue;
1494+ fi;
1495+ local srcPath;
1496+ for srcPath in "${!output}"/$patt;
1497+ do
1498+ if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
1499+ continue;
1500+ fi;
1501+ if [ "$dstOut" = REMOVE ]; then
1502+ echo "Removing $srcPath";
1503+ rm -r "$srcPath";
1504+ else
1505+ local dstPath="$dstOut${srcPath#${!output}}";
1506+ echo "Moving $srcPath to $dstPath";
1507+ if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
1508+ rmdir "$srcPath" --ignore-fail-on-non-empty;
1509+ if [ -d "$srcPath" ]; then
1510+ mv -t "$dstPath" "$srcPath"/*;
1511+ rmdir "$srcPath";
1512+ fi;
1513+ else
1514+ mkdir -p "$(readlink -m "$dstPath/..")";
1515+ mv "$srcPath" "$dstPath";
1516+ fi;
1517+ fi;
1518+ local srcParent="$(readlink -m "$srcPath/..")";
1519+ if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
1520+ echo "Removing empty $srcParent/ and (possibly) its parents";
1521+ rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
1522+ fi;
1523+ done;
1524+ done
1525+}
1526+nixChattyLog ()
1527+{
1528+
1529+ _nixLogWithLevel 5 "$*"
1530+}
1531+nixDebugLog ()
1532+{
1533+
1534+ _nixLogWithLevel 6 "$*"
1535+}
1536+nixErrorLog ()
1537+{
1538+
1539+ _nixLogWithLevel 0 "$*"
1540+}
1541+nixInfoLog ()
1542+{
1543+
1544+ _nixLogWithLevel 3 "$*"
1545+}
1546+nixLog ()
1547+{
1548+
1549+ [[ -z ${NIX_LOG_FD-} ]] && return 0;
1550+ local callerName="${FUNCNAME[1]}";
1551+ if [[ $callerName == "_callImplicitHook" ]]; then
1552+ callerName="${hookName:?}";
1553+ fi;
1554+ printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
1555+}
1556+nixNoticeLog ()
1557+{
1558+
1559+ _nixLogWithLevel 2 "$*"
1560+}
1561+nixTalkativeLog ()
1562+{
1563+
1564+ _nixLogWithLevel 4 "$*"
1565+}
1566+nixVomitLog ()
1567+{
1568+
1569+ _nixLogWithLevel 7 "$*"
1570+}
1571+nixWarnLog ()
1572+{
1573+
1574+ _nixLogWithLevel 1 "$*"
1575+}
1576+noBrokenSymlinks ()
1577+{
1578+
1579+ local -r output="${1:?}";
1580+ local path;
1581+ local pathParent;
1582+ local symlinkTarget;
1583+ local -i numDanglingSymlinks=0;
1584+ local -i numReflexiveSymlinks=0;
1585+ local -i numUnreadableSymlinks=0;
1586+ if [[ ! -e $output ]]; then
1587+ nixWarnLog "skipping non-existent output $output";
1588+ return 0;
1589+ fi;
1590+ nixInfoLog "running on $output";
1591+ while IFS= read -r -d '' path; do
1592+ pathParent="$(dirname "$path")";
1593+ if ! symlinkTarget="$(readlink "$path")"; then
1594+ nixErrorLog "the symlink $path is unreadable";
1595+ numUnreadableSymlinks+=1;
1596+ continue;
1597+ fi;
1598+ if [[ $symlinkTarget == /* ]]; then
1599+ nixInfoLog "symlink $path points to absolute target $symlinkTarget";
1600+ else
1601+ nixInfoLog "symlink $path points to relative target $symlinkTarget";
1602+ symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
1603+ fi;
1604+ if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
1605+ nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
1606+ numDanglingSymlinks+=1;
1607+ continue;
1608+ fi;
1609+ if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
1610+ nixInfoLog "symlink $path points outside the Nix store; ignoring";
1611+ continue;
1612+ fi;
1613+ if [[ $path == "$symlinkTarget" ]]; then
1614+ nixErrorLog "the symlink $path is reflexive";
1615+ numReflexiveSymlinks+=1;
1616+ else
1617+ if [[ ! -e $symlinkTarget ]]; then
1618+ nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
1619+ numDanglingSymlinks+=1;
1620+ else
1621+ nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
1622+ fi;
1623+ fi;
1624+ done < <(find "$output" -type l -print0);
1625+ if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
1626+ nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
1627+ exit 1;
1628+ fi;
1629+ return 0
1630+}
1631+noBrokenSymlinksInAllOutputs ()
1632+{
1633+
1634+ if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
1635+ for output in $(getAllOutputNames);
1636+ do
1637+ noBrokenSymlinks "${!output}";
1638+ done;
1639+ fi
1640+}
1641+patchELF ()
1642+{
1643+
1644+ local dir="$1";
1645+ [ -e "$dir" ] || return 0;
1646+ echo "shrinking RPATHs of ELF executables and libraries in $dir";
1647+ local i;
1648+ while IFS= read -r -d '' i; do
1649+ if [[ "$i" =~ .build-id ]]; then
1650+ continue;
1651+ fi;
1652+ if ! isELF "$i"; then
1653+ continue;
1654+ fi;
1655+ echo "shrinking $i";
1656+ patchelf --shrink-rpath "$i" || true;
1657+ done < <(find "$dir" -type f -print0)
1658+}
1659+patchPhase ()
1660+{
1661+
1662+ runHook prePatch;
1663+ local -a patchesArray;
1664+ concatTo patchesArray patches;
1665+ local -a flagsArray;
1666+ concatTo flagsArray patchFlags=-p1;
1667+ for i in "${patchesArray[@]}";
1668+ do
1669+ echo "applying patch $i";
1670+ local uncompress=cat;
1671+ case "$i" in
1672+ *.gz)
1673+ uncompress="gzip -d"
1674+ ;;
1675+ *.bz2)
1676+ uncompress="bzip2 -d"
1677+ ;;
1678+ *.xz)
1679+ uncompress="xz -d"
1680+ ;;
1681+ *.lzma)
1682+ uncompress="lzma -d"
1683+ ;;
1684+ esac;
1685+ $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
1686+ done;
1687+ runHook postPatch
1688+}
1689+patchShebangs ()
1690+{
1691+
1692+ local pathName;
1693+ local update=false;
1694+ while [[ $# -gt 0 ]]; do
1695+ case "$1" in
1696+ --host)
1697+ pathName=HOST_PATH;
1698+ shift
1699+ ;;
1700+ --build)
1701+ pathName=PATH;
1702+ shift
1703+ ;;
1704+ --update)
1705+ update=true;
1706+ shift
1707+ ;;
1708+ --)
1709+ shift;
1710+ break
1711+ ;;
1712+ -* | --*)
1713+ echo "Unknown option $1 supplied to patchShebangs" 1>&2;
1714+ return 1
1715+ ;;
1716+ *)
1717+ break
1718+ ;;
1719+ esac;
1720+ done;
1721+ echo "patching script interpreter paths in $@";
1722+ local f;
1723+ local oldPath;
1724+ local newPath;
1725+ local arg0;
1726+ local args;
1727+ local oldInterpreterLine;
1728+ local newInterpreterLine;
1729+ if [[ $# -eq 0 ]]; then
1730+ echo "No arguments supplied to patchShebangs" 1>&2;
1731+ return 0;
1732+ fi;
1733+ local f;
1734+ while IFS= read -r -d '' f; do
1735+ isScript "$f" || continue;
1736+ read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
1737+ read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
1738+ if [[ -z "${pathName:-}" ]]; then
1739+ if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
1740+ pathName=HOST_PATH;
1741+ else
1742+ pathName=PATH;
1743+ fi;
1744+ fi;
1745+ if [[ "$oldPath" == *"/bin/env" ]]; then
1746+ if [[ $arg0 == "-S" ]]; then
1747+ arg0=${args%% *};
1748+ [[ "$args" == *" "* ]] && args=${args#* } || args=;
1749+ newPath="$(PATH="${!pathName}" type -P "env" || true)";
1750+ args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
1751+ else
1752+ if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
1753+ echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
1754+ exit 1;
1755+ else
1756+ newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
1757+ fi;
1758+ fi;
1759+ else
1760+ if [[ -z $oldPath ]]; then
1761+ oldPath="/bin/sh";
1762+ fi;
1763+ newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
1764+ args="$arg0 $args";
1765+ fi;
1766+ newInterpreterLine="$newPath $args";
1767+ newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
1768+ if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
1769+ if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
1770+ echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
1771+ escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
1772+ timestamp=$(stat --printf "%y" "$f");
1773+ tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
1774+ sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
1775+ local restoreReadOnly;
1776+ if [[ ! -w "$f" ]]; then
1777+ chmod +w "$f";
1778+ restoreReadOnly=true;
1779+ fi;
1780+ cat "$tmpFile" > "$f";
1781+ rm "$tmpFile";
1782+ if [[ -n "${restoreReadOnly:-}" ]]; then
1783+ chmod -w "$f";
1784+ fi;
1785+ touch --date "$timestamp" "$f";
1786+ fi;
1787+ fi;
1788+ done < <(find "$@" -type f -perm -0100 -print0)
1789+}
1790+patchShebangsAuto ()
1791+{
1792+
1793+ if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
1794+ if [[ "$output" != out && "$output" = "$outputDev" ]]; then
1795+ patchShebangs --build "$prefix";
1796+ else
1797+ patchShebangs --host "$prefix";
1798+ fi;
1799+ fi
1800+}
1801+prependToVar ()
1802+{
1803+
1804+ local -n nameref="$1";
1805+ local useArray type;
1806+ if [ -n "$__structuredAttrs" ]; then
1807+ useArray=true;
1808+ else
1809+ useArray=false;
1810+ fi;
1811+ if type=$(declare -p "$1" 2> /dev/null); then
1812+ case "${type#* }" in
1813+ -A*)
1814+ echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
1815+ return 1
1816+ ;;
1817+ -a*)
1818+ useArray=true
1819+ ;;
1820+ *)
1821+ useArray=false
1822+ ;;
1823+ esac;
1824+ fi;
1825+ shift;
1826+ if $useArray; then
1827+ nameref=("$@" ${nameref+"${nameref[@]}"});
1828+ else
1829+ nameref="$* ${nameref-}";
1830+ fi
1831+}
1832+printLines ()
1833+{
1834+
1835+ (( "$#" > 0 )) || return 0;
1836+ printf '%s\n' "$@"
1837+}
1838+printPhases ()
1839+{
1840+
1841+ definePhases;
1842+ local phase;
1843+ for phase in ${phases[*]};
1844+ do
1845+ printf '%s\n' "$phase";
1846+ done
1847+}
1848+printWords ()
1849+{
1850+
1851+ (( "$#" > 0 )) || return 0;
1852+ printf '%s ' "$@"
1853+}
1854+recordPropagatedDependencies ()
1855+{
1856+
1857+ declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
1858+ declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
1859+ local propagatedInputsIndex;
1860+ for propagatedInputsIndex in "${!flatVars[@]}";
1861+ do
1862+ local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
1863+ local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
1864+ [[ -n "${!propagatedInputsSlice}" ]] || continue;
1865+ mkdir -p "${!outputDev}/nix-support";
1866+ printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
1867+ done
1868+}
1869+runHook ()
1870+{
1871+
1872+ local hookName="$1";
1873+ shift;
1874+ local hooksSlice="${hookName%Hook}Hooks[@]";
1875+ local hook;
1876+ for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1877+ do
1878+ _logHook "$hookName" "$hook" "$@";
1879+ _eval "$hook" "$@";
1880+ done;
1881+ return 0
1882+}
1883+runOneHook ()
1884+{
1885+
1886+ local hookName="$1";
1887+ shift;
1888+ local hooksSlice="${hookName%Hook}Hooks[@]";
1889+ local hook ret=1;
1890+ for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1891+ do
1892+ _logHook "$hookName" "$hook" "$@";
1893+ if _eval "$hook" "$@"; then
1894+ ret=0;
1895+ break;
1896+ fi;
1897+ done;
1898+ return "$ret"
1899+}
1900+runPhase ()
1901+{
1902+
1903+ local curPhase="$*";
1904+ if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
1905+ return;
1906+ fi;
1907+ if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
1908+ return;
1909+ fi;
1910+ if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
1911+ return;
1912+ fi;
1913+ if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
1914+ return;
1915+ fi;
1916+ if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
1917+ return;
1918+ fi;
1919+ if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
1920+ return;
1921+ fi;
1922+ if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
1923+ return;
1924+ fi;
1925+ if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
1926+ return;
1927+ fi;
1928+ if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
1929+ return;
1930+ fi;
1931+ showPhaseHeader "$curPhase";
1932+ dumpVars;
1933+ local startTime endTime;
1934+ startTime=$(date +"%s");
1935+ eval "${!curPhase:-$curPhase}";
1936+ endTime=$(date +"%s");
1937+ showPhaseFooter "$curPhase" "$startTime" "$endTime";
1938+ if [ "$curPhase" = unpackPhase ]; then
1939+ [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
1940+ cd -- "${sourceRoot:-.}";
1941+ fi
1942+}
1943+showPhaseFooter ()
1944+{
1945+
1946+ local phase="$1";
1947+ local startTime="$2";
1948+ local endTime="$3";
1949+ local delta=$(( endTime - startTime ));
1950+ (( delta < 30 )) && return;
1951+ local H=$((delta/3600));
1952+ local M=$((delta%3600/60));
1953+ local S=$((delta%60));
1954+ echo -n "$phase completed in ";
1955+ (( H > 0 )) && echo -n "$H hours ";
1956+ (( M > 0 )) && echo -n "$M minutes ";
1957+ echo "$S seconds"
1958+}
1959+showPhaseHeader ()
1960+{
1961+
1962+ local phase="$1";
1963+ echo "Running phase: $phase";
1964+ if [[ -z ${NIX_LOG_FD-} ]]; then
1965+ return;
1966+ fi;
1967+ printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
1968+}
1969+stripDirs ()
1970+{
1971+
1972+ local cmd="$1";
1973+ local ranlibCmd="$2";
1974+ local paths="$3";
1975+ local stripFlags="$4";
1976+ local excludeFlags=();
1977+ local pathsNew=;
1978+ [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
1979+ [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
1980+ local pattern;
1981+ if [ -n "${stripExclude:-}" ]; then
1982+ for pattern in "${stripExclude[@]}";
1983+ do
1984+ excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
1985+ done;
1986+ fi;
1987+ local p;
1988+ for p in ${paths};
1989+ do
1990+ if [ -e "$prefix/$p" ]; then
1991+ pathsNew="${pathsNew} $prefix/$p";
1992+ fi;
1993+ done;
1994+ paths=${pathsNew};
1995+ if [ -n "${paths}" ]; then
1996+ echo "stripping (with command $cmd and flags $stripFlags) in $paths";
1997+ local striperr;
1998+ striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
1999+ find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
2000+ [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
2001+ rm "$striperr";
2002+ find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
2003+ fi
2004+}
2005+stripHash ()
2006+{
2007+
2008+ local strippedName casematchOpt=0;
2009+ strippedName="$(basename -- "$1")";
2010+ shopt -q nocasematch && casematchOpt=1;
2011+ shopt -u nocasematch;
2012+ if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
2013+ echo "${strippedName:33}";
2014+ else
2015+ echo "$strippedName";
2016+ fi;
2017+ if (( casematchOpt )); then
2018+ shopt -s nocasematch;
2019+ fi
2020+}
2021+substitute ()
2022+{
2023+
2024+ local input="$1";
2025+ local output="$2";
2026+ shift 2;
2027+ if [ ! -f "$input" ]; then
2028+ echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
2029+ return 1;
2030+ fi;
2031+ local content;
2032+ consumeEntire content < "$input";
2033+ if [ -e "$output" ]; then
2034+ chmod +w "$output";
2035+ fi;
2036+ substituteStream content "file '$input'" "$@" > "$output"
2037+}
2038+substituteAll ()
2039+{
2040+
2041+ local input="$1";
2042+ local output="$2";
2043+ local -a args=();
2044+ _allFlags;
2045+ substitute "$input" "$output" "${args[@]}"
2046+}
2047+substituteAllInPlace ()
2048+{
2049+
2050+ local fileName="$1";
2051+ shift;
2052+ substituteAll "$fileName" "$fileName" "$@"
2053+}
2054+substituteAllStream ()
2055+{
2056+
2057+ local -a args=();
2058+ _allFlags;
2059+ substituteStream "$1" "$2" "${args[@]}"
2060+}
2061+substituteInPlace ()
2062+{
2063+
2064+ local -a fileNames=();
2065+ for arg in "$@";
2066+ do
2067+ if [[ "$arg" = "--"* ]]; then
2068+ break;
2069+ fi;
2070+ fileNames+=("$arg");
2071+ shift;
2072+ done;
2073+ if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
2074+ echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
2075+ return 1;
2076+ fi;
2077+ for file in "${fileNames[@]}";
2078+ do
2079+ substitute "$file" "$file" "$@";
2080+ done
2081+}
2082+substituteStream ()
2083+{
2084+
2085+ local var=$1;
2086+ local description=$2;
2087+ shift 2;
2088+ while (( "$#" )); do
2089+ local replace_mode="$1";
2090+ case "$1" in
2091+ --replace)
2092+ if ! "$_substituteStream_has_warned_replace_deprecation"; then
2093+ echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
2094+ _substituteStream_has_warned_replace_deprecation=true;
2095+ fi;
2096+ replace_mode='--replace-warn'
2097+ ;&
2098+ --replace-quiet | --replace-warn | --replace-fail)
2099+ pattern="$2";
2100+ replacement="$3";
2101+ shift 3;
2102+ if ! [[ "${!var}" == *"$pattern"* ]]; then
2103+ if [ "$replace_mode" == --replace-warn ]; then
2104+ printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2105+ else
2106+ if [ "$replace_mode" == --replace-fail ]; then
2107+ printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2108+ return 1;
2109+ fi;
2110+ fi;
2111+ fi;
2112+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2113+ ;;
2114+ --subst-var)
2115+ local varName="$2";
2116+ shift 2;
2117+ if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
2118+ echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
2119+ return 1;
2120+ fi;
2121+ if [ -z ${!varName+x} ]; then
2122+ echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
2123+ return 1;
2124+ fi;
2125+ pattern="@$varName@";
2126+ replacement="${!varName}";
2127+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2128+ ;;
2129+ --subst-var-by)
2130+ pattern="@$2@";
2131+ replacement="$3";
2132+ eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
2133+ shift 3
2134+ ;;
2135+ *)
2136+ echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
2137+ return 1
2138+ ;;
2139+ esac;
2140+ done;
2141+ printf "%s" "${!var}"
2142+}
2143+unpackFile ()
2144+{
2145+
2146+ curSrc="$1";
2147+ echo "unpacking source archive $curSrc";
2148+ if ! runOneHook unpackCmd "$curSrc"; then
2149+ echo "do not know how to unpack source archive $curSrc";
2150+ exit 1;
2151+ fi
2152+}
2153+unpackPhase ()
2154+{
2155+
2156+ runHook preUnpack;
2157+ if [ -z "${srcs:-}" ]; then
2158+ if [ -z "${src:-}" ]; then
2159+ echo 'variable $src or $srcs should point to the source';
2160+ exit 1;
2161+ fi;
2162+ srcs="$src";
2163+ fi;
2164+ local -a srcsArray;
2165+ concatTo srcsArray srcs;
2166+ local dirsBefore="";
2167+ for i in *;
2168+ do
2169+ if [ -d "$i" ]; then
2170+ dirsBefore="$dirsBefore $i ";
2171+ fi;
2172+ done;
2173+ for i in "${srcsArray[@]}";
2174+ do
2175+ unpackFile "$i";
2176+ done;
2177+ : "${sourceRoot=}";
2178+ if [ -n "${setSourceRoot:-}" ]; then
2179+ runOneHook setSourceRoot;
2180+ else
2181+ if [ -z "$sourceRoot" ]; then
2182+ for i in *;
2183+ do
2184+ if [ -d "$i" ]; then
2185+ case $dirsBefore in
2186+ *\ $i\ *)
2187+
2188+ ;;
2189+ *)
2190+ if [ -n "$sourceRoot" ]; then
2191+ echo "unpacker produced multiple directories";
2192+ exit 1;
2193+ fi;
2194+ sourceRoot="$i"
2195+ ;;
2196+ esac;
2197+ fi;
2198+ done;
2199+ fi;
2200+ fi;
2201+ if [ -z "$sourceRoot" ]; then
2202+ echo "unpacker appears to have produced no directories";
2203+ exit 1;
2204+ fi;
2205+ echo "source root is $sourceRoot";
2206+ if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
2207+ chmod -R u+w -- "$sourceRoot";
2208+ fi;
2209+ runHook postUnpack
2210+}
2211+updateAutotoolsGnuConfigScriptsPhase ()
2212+{
2213+
2214+ if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
2215+ return;
2216+ fi;
2217+ for script in config.sub config.guess;
2218+ do
2219+ for f in $(find . -type f -name "$script");
2220+ do
2221+ echo "Updating Autotools / GNU config script to a newer upstream version: $f";
2222+ cp -f "/nix/store/4i7gvqwiq5mbl5pxh43frzq2nibxvwb5-gnu-config-2024-01-01/$script" "$f";
2223+ done;
2224+ done
2225+}
2226+updateSourceDateEpoch ()
2227+{
2228+
2229+ local path="$1";
2230+ [[ $path == -* ]] && path="./$path";
2231+ local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
2232+ local time="${res[0]//\.[0-9]*/}";
2233+ local newestFile="${res[1]}";
2234+ if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
2235+ echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
2236+ export SOURCE_DATE_EPOCH="$time";
2237+ local now="$(date +%s)";
2238+ if [ "$time" -gt $((now - 60)) ]; then
2239+ echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
2240+ fi;
2241+ fi
2242+}
2243+PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
2244+XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
2245+export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
2246+export TMP="$NIX_BUILD_TOP"
2247+export TMPDIR="$NIX_BUILD_TOP"
2248+export TEMP="$NIX_BUILD_TOP"
2249+export TEMPDIR="$NIX_BUILD_TOP"
2250+eval "${shellHook:-}"
.github/workflows/build.yml+16−18
@@ -5,26 +5,8 @@ on:
55 branches: [master]
66 pull_request:
77
8−# Cancel an in-progress run when a newer commit is pushed to the same ref, so CI minutes
9−# aren't spent finishing a build that's already superseded.
10−concurrency:
11− group: ${{ github.workflow }}-${{ github.ref }}
12− cancel-in-progress: true
13−
148jobs:
15− # The Crux core is plain Rust — build and unit-test it natively (the Android
16− # shell mirrors this logic; see README).
17− core:
18− name: Crux core
19− runs-on: ubuntu-latest
20− steps:
21− - uses: actions/checkout@v4
22− - name: Test the Crux core
23− working-directory: shared
24− run: cargo test
25−
269 build:
27− name: Android app
2810 runs-on: ubuntu-latest
2911
3012 steps:
@@ -42,15 +24,31 @@ jobs:
4224 - name: Run unit tests
4325 run: ./gradlew testDebugUnitTest --no-daemon
4426
27+ - name: Static analysis (detekt)
28+ run: ./gradlew detekt --no-daemon
29+
4530 - name: Lint
4631 run: ./gradlew lintDebug --no-daemon
4732
4833 - name: Build debug APK
4934 run: ./gradlew assembleDebug --no-daemon
5035
36+ - name: Build release APK
37+ run: ./gradlew assembleRelease --no-daemon
38+
5139 - name: Upload debug APK
5240 uses: actions/upload-artifact@v4
5341 with:
5442 name: opencode-debug.apk
5543 path: app/build/outputs/apk/debug/*.apk
5644 if-no-files-found: error
45+
46+ # The release output dir is `release/` when unsigned but `release-signed/` (or a
47+ # variant name) once a signing config is applied. Match both so CI keeps working
48+ # once the OPENCODE_STORE_* secrets are wired up.
49+ - name: Upload release APK
50+ uses: actions/upload-artifact@v4
51+ with:
52+ name: opencode-release.apk
53+ path: app/build/outputs/apk/**/*.apk
54+ if-no-files-found: error
.github/workflows/release.ymldeleted−113
@@ -1,113 +0,0 @@
1−name: Release
2−
3−# Build a minified, signed release APK and publish it as a GitHub Release.
4−#
5−# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6−# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7−# Actions tab (workflow_dispatch). For a branch push or manual run the version
8−# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9−# tag name (with the leading `v` stripped) as the version.
10−#
11−# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12−# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13−#
14−# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15−# with that keystore; otherwise the Gradle build falls back to the debug key so
16−# the released APK is still installable (see app/build.gradle.kts). The version
17−# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
18−on:
19− push:
20− branches: [master]
21− tags: ["v*"]
22− workflow_dispatch:
23−
24−# Never abort a release mid-flight — a half-published release with a missing
25−# artifact is worse than a slightly delayed one.
26−concurrency:
27− group: ${{ github.workflow }}-${{ github.ref }}
28− cancel-in-progress: false
29−
30−jobs:
31− release:
32− runs-on: ubuntu-latest
33− permissions:
34− contents: write # push the tag and create the release
35− id-token: write # OIDC so GitHub can sign the provenance attestation
36− attestations: write # publish the attestation record
37− env:
38− OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39− OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40− OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41− OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
42− steps:
43− - uses: actions/checkout@v4
44− with:
45− # Full history so the versionCode (git rev-list --count HEAD) is
46− # accurate instead of collapsing to 1 on a shallow clone.
47− fetch-depth: 0
48−
49− - name: Compute version
50− id: version
51− run: |
52− if [ "${{ github.ref_type }}" = "tag" ]; then
53− VERSION="${GITHUB_REF_NAME#v}"
54− TAG="${GITHUB_REF_NAME}"
55− else
56− VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57− TAG="v${VERSION}"
58− fi
59− CODE="$(git rev-list --count HEAD)"
60− {
61− echo "version=$VERSION"
62− echo "tag=$TAG"
63− echo "code=$CODE"
64− } >> "$GITHUB_OUTPUT"
65− echo "Releasing $TAG (version $VERSION, code $CODE)"
66−
67− - uses: actions/setup-java@v4
68− with:
69− distribution: temurin
70− java-version: 17
71−
72− - uses: android-actions/setup-android@v3
73−
74− - uses: gradle/actions/setup-gradle@v4
75−
76− # The keystore ships as base64 in a secret so it never touches the repo.
77− # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
78− - name: Decode signing keystore
79− if: env.OPENCODE_STORE_BASE64 != ''
80− run: |
81− STORE_FILE="$RUNNER_TEMP/release.keystore"
82− printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83− echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84−
85− - name: Build release APK
86− run: |
87− ./gradlew assembleRelease \
88− -PversionName="${{ steps.version.outputs.version }}" \
89− -PversionCode="${{ steps.version.outputs.code }}" \
90− --no-daemon
91−
92− # Rename to a stable, version-tagged filename for the release attachment.
93− - name: Stage APK
94− run: |
95− mkdir -p staging
96− APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97− cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98−
99− # Signed build-provenance attestation for the exact artifact being
100− # published; later verifiable with `gh attestation verify <apk>`.
101− - name: Attest build provenance
102− uses: actions/attest-build-provenance@v2
103− with:
104− subject-path: staging/*.apk
105−
106− - name: Create release
107− uses: softprops/action-gh-release@v2
108− with:
109− tag_name: ${{ steps.version.outputs.tag }}
110− name: ${{ steps.version.outputs.version }}
111− make_latest: true
112− generate_release_notes: true
113− files: staging/*.apk
.gitignore−20
@@ -1,8 +1,6 @@
11# Gradle
22.gradle/
33build/
4−*.apk
5−*.aab
64
75# Kotlin
86.kotlin/
@@ -31,21 +29,3 @@ Thumbs.db
3129
3230# Nix
3331result
34−result-*
35−.direnv/
36−
37−# Local tooling / editor state (not part of the project)
38−.crush/
39−.crush.db
40−*.log
41−
42−# Rust
43−shared/target/
44−/target/
45−
46−# The `generated/` shared type sources ARE committed (regenerable via
47−# `cargo run --features typegen --bin codegen`); only the built JNI .so
48−# artifacts are ignored.
49−generated/jniLibs/
50−
51−# Gradle wrapper jar is committed so CI can run ./gradlew
AGENTS.mdnew file+197
@@ -0,0 +1,197 @@
1+# AGENTS.md
2+
3+Guidance for AI coding agents (and humans) working in this repository.
4+
5+## Project
6+
7+opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8+[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9+remote opencode server over REST plus a long-lived Server-Sent Events stream,
10+and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11+an adaptive single-pane / two-pane Material 3 UI.
12+
13+- Application id / namespace: `soy.iko.opencode`
14+- Package root: `app/src/main/java/soy/iko/opencode`
15+- minSdk 26, compileSdk/targetSdk 35, JDK 17, Kotlin 2.0.x, AGP 8.7.x
16+- Single Gradle module (`:app`); no dynamic feature modules.
17+
18+## Build & environment
19+
20+A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 35, and a
21+Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22+
23+```bash
24+nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26+./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27+```
28+
29+Outside Nix, you need JDK 17 and the Android SDK (platform 35, build-tools
30+35.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31+
32+Install on a connected device/emulator:
33+
34+```bash
35+adb install -r app/build/outputs/apk/debug/app-debug.apk
36+adb shell am start -n soy.iko.opencode/.MainActivity
37+```
38+
39+## Test, lint, and static analysis
40+
41+```bash
42+./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43+./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44+./gradlew lintDebug # Android Lint (abortOnError = true)
45+./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46+```
47+
48+- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49+ `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50+ that override the `open` members of the real stores/clients). JUnit 4 +
51+ `kotlinx-coroutines-test`; no Robolectric — `testOptions.unitTests.isReturnDefaultValues = true`
52+ stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53+- Instrumented tests live in `app/src/androidTest/...` and need a device.
54+- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55+ findings block CI; existing ones are grandfathered.
56+- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` → `detekt` →
57+ `lintDebug` → `assembleDebug` → `assembleRelease`, then uploads both APKs.
58+
59+## Running a server to develop against
60+
61+```bash
62+opencode serve --hostname 0.0.0.0 --port 4096
63+```
64+
65+Connect the app over LAN (`http://<host-ip>:4096`) or USB
66+(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67+(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68+"Set up a server and connect to it" section for full details.
69+
70+## Architecture
71+
72+Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73+
74+```
75+OpencodeApp (Application)
76+ └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77+ unread tracker, auto-reconnect, pending share / open-session signals)
78+ └─ OpencodeConnection (one per active server profile)
79+ ├─ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80+ ├─ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81+ ├─ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82+ └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83+ └─ MessageStore (in-memory reducer; internal, unit-tested)
84+
85+ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86+ └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87+ └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88+```
89+
90+Key invariants:
91+
92+- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93+ the SSE stream *before* the initial REST load and before any prompt POST, so
94+ early streamed parts aren't missed. Preserve this ordering if you touch it.
95+- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96+ for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97+ discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98+ throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99+ `UnknownEvent` — the whole point is that a new server release can't break the
100+ client. Add new modeled variants alongside the `Unknown` fallback.
101+- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102+ per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103+- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104+ `Connected` after a prior connection, the repository re-fetches messages from
105+ REST and merges (without pruning) under a generation counter so a stale fetch
106+ can't clobber a newer one. See `SessionRepository.kt` for the contract.
107+- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108+ the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109+ eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110+ for HTTP profiles the `Authorization` header is set proactively in
111+ `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112+ installed so credentials can't be re-sent silently on a cleartext 401.
113+- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114+ `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115+ called on real devices) that cancels the app scope and closes the active
116+ connection. `AppContainer.shutdown()` is guarded against double-invocation.
117+
118+## Source map
119+
120+```
121+data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122+data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123+ OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124+data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125+ EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126+ DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127+di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128+notification NotificationChannels, RunForegroundService (holds process priority
129+ during a run), SessionNotifications (completion notifications)
130+ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131+ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132+ agent/model/command picker sheets, permission dialog, export
133+ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134+ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135+ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136+ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137+util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138+```
139+
140+## Conventions
141+
142+- **No comments unless asked** — existing code has extensive `//` comments
143+ explaining *why* (invariants, race conditions, security rationale). When you
144+ add code, match that style only for genuinely non-obvious invariants; don't
145+ narrate *what* the code does.
146+- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147+ inside coroutines — plain `runCatching` swallows `CancellationException` and
148+ breaks structured concurrency. Use it for any try/catch in a suspend context.
149+- **`safeExceptionSummary`** for logging Ktor exceptions — `ClientRequestException`
150+ messages embed the full request URL (may contain auth/paths); never log the
151+ raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152+- **`open` classes with protected no-arg test constructors.** Stores, the API
153+ client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154+ `open` and have a `protected constructor()` so tests can subclass and override
155+ individual members without instantiating Android frameworks. Keep this pattern
156+ when adding new injectable components.
157+- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158+ sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159+ than scattering literals.
160+- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161+ `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162+ against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163+- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164+ `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165+ Preserve this when adding mutating endpoints.
166+- **`withRetry`** wraps all REST reads and the permission response. Transient
167+ failures back off with jitter; see `NetworkConfig` for the parameters.
168+- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169+ 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170+ use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171+ composables and long parameter lists are accepted (detekt `LongMethod` /
172+ `LongParameterList` disabled).
173+- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174+ (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175+ Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176+- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177+ correctly.
178+- **Kotlin code style: `official`** (see `gradle.properties`).
179+
180+## Things to avoid
181+
182+- Don't add Hilt/Dagger/KSP — DI is intentionally hand-written (`AppContainer`).
183+- Don't add Robolectric — JVM tests use `isReturnDefaultValues = true` stubs and
184+ fakes; only device-dependent code goes in `androidTest`.
185+- Don't run `nix flake check` manually (CI only — per repo convention).
186+- Don't read or query `/nix/store` (extremely large — per repo convention).
187+- Don't introduce new detekt findings; the baseline only grandfathers existing
188+ ones. Run `./gradlew detekt` before considering work done.
189+- Don't log raw Ktor/OkHttp exceptions — use `safeExceptionSummary`.
190+- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191+ variants exist for forward compatibility and must remain reachable.
192+
193+## Renovate
194+
195+`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196+Ktor is grouped; AndroidX majors are held for manual review (pinned to
197+compileSdk 35). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mddeleted−40
@@ -1,40 +0,0 @@
1−# opencode-android
2−
3−Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4−
5−## Architecture
6−
7−- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8−- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9−- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10−- Three screens: Connect → Sessions → Chat
11−
12−## Key Files
13−
14−- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15−- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16−- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17−- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18−- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19−- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20−- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21−
22−## Build
23−
24−```sh
25−source .buildenv.sh
26−nix develop -c ./gradlew assembleDebug
27−```
28−
29−## Code Conventions
30−
31−- Kotlin + Jetpack Compose (Material 3)
32−- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33−- All state mutations on main dispatcher (viewModelScope)
34−- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35−- Keep the Rust core and Kotlin port behaviourally identical
36−
37−## Version Control
38−
39−- Uses `jj` (jujutsu) colocated with git
40−- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+64−126
@@ -1,153 +1,91 @@
11# opencode-android
22
3−A native Android client for the [**opencode**](https://opencode.ai) server (the
4−`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5−and **Jetpack Compose**.
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
64
7−Connect to a running `opencode serve`, browse and create sessions, and chat with
8−the agent — assistant replies (text, reasoning, and tool calls) stream in live
9−over the server's SSE event stream.
5+Application id: `soy.iko.opencode`
106
11−<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
7+## Set up a server and connect to it
128
13−## Architecture
14−
15−The app follows the Crux pattern: **all application logic lives in a pure,
16−platform-agnostic core; the platform is a thin shell** that renders the core's
17−view model, sends it events, and performs the side effects it requests.
18−
19−```
20−┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
21−│ Crux core — the single source of truth │
22−│ • Model / Event / Effect / ViewModel │
23−│ • update(): connect+auth, sessions, chat, SSE stream application │
24−│ • crux_http for HTTP effects; unit-tested with `cargo test` │
25−└───────────────────────────────────────────────────────────────────────┘
26− ▲ mirrored, behaviour-for-behaviour
27−┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
28−│ Jetpack Compose shell │
29−│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
30−│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
31−│ • Compose UI: Connect / Sessions / Chat │
32−└───────────────────────────────────────────────────────────────────────┘
33−```
34−
35−### Why a Kotlin port instead of a JNI `.so`?
36−
37−Crux normally compiles the Rust core to a native library and the shell talks to
38−it over an FFI bridge. That requires an Android cross-compilation toolchain
39−(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40−environment intentionally ships **without** an NDK, so instead:
41−
42−- The **Rust core is the canonical specification** of behaviour and is fully
43− unit-tested (`cd shared && cargo test`).
44−- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45− reducer, driving an identical `UiState`/`ViewModel`.
46−
47−The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48−can be swapped for the compiled `.so` behind the same seam. The crate already
49−builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50−type-generation.
51−
52−## Project layout
53−
54−```
55−flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56−shared/ Crux core (Rust)
57− src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58− src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59− src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60−generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61−app/ Android app (Kotlin + Compose)
62− src/main/java/soy/iko/opencode/
63− core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64− ui/ Compose screens (Connect/Sessions/Chat), theme, components
65− MainActivity.kt, CoreViewModel.kt
66−```
67−
68−## Getting started
9+This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
10+need an opencode server running somewhere reachable from your device.
6911
70−### Dev shell
12+### 1. Start the server
7113
72−Everything you need is provided by the Nix flake:
14+On the host that has your project and opencode installed:
7315
74−```sh
75−nix develop # or: direnv allow (an .envrc with `use flake` is included)
16+```bash
17+opencode serve --hostname 0.0.0.0 --port 4096
7618```
7719
78−This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79−build-tools 35.0.0) on your `PATH`.
20+Useful flags:
8021
81−### Build & test
22+| Flag | Default | Description |
23+| --- | --- | --- |
24+| `--port` | `4096` | Port to listen on |
25+| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
26+| `--mdns` | `false` | Enable mDNS discovery |
27+| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
8228
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−```
29+The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
9330
94−### Run against a server
31+### 2. (Optional) Add authentication
9532
96−Start opencode's server on your machine or LAN:
33+opencode has no auth by default. To protect the server with HTTP Basic auth:
9734
98−```sh
99−opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
35+```bash
36+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
10037```
10138
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.
39+The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
40+app's add-server dialog.
10541
106−> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
42+### 3. Connect from the app
10743
108−## The opencode API this client speaks
44+**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
10945
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.
46+**Over USB only** — forward the port to the device and point the app at localhost:
12247
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− ```
48+```bash
49+adb reverse tcp:4096 tcp:4096
50+```
13551
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`.
52+Then use base URL `http://127.0.0.1:4096` in the app.
13853
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.
54+> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
55+> a reverse proxy, use an `https://` base URL instead.
14356
144−## Notes & limitations
57+## Architecture
14558
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).
59+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
60+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
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+```
15084
151−## License
85+## Install & run
15286
153−Apache-2.0.
87+```bash
88+adb install -r app/build/outputs/apk/debug/app-debug.apk
89+adb shell am start -n soy.iko.opencode/.MainActivity
90+adb logcat --pid=$(adb shell pidof soy.iko.opencode)
91+```
app/build.gradle.kts+92−44
@@ -1,21 +1,21 @@
11plugins {
22 alias(libs.plugins.android.application)
33 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.compose)
54 alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.compose)
6+ alias(libs.plugins.detekt)
67}
78
8−// Version is overridable from the release workflow via
9−// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10−val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11−val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
12−
13−// A release keystore is supplied by CI (decoded from a secret) via env vars.
14−// When it's absent — local builds, or CI before signing is configured — the
15−// release build falls back to the debug signing config so the APK is still
16−// installable, rather than producing an uninstallable unsigned APK.
17−val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18−val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
9+/**
10+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
11+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
12+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
13+ */
14+val gitCommitCount: Int = runCatching {
15+ providers.exec {
16+ commandLine("git", "rev-list", "--count", "HEAD")
17+ }.standardOutput.asText.get().trim().toInt()
18+}.getOrDefault(1)
1919
2020android {
2121 namespace = "soy.iko.opencode"
@@ -25,35 +25,37 @@ android {
2525 applicationId = "soy.iko.opencode"
2626 minSdk = 26
2727 targetSdk = 35
28− versionCode = appVersionCode
29− versionName = appVersionName
30−
28+ // Monotonically increasing across builds: derived from the git commit count so
29+ // each release ships a strictly higher versionCode without manual bookkeeping
30+ // (and never triggers a "downgrade" install block). Falls back to 1 when the
31+ // .git history isn't available (e.g. a plain source export).
32+ versionCode = gitCommitCount
33+ versionName = "0.1.0"
3134 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3235 }
3336
34− signingConfigs {
35− if (hasReleaseKeystore) {
36− create("release") {
37− storeFile = file(releaseStorePath!!)
38− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
39− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
40− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41− }
37+ // Release builds are signed when keystore credentials are supplied via env vars
38+ // (so CI can publish an installable APK). When the env vars are absent, the
39+ // release build falls back to unsigned — same behavior as before — so local
40+ // builds keep working without any keystore setup.
41+ val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
42+ signingConfigs.create("release") {
43+ storeFile = file(path)
44+ storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
45+ keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
46+ keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
4247 }
4348 }
4449
4550 buildTypes {
4651 release {
4752 isMinifyEnabled = true
53+ isShrinkResources = true
4854 proguardFiles(
4955 getDefaultProguardFile("proguard-android-optimize.txt"),
5056 "proguard-rules.pro",
5157 )
52− signingConfig = if (hasReleaseKeystore) {
53− signingConfigs.getByName("release")
54− } else {
55− signingConfigs.getByName("debug")
56− }
58+ releaseSigning?.let { signingConfig = it }
5759 }
5860 }
5961
@@ -62,42 +64,88 @@ android {
6264 targetCompatibility = JavaVersion.VERSION_17
6365 }
6466
65− kotlinOptions {
66− jvmTarget = "17"
67+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
68+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
69+ kotlin {
70+ compilerOptions {
71+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72+ }
6773 }
6874
6975 buildFeatures {
7076 compose = true
7177 }
7278
79+ lint {
80+ // Fail CI on real errors. Warnings are reported but don't break the build so
81+ // minor/style issues don't gate development.
82+ abortOnError = true
83+ // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion")
85+ }
86+
7387 packaging {
7488 resources {
7589 excludes += "/META-INF/{AL2.0,LGPL2.1}"
7690 }
7791 }
92+
93+ testOptions {
94+ unitTests {
95+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
96+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
97+ // against Android APIs don't need Robolectric.
98+ isReturnDefaultValues = true
99+ isIncludeAndroidResources = true
100+ }
101+ }
78102}
79103
80104dependencies {
81105 implementation(libs.androidx.core.ktx)
82− implementation(libs.androidx.lifecycle.runtime.ktx)
83− implementation(libs.androidx.lifecycle.runtime.compose)
84− implementation(libs.androidx.lifecycle.viewmodel.ktx)
85− implementation(libs.androidx.lifecycle.viewmodel.compose)
106+ implementation(libs.androidx.core.splashscreen)
86107 implementation(libs.androidx.activity.compose)
108+ implementation(libs.androidx.lifecycle.viewmodel.compose)
109+ implementation(libs.androidx.lifecycle.runtime.compose)
110+ implementation(libs.androidx.navigation.compose)
87111
88− implementation(platform(libs.androidx.compose.bom))
89− implementation(libs.androidx.compose.ui)
90− implementation(libs.androidx.compose.ui.graphics)
91− implementation(libs.androidx.compose.ui.tooling.preview)
92− implementation(libs.androidx.compose.material3)
93− implementation(libs.androidx.compose.material.icons.extended)
94− debugImplementation(libs.androidx.compose.ui.tooling)
112+ implementation(platform(libs.compose.bom))
113+ implementation(libs.compose.ui)
114+ implementation(libs.compose.ui.graphics)
115+ implementation(libs.compose.ui.tooling.preview)
116+ implementation(libs.compose.material3)
117+ implementation(libs.compose.material.icons.extended)
118+ debugImplementation(libs.compose.ui.tooling)
119+
120+ implementation(libs.ktor.client.core)
121+ implementation(libs.ktor.client.okhttp)
122+ implementation(libs.ktor.client.content.negotiation)
123+ implementation(libs.ktor.serialization.kotlinx.json)
124+ implementation(libs.ktor.client.auth)
95125
96− implementation(libs.kotlinx.coroutines.android)
97126 implementation(libs.kotlinx.serialization.json)
98− implementation(libs.okhttp)
99− implementation(libs.okhttp.sse)
127+ implementation(libs.kotlinx.coroutines.android)
128+
129+ implementation(libs.androidx.datastore.preferences)
130+ implementation(libs.androidx.security.crypto)
131+ implementation(libs.coil.compose)
132+ implementation(libs.markdown.renderer)
133+ implementation(libs.markdown.renderer.m3)
100134
101135 testImplementation(libs.junit)
102136 testImplementation(libs.kotlinx.coroutines.test)
137+ testImplementation(libs.ktor.client.mock)
138+
139+ androidTestImplementation(libs.androidx.test.ext.junit)
140+ androidTestImplementation(libs.androidx.test.runner)
141+ androidTestImplementation(libs.androidx.test.core)
142+ androidTestImplementation(libs.kotlinx.coroutines.test)
143+}
144+
145+detekt {
146+ buildUponDefaultConfig = true
147+ // Carry forward the current baseline so CI only fails on *new* findings;
148+ // existing issues are grandfathered rather than blocking the migration.
149+ baseline = file("detekt-baseline.xml")
150+ config.setFrom("$rootDir/config/detekt/detekt.yml")
103151}
app/detekt-baseline.xmlnew file+15
@@ -0,0 +1,15 @@
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:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
7+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
8+ <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
9+ <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
10+ <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
11+ <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
12+ <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
13+ <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
14+ </CurrentIssues>
15+</SmellBaseline>
app/proguard-rules.pro+44−13
@@ -1,16 +1,47 @@
1−# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2−-keepattributes *Annotation*, InnerClasses
1+# --- kotlinx.serialization ---
2+# The plugin generates serializers; keep them and the $$serializer companion members.
3+-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
34-dontnote kotlinx.serialization.**
4−
5−-keepclassmembers class **$$serializer { *; }
6−-keepclasseswithmembers class * {
7− @kotlinx.serialization.Serializable <methods>;
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;
815}
9−-if @kotlinx.serialization.Serializable class **
10−-keep, allowobfuscation, allowshrinking class <1>
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+
19+# --- Ktor / OkHttp (HTTP client + SSE) ---
20+# Don't warn about missing optional dependencies; keep only the classes that are
21+# accessed via reflection (Ktor engine registration, OkHttp internals that R8
22+# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
23+# so R8 can still eliminate dead code and shrink the APK.
24+-dontwarn io.ktor.**
25+-dontwarn okhttp3.**
26+-dontwarn okio.**
27+-dontwarn org.slf4j.**
28+-dontwarn org.jetbrains.annotations.**
29+# Ktor loads engines and plugins via ServiceLoader — keep the service files.
30+-keep class io.ktor.client.engine.** { *; }
31+-keep class io.ktor.client.plugins.** { *; }
32+-keep class io.ktor.serialization.** { *; }
33+-keep class io.ktor.http.** { *; }
34+# OkHttp uses reflection for platform detection and internal classes.
35+-keep class okhttp3.internal.platform.** { *; }
36+-keep class okhttp3.** { *; }
37+-keep class okio.** { *; }
38+
39+# --- Coil (image loading) ---
40+# Coil uses reflection for loadable decoders and the Compose integration.
41+-keep class coil.** { *; }
42+-keep class coil3.** { *; }
43+-dontwarn coil.**
1144
12−# OkHttp / okio — platform-specific bits referenced reflectively.
13−-dontwarn okhttp3.internal.platform.**
14−-dontwarn org.conscrypt.**
15−-dontwarn org.bouncycastle.**
16−-dontwarn org.openjsse.**
45+# --- androidx.security.crypto pulls in Tink, which references compile-only
46+# com.google.errorprone annotations. They aren't on the runtime classpath.
47+-dontwarn com.google.errorprone.annotations.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktnew file+36
@@ -0,0 +1,36 @@
1+package soy.iko.opencode
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertNotNull
6+import org.junit.Assert.assertTrue
7+import org.junit.Test
8+import org.junit.runner.RunWith
9+
10+/**
11+ * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12+ * are initialized on startup, and the notification channels are created.
13+ */
14+@RunWith(AndroidJUnit4::class)
15+class ApplicationBootstrapTest {
16+
17+ @Test
18+ fun containerIsInitialized() {
19+ val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20+ assertNotNull(app.container)
21+ assertNotNull(app.container.settingsStore)
22+ }
23+
24+ @Test
25+ fun notificationChannelsExist() {
26+ if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27+ val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28+ // Touching the app instance runs Application.onCreate, which creates the channels.
29+ ApplicationProvider.getApplicationContext<OpencodeApp>()
30+ val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31+ as android.app.NotificationManager
32+ val ids = nm.notificationChannels.map { it.id }
33+ assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34+ assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35+ }
36+}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktnew file+67
@@ -0,0 +1,67 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import org.junit.Assert.assertEquals
6+import org.junit.Assert.assertNotNull
7+import org.junit.Assert.assertNull
8+import org.junit.Assert.assertTrue
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+/**
13+ * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14+ * real app-private filesystem: a simulated crash is written, then read back, listed, and
15+ * deleted through the public API.
16+ */
17+@RunWith(AndroidJUnit4::class)
18+class CrashLoggerTest {
19+
20+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21+ private val logger = CrashLogger.get(context)
22+
23+ @Test
24+ fun writeReadDeleteRoundTrip() {
25+ logger.clearAll()
26+
27+ // Simulate an uncaught exception by invoking the report path directly.
28+ val report = writeFakeReport()
29+
30+ logger.refresh()
31+ assertEquals(1, logger.reportCount())
32+
33+ val content = logger.readReport(report)
34+ assertNotNull(content)
35+ assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36+ assertTrue("report should contain device metadata", content.contains("Android:"))
37+
38+ logger.deleteReport(report)
39+ assertEquals(0, logger.reportCount())
40+ assertNull(logger.readReport(report))
41+ }
42+
43+ @Test
44+ fun clearAllRemovesEveryReport() {
45+ writeFakeReport("a")
46+ writeFakeReport("b")
47+ logger.refresh()
48+ assertTrue(logger.reportCount() >= 2)
49+
50+ logger.clearAll()
51+ assertEquals(0, logger.reportCount())
52+ }
53+
54+ private fun writeFakeReport(tag: String = "test"): String {
55+ val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56+ val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57+ java.io.File(dir, name).writeText(
58+ """
59+ opencode-android crash report
60+ Android: test
61+ java.lang.IllegalStateException: boom $tag
62+ at Crasher.crash(Crasher.kt:1)
63+ """.trimIndent(),
64+ )
65+ return name
66+ }
67+}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.repo
2+
3+import androidx.test.core.app.ApplicationProvider
4+import androidx.test.ext.junit.runners.AndroidJUnit4
5+import kotlinx.coroutines.CoroutineScope
6+import kotlinx.coroutines.Dispatchers
7+import kotlinx.coroutines.SupervisorJob
8+import org.junit.Assert.assertEquals
9+import org.junit.Test
10+import org.junit.runner.RunWith
11+
12+@RunWith(AndroidJUnit4::class)
13+class DraftStoreTest {
14+
15+ private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16+
17+ @Test
18+ fun setGetRemoveRoundTrip() {
19+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20+ val store = DraftStore(context, scope)
21+
22+ // Use a unique session ID so it doesn't collide with other tests.
23+ val sid = "test-${System.nanoTime()}"
24+
25+ store.set(sid, "hello world")
26+ assertEquals("hello world", store.get(sid))
27+
28+ // Overwrite
29+ store.set(sid, "updated")
30+ assertEquals("updated", store.get(sid))
31+
32+ // Blank clears the entry
33+ store.set(sid, " ")
34+ assertEquals("", store.get(sid))
35+
36+ // Explicit remove
37+ store.set(sid, "text")
38+ store.remove(sid)
39+ assertEquals("", store.get(sid))
40+
41+ scope.cancel()
42+ }
43+
44+ @Test
45+ fun getReturnsEmptyForUnknownSession() {
46+ val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47+ assertEquals("", store.get("never-set-${System.nanoTime()}"))
48+ }
49+}
app/src/main/AndroidManifest.xml+35−7
@@ -1,26 +1,54 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+ xmlns:tools="http://schemas.android.com/tools">
34
45 <uses-permission android:name="android.permission.INTERNET" />
56 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
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" />
610
711 <application
8− android:allowBackup="true"
9− android:icon="@android:drawable/sym_def_app_icon"
12+ android:name=".OpencodeApp"
13+ android:allowBackup="false"
14+ android:dataExtractionRules="@xml/data_extraction_rules"
15+ android:fullBackupContent="false"
16+ android:icon="@mipmap/ic_launcher"
1017 android:label="@string/app_name"
18+ android:networkSecurityConfig="@xml/network_security_config"
1119 android:supportsRtl="true"
12− android:theme="@style/Theme.OpencodeAndroid"
13− android:usesCleartextTraffic="true">
20+ android:theme="@style/Theme.OpencodeAndroid">
1421
1522 <activity
1623 android:name=".MainActivity"
1724 android:exported="true"
18− android:windowSoftInputMode="adjustResize"
19− android:theme="@style/Theme.OpencodeAndroid">
25+ android:launchMode="singleTop"
26+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
27+ android:enableOnBackInvokedCallback="true"
28+ tools:targetApi="33"
29+ android:theme="@style/Theme.OpencodeAndroid.Splash">
2030 <intent-filter>
2131 <action android:name="android.intent.action.MAIN" />
2232 <category android:name="android.intent.category.LAUNCHER" />
2333 </intent-filter>
34+ <intent-filter>
35+ <action android:name="android.intent.action.SEND" />
36+ <category android:name="android.intent.category.DEFAULT" />
37+ <data android:mimeType="text/plain" />
38+ </intent-filter>
39+ <!-- Deep link to a session: opencode://session/{sessionId} -->
40+ <intent-filter>
41+ <action android:name="android.intent.action.VIEW" />
42+ <category android:name="android.intent.category.DEFAULT" />
43+ <category android:name="android.intent.category.BROWSABLE" />
44+ <data android:scheme="opencode" android:host="session" />
45+ </intent-filter>
2446 </activity>
47+
48+ <service
49+ android:name=".notification.RunForegroundService"
50+ android:exported="false"
51+ android:foregroundServiceType="dataSync" />
2552 </application>
53+
2654</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktdeleted−66
@@ -1,66 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import android.content.Context
5−import androidx.core.content.edit
6−import androidx.lifecycle.AndroidViewModel
7−import androidx.lifecycle.viewModelScope
8−import kotlinx.coroutines.flow.distinctUntilChangedBy
9−import kotlinx.coroutines.launch
10−import soy.iko.opencode.core.Event
11−import soy.iko.opencode.core.OpencodeCore
12−
13−/**
14− * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15− * connection) survives configuration changes. It also persists the last server
16− * URL / username so reconnecting is one tap.
17− */
18−class CoreViewModel(app: Application) : AndroidViewModel(app) {
19− private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20−
21− val core = OpencodeCore(viewModelScope)
22−
23− init {
24− prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25− prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26− // The password is stored in plain text in SharedPreferences — a deliberate
27− // convenience trade-off for a local dev tool so reconnecting is one tap.
28− prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29−
30− // On a fresh process after a configuration change, resume the SSE stream if
31− // the core is already connected (no-op otherwise — it guards on `connected`).
32− core.resumeStreamingIfConnected()
33−
34− // Returning users shouldn't have to tap Connect: if a URL was restored and we
35− // aren't already connected, kick off the connection attempt on launch. The core
36− // handles an empty/invalid URL, so this only fires when there's something to try.
37− core.view.value.let { restored ->
38− if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39− core.dispatch(Event.Connect)
40− }
41− }
42−
43− // Persist connection details once a connection succeeds.
44− viewModelScope.launch {
45− core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46− if (state.connected) {
47− prefs.edit {
48− putString(KEY_URL, state.serverUrl)
49− putString(KEY_USER, state.username)
50− putString(KEY_PASSWORD, state.password)
51− }
52− }
53− }
54− }
55− }
56−
57− override fun onCleared() {
58− core.shutdown()
59− }
60−
61− private companion object {
62− const val KEY_URL = "serverUrl"
63− const val KEY_USER = "username"
64− const val KEY_PASSWORD = "password"
65− }
66−}
app/src/main/java/soy/iko/opencode/MainActivity.kt+115−13
@@ -1,31 +1,133 @@
11package soy.iko.opencode
22
3+import android.Manifest
4+import android.content.Intent
5+import android.content.pm.PackageManager
6+import android.os.Build
37import android.os.Bundle
48import androidx.activity.ComponentActivity
5−import androidx.activity.SystemBarStyle
69import androidx.activity.compose.setContent
710import androidx.activity.enableEdgeToEdge
11+import androidx.activity.result.contract.ActivityResultContracts
12+import androidx.compose.foundation.isSystemInDarkTheme
13+import androidx.compose.runtime.LaunchedEffect
814import androidx.compose.runtime.getValue
15+import androidx.compose.runtime.remember
16+import kotlinx.coroutines.flow.combine
17+import androidx.core.content.ContextCompat
18+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
919import androidx.lifecycle.compose.collectAsStateWithLifecycle
10−import androidx.lifecycle.viewmodel.compose.viewModel
11−import soy.iko.opencode.ui.OpencodeApp
20+import soy.iko.opencode.data.repo.ThemeMode
21+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
1222import soy.iko.opencode.ui.theme.OpencodeTheme
1323
1424class MainActivity : ComponentActivity() {
25+
26+ private val requestNotificationPermission = registerForActivityResult(
27+ ActivityResultContracts.RequestPermission(),
28+ ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
29+
30+ private var shareIntentHandled = false
31+ private var openSessionHandled = false
32+
1533 override fun onCreate(savedInstanceState: Bundle?) {
16− enableEdgeToEdge(
17− statusBarStyle = SystemBarStyle.auto(
18− lightScrim = 0xFF0F1115.toInt(),
19− darkScrim = 0xFF0F1115.toInt(),
20− ),
21− )
34+ val splash = installSplashScreen()
2235 super.onCreate(savedInstanceState)
36+ enableEdgeToEdge()
37+ savedInstanceState?.let {
38+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
39+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
40+ }
41+ val container = (application as OpencodeApp).container
42+ handleIntent(intent)
43+ if (savedInstanceState == null) maybeRequestNotificationPermission()
44+ // Hold the splash until the persisted theme settings load, so we never paint a
45+ // frame with the default (dynamicColor=true / SYSTEM) that then snaps to the
46+ // user's real choice — the flash the SettingsScreen's null-gate already avoids.
47+ var themeLoaded = false
48+ splash.setKeepOnScreenCondition { !themeLoaded }
2349 setContent {
24− OpencodeTheme {
25− val vm: CoreViewModel = viewModel()
26− val state by vm.core.view.collectAsStateWithLifecycle()
27− OpencodeApp(state = state, dispatch = vm.core::dispatch)
50+ val theme by remember(container) {
51+ container.settingsStore.themeMode
52+ .combine(container.settingsStore.dynamicColor, ::Pair)
53+ }.collectAsStateWithLifecycle(initialValue = null)
54+ LaunchedEffect(theme) { if (theme != null) themeLoaded = true }
55+ theme?.let { (themeMode, dynamicColor) ->
56+ val dark = when (themeMode) {
57+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
58+ ThemeMode.LIGHT -> false
59+ ThemeMode.DARK -> true
60+ }
61+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
62+ OpencodeAppUi(container = container)
63+ }
64+ }
65+ }
66+ }
67+
68+ override fun onNewIntent(intent: Intent) {
69+ super.onNewIntent(intent)
70+ setIntent(intent)
71+ shareIntentHandled = false
72+ openSessionHandled = false
73+ handleIntent(intent)
74+ }
75+
76+ /** Capture text shared from another app so it can be prefilled into a session draft,
77+ * and session ids from notification taps / deep links so we can open them. */
78+ private fun handleIntent(intent: Intent?) {
79+ val container = (application as OpencodeApp).container
80+ if (intent?.action == Intent.ACTION_SEND && !shareIntentHandled) {
81+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
82+ ?.takeIf { it.isNotBlank() }
83+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
84+ if (text != null) {
85+ container.setPendingShare(text)
86+ shareIntentHandled = true
2887 }
2988 }
89+ // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
90+ val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
91+ ?.data
92+ ?.takeIf { it.host == "session" }
93+ ?.lastPathSegment
94+ val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
95+ // Validate: session ids are opaque server-generated identifiers. Reject anything
96+ // with path separators or other traversal/control characters so a malicious deep
97+ // link can't inject path components into the REST URL path.
98+ // Guard against re-firing the retained ACTION_VIEW intent after process-death
99+ // restore: onCreate calls handleIntent again with the original intent, so without
100+ // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
101+ if (!openSessionHandled) {
102+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
103+ container.requestOpenSession(it)
104+ openSessionHandled = true
105+ }
106+ }
107+ }
108+
109+ companion object {
110+ /** Intent extra carrying a session id to open (notifications / deep links). */
111+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
112+
113+ /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
114+ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
115+
116+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
117+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
118+ }
119+
120+ override fun onSaveInstanceState(outState: Bundle) {
121+ super.onSaveInstanceState(outState)
122+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
123+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
124+ }
125+
126+ /** Ask for POST_NOTIFICATIONS once on Android 13+ so run/completion notifications show. */
127+ private fun maybeRequestNotificationPermission() {
128+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
129+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
130+ PackageManager.PERMISSION_GRANTED
131+ if (!granted) requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
30132 }
31133}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import coil.ImageLoader
5+import coil.ImageLoaderFactory
6+import coil.disk.DiskCache
7+import coil.memory.MemoryCache
8+import soy.iko.opencode.data.repo.CrashLogger
9+import soy.iko.opencode.di.AppContainer
10+import java.util.concurrent.atomic.AtomicBoolean
11+
12+class OpencodeApp : Application(), ImageLoaderFactory {
13+ lateinit var container: AppContainer
14+ private set
15+
16+ private val shutdownCalled = AtomicBoolean(false)
17+
18+ override fun onCreate() {
19+ super.onCreate()
20+ CrashLogger.get(this).install()
21+ container = AppContainer(this)
22+ // onTerminate() is only called in emulated process environments, not on real
23+ // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
24+ // scope cancellation, connection close) runs when the process is actually killed.
25+ Runtime.getRuntime().addShutdownHook(Thread {
26+ shutdown()
27+ })
28+ }
29+
30+ // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
31+ // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
32+ // the (auth-protected) network every time. Data-URI (base64) images skip the disk
33+ // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
34+ override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
35+ .memoryCache {
36+ MemoryCache.Builder(this)
37+ .maxSizePercent(0.25)
38+ .build()
39+ }
40+ .diskCache {
41+ DiskCache.Builder()
42+ .directory(cacheDir.resolve("image_cache"))
43+ .maxSizeBytes(50L * 1024 * 1024)
44+ .build()
45+ }
46+ .build()
47+
48+ override fun onTerminate() {
49+ super.onTerminate()
50+ shutdown()
51+ }
52+
53+ private fun shutdown() {
54+ // Guard against double shutdown — onTerminate and the shutdown hook can both
55+ // fire in emulated environments.
56+ if (!shutdownCalled.compareAndSet(false, true)) return
57+ container.shutdown()
58+ }
59+}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktdeleted−75
@@ -1,75 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.coroutines.Dispatchers
4−import kotlinx.coroutines.suspendCancellableCoroutine
5−import kotlinx.coroutines.withContext
6−import okhttp3.Call
7−import okhttp3.Callback
8−import okhttp3.MediaType.Companion.toMediaType
9−import okhttp3.OkHttpClient
10−import okhttp3.Request
11−import okhttp3.RequestBody.Companion.toRequestBody
12−import okhttp3.Response
13−import java.io.IOException
14−import java.util.concurrent.TimeUnit
15−import kotlin.coroutines.resume
16−import kotlin.coroutines.resumeWithException
17−
18−/** A completed HTTP response (any status code — non-2xx is not an error here). */
19−data class HttpResp(val code: Int, val body: String)
20−
21−/**
22− * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23− * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24− * carrying its status code, matching the Rust core's `HttpResult` handling.
25− */
26−class HttpClient(
27− val okhttp: OkHttpClient = OkHttpClient.Builder()
28− .connectTimeout(15, TimeUnit.SECONDS)
29− .readTimeout(30, TimeUnit.SECONDS)
30− .build(),
31−) {
32− private val jsonMedia = "application/json".toMediaType()
33−
34− suspend fun get(url: String, auth: String?): Result<HttpResp> =
35− exec(url, auth) { get() }
36−
37− suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38− exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39−
40− suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41− exec(url, auth) { delete() }
42−
43− // Build and run the request off the main thread. Building it — parsing [url]
44− // in particular — can throw (e.g. a URL with no scheme), so that happens
45− // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46− // instead of crashing the caller's coroutine.
47− private suspend fun exec(
48− url: String,
49− auth: String?,
50− method: Request.Builder.() -> Request.Builder,
51− ): Result<HttpResp> = withContext(Dispatchers.IO) {
52− runCatching {
53− val builder = Request.Builder().url(url).method()
54− builder.header("User-Agent", "opencode-android/1.0")
55− if (auth != null) builder.header("Authorization", auth)
56− await(okhttp.newCall(builder.build()))
57− }
58− }
59−
60− private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61− cont.invokeOnCancellation { runCatching { call.cancel() } }
62− call.enqueue(object : Callback {
63− override fun onFailure(call: Call, e: IOException) {
64− if (cont.isActive) cont.resumeWithException(e)
65− }
66−
67− override fun onResponse(call: Call, response: Response) {
68− response.use {
69− val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70− if (cont.isActive) cont.resume(HttpResp(it.code, body))
71− }
72− }
73− })
74− }
75−}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktdeleted−668
@@ -1,668 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.coroutines.CoroutineScope
4−import kotlinx.coroutines.Job
5−import kotlinx.coroutines.delay
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.launch
10−import java.util.Base64
11−
12−/**
13− * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14− *
15− * It owns all state, turns [Event]s into state changes + effects, and exposes an
16− * observable [view]. Effects are executed here in the shell: REST calls through
17− * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18− * server-sent event is fed back in and applied incrementally, so assistant
19− * replies stream in token-by-token.
20− *
21− * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22− * network I/O hops to a background thread inside the clients.
23− */
24−class OpencodeCore(
25− private val scope: CoroutineScope,
26− private val http: HttpClient = HttpClient(),
27−) {
28− // ── Model ────────────────────────────────────────────────────────────────
29− private var serverUrl = ""
30− private var username = ""
31− private var password = ""
32− private var authRequired = false
33− private var connected = false
34− private var loading = false
35− private var error: String? = null
36− private var screen = Screen.Connect
37− private val sessions = mutableListOf<SessionView>()
38− private var currentSessionId: String? = null
39− private var currentSessionTitle = ""
40− private val messages = mutableListOf<MsgState>()
41− private var draft = ""
42− private var generating = false
43− private var sseConnected = true
44− private var localSeq = 0L
45−
46− private val _view = MutableStateFlow(snapshot())
47− val view: StateFlow<UiState> = _view.asStateFlow()
48−
49− private var sse: SseClient? = null
50− private var sseClosedIntentionally = false
51− private var reconnectAttempts = 0
52− private var reconnectJob: Job? = null
53−
54− // Guards against overlapping session refreshes: rapid taps launch multiple
55− // loadSessions coroutines and the slowest-to-return would otherwise win.
56− private var loadSessionsJob: Job? = null
57− // Tracks the in-flight createSession coroutine so navigating away can cancel it
58− // before it dispatches SelectSession into a screen the user already left.
59− private var createSessionJob: Job? = null
60− // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61− // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62− private var loadMessagesJob: Job? = null
63− // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64− // otherwise launch two concurrent health probes; cancel the prior one first.
65− private var connectJob: Job? = null
66− // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67− // before its response handler mutates the sessions list / screen the user already left.
68− private var deleteSessionJob: Job? = null
69− // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70− // can cancel it before its response handler touches stale message state.
71− private var sendMessageJob: Job? = null
72− // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73− // fire multiple abort requests, and navigating away can cancel it.
74− private var abortJob: Job? = null
75− // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76− private val removedMessageIds = mutableSetOf<String>()
77−
78− // ── Event entry point ──────────────────────────────────────────────────────
79− fun dispatch(event: Event) {
80− when (event) {
81− // Store the field verbatim while typing; normalizing here would fight
82− // the user's edits (e.g. eat the "://" as they type it). The URL is
83− // normalized once, on Connect.
84− // Clear any prior error as soon as the user edits a field, so a stale
85− // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86− is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87− is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88− is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89−
90− Event.Connect -> {
91− serverUrl = normalizeUrl(serverUrl)
92− if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93− // The auth fields are showing but empty — sending the request now
94− // would just 401 again in a loop. Prompt for credentials instead.
95− if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96− // Leave authRequired as-is; probeHealth sets it from the response (true on
97− // 401, false on success). Resetting it here just flickers the auth fields.
98− loading = true; error = null; emit()
99− connectJob?.cancel()
100− connectJob = scope.launch { probeHealth() }
101− }
102−
103− Event.LoadSessions -> {
104− if (!connected) { error = "Not connected"; emit(); return }
105− loading = true; emit()
106− loadSessionsJob?.cancel()
107− loadSessionsJob = scope.launch { loadSessions() }
108− }
109−
110− is Event.SelectSession -> {
111− // The session may have been deleted (e.g. via SSE) while the list was on
112− // screen; opening it would only 404 on loadMessages. Bail with an error.
113− val session = sessions.firstOrNull { it.id == event.id }
114− if (session == null) { error = "Session no longer exists"; emit(); return }
115− currentSessionTitle = session.title
116− currentSessionId = event.id
117− messages.clear()
118− removedMessageIds.clear()
119− screen = Screen.Chat
120− loading = true
121− generating = false
122− error = null
123− emit()
124− connectJob?.cancel()
125− abortJob?.cancel()
126− sendMessageJob?.cancel()
127− deleteSessionJob?.cancel()
128− loadMessagesJob?.cancel()
129− loadMessagesJob = scope.launch { loadMessages(event.id) }
130− }
131−
132− Event.CreateSession -> {
133− if (!connected) { error = "Not connected"; emit(); return }
134− loading = true; error = null; emit()
135− createSessionJob?.cancel()
136− createSessionJob = scope.launch { createSession() }
137− }
138−
139− is Event.DeleteSession -> {
140− if (!connected) { error = "Not connected"; emit(); return }
141− val id = event.id
142− // Delete on the server first; only drop it from the UI once that
143− // succeeds, so a failed request leaves the session recoverable.
144− loading = true; error = null; emit()
145− deleteSessionJob?.cancel()
146− deleteSessionJob = scope.launch {
147− http.delete("$serverUrl/session/$id", authHeader()).fold(
148− onSuccess = { resp ->
149− if (resp.code in 200..299) {
150− sessions.removeAll { it.id == id }
151− if (isCurrent(id)) {
152− currentSessionId = null
153− messages.clear()
154− screen = Screen.Sessions
155− }
156− } else {
157− error = "Failed to delete session"
158− }
159− loading = false
160− },
161− onFailure = {
162− error = "Failed to delete session"
163− loading = false
164− },
165− )
166− emit()
167− }
168− }
169−
170− is Event.DraftChanged -> { draft = event.text; emit() }
171−
172− is Event.SendMessage -> sendMessage(event.text)
173−
174− Event.CancelGeneration -> {
175− generating = false
176− messages.forEach { it.streaming = false }
177− emit()
178− val id = currentSessionId ?: return
179− // Debounce rapid cancel taps: cancel any in-flight abort before firing
180− // another so we don't stack duplicate abort requests.
181− abortJob?.cancel()
182− abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183− }
184−
185− Event.NavigateToSessions -> {
186− screen = Screen.Sessions
187− currentSessionId = null
188− messages.clear()
189− removedMessageIds.clear()
190− draft = ""
191− generating = false
192− // Clear any error from the chat (e.g. a failed send) so it doesn't
193− // linger on the sessions list after the user navigates back.
194− error = null
195− loading = true
196− emit()
197− connectJob?.cancel()
198− abortJob?.cancel()
199− createSessionJob?.cancel()
200− deleteSessionJob?.cancel()
201− sendMessageJob?.cancel()
202− loadMessagesJob?.cancel()
203− loadSessionsJob?.cancel()
204− loadSessionsJob = scope.launch { loadSessions() }
205− }
206−
207− Event.NavigateToConnect -> {
208− screen = Screen.Connect
209− connected = false
210− loading = false
211− generating = false
212− sessions.clear()
213− messages.clear()
214− removedMessageIds.clear()
215− currentSessionId = null
216− draft = ""
217− // Reset to the default so a late-firing close callback from the old
218− // SSE connection can't flash the reconnecting banner on the next connect.
219− sseConnected = true
220− connectJob?.cancel()
221− abortJob?.cancel()
222− createSessionJob?.cancel()
223− deleteSessionJob?.cancel()
224− sendMessageJob?.cancel()
225− loadMessagesJob?.cancel()
226− closeSse()
227− emit()
228− }
229−
230− Event.DismissError -> { error = null; emit() }
231− }
232− }
233−
234− /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235− fun resumeStreamingIfConnected() {
236− if (connected && sse == null) startSse()
237− }
238−
239− fun shutdown() {
240− // Cancel every tracked coroutine before tearing down the stream so none can
241− // emit into a cancelled scope after the ViewModel is cleared.
242− connectJob?.cancel()
243− loadSessionsJob?.cancel()
244− createSessionJob?.cancel()
245− loadMessagesJob?.cancel()
246− deleteSessionJob?.cancel()
247− sendMessageJob?.cancel()
248− abortJob?.cancel()
249− closeSse()
250− }
251−
252− // ── Effects ────────────────────────────────────────────────────────────────
253− private fun sendMessage(raw: String) {
254− val text = raw.trim()
255− val session = currentSessionId ?: return
256− if (text.isEmpty()) return
257− // Debounce rapid double-taps on send: a send is already in flight, so a second
258− // tap would create a duplicate user message and fire a duplicate request.
259− if (generating) return
260− localSeq += 1
261− val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262− status = MessageStatus.Pending
263− textParts["local"] = text
264− }
265− messages.add(user)
266− // Keep the draft text so we can restore it if the send fails; otherwise the
267− // user's message is lost and they'd have to retype it.
268− val savedDraft = draft
269− draft = ""
270− generating = true
271− error = null
272− emit()
273−
274− val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275− sendMessageJob?.cancel()
276− sendMessageJob = scope.launch {
277− val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278− result.fold(
279− onSuccess = { resp ->
280− if (resp.code in 200..299) {
281− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282− } else {
283− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284− generating = false
285− messages.forEach { it.streaming = false }
286− draft = savedDraft
287− error = "Server returned status ${resp.code}"
288− }
289− },
290− onFailure = { e ->
291− messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292− generating = false
293− messages.forEach { it.streaming = false }
294− draft = savedDraft
295− error = "Request failed: ${e.message}"
296− },
297− )
298− emit()
299− }
300− }
301−
302− private suspend fun probeHealth() {
303− val hasCreds = hasCredentials()
304− http.get("$serverUrl/global/health", authHeader()).fold(
305− onSuccess = { resp ->
306− when {
307− resp.code == 401 -> {
308− loading = false
309− // A prior connection may have succeeded (connected = true); a
310− // re-probe returning 401 (e.g. expired credentials) means we're
311− // no longer authenticated, so drop the connected flag.
312− connected = false
313− // Always reveal the auth fields so the user can see/edit
314− // their credentials — even when the ones supplied were wrong.
315− authRequired = true
316− if (hasCreds) error = "Invalid credentials"
317− emit()
318− }
319− resp.code in 200..299 -> {
320− connected = true
321− authRequired = false
322− error = null
323− screen = Screen.Sessions
324− loading = true
325− emit()
326− startSse()
327− // Route through loadSessionsJob so a prior in-flight loadSessions
328− // (e.g. from a rapid double-connect) is cancelled first.
329− loadSessionsJob?.cancel()
330− loadSessionsJob = scope.launch { loadSessions() }
331− }
332− else -> {
333− loading = false
334− connected = false
335− error = "Server returned status ${resp.code}"
336− emit()
337− }
338− }
339− },
340− onFailure = { e ->
341− loading = false
342− connected = false
343− error = "Connection failed: ${e.message}"
344− emit()
345− },
346− )
347− }
348−
349− private suspend fun loadSessions() {
350− http.get("$serverUrl/session", authHeader()).fold(
351− onSuccess = { resp ->
352− if (resp.code in 200..299) {
353− sessions.clear()
354− sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355− // Impose a stable alphabetical order so the list doesn't shuffle with
356− // whatever order the server happens to return sessions in.
357− sessions.sortBy { it.title.lowercase() }
358− } else {
359− error = "Server returned status ${resp.code}"
360− }
361− },
362− onFailure = { e -> error = "Request failed: ${e.message}" },
363− )
364− loading = false
365− emit()
366− }
367−
368− private suspend fun createSession() {
369− http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370− onSuccess = { resp ->
371− val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372− if (session != null) {
373− if (sessions.none { it.id == session.id }) {
374− sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375− // Restore the alphabetical order immediately (matching loadSessions /
376− // SessionUpserted) so the new session doesn't sit out-of-order at the
377− // top until the SSE SessionUpserted event arrives to re-sort.
378− sessions.sortBy { it.title.lowercase() }
379− }
380− emit()
381− // SelectSession takes over the loading state (loads messages).
382− dispatch(Event.SelectSession(session.id))
383− return
384− }
385− error = "Server returned status ${resp.code}"
386− },
387− onFailure = { e -> error = "Request failed: ${e.message}" },
388− )
389− loading = false
390− emit()
391− }
392−
393− private suspend fun loadMessages(sessionId: String) {
394− http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395− onSuccess = { resp ->
396− if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397− loading = false
398− if (resp.code in 200..299) {
399− messages.clear()
400− removedMessageIds.clear()
401− messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402− generating = messages.any { it.streaming }
403− } else {
404− error = "Server returned status ${resp.code}"
405− }
406− emit()
407− },
408− onFailure = { e ->
409− if (!isCurrent(sessionId)) { loading = false; return }
410− loading = false
411− generating = false
412− error = "Request failed: ${e.message}"
413− emit()
414− },
415− )
416− }
417−
418− // ── SSE ─────────────────────────────────────────────────────────────────────
419− private fun startSse() {
420− closeSse()
421− sseClosedIntentionally = false
422− val client = SseClient(
423− url = "$serverUrl/event",
424− auth = authHeader(),
425− onEvent = { json -> scope.launch { handleServerEvent(json) } },
426− onStateChange = { open ->
427− scope.launch {
428− sseConnected = open
429− if (open) reconnectAttempts = 0 else scheduleReconnect()
430− emit()
431− }
432− },
433− )
434− sse = client
435− client.connect()
436− }
437−
438− private fun closeSse() {
439− sseClosedIntentionally = true
440− reconnectJob?.cancel()
441− reconnectJob = null
442− sse?.close()
443− sse = null
444− }
445−
446− private fun scheduleReconnect() {
447− if (sseClosedIntentionally || !connected) return
448− // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449− // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450− val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451− if (delayMs < 30_000L) reconnectAttempts += 1
452− // Cancel any pending reconnect so rapid network flapping can't stack multiple
453− // coroutines that each open a new SSE connection.
454− reconnectJob?.cancel()
455− reconnectJob = scope.launch {
456− delay(delayMs)
457− if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458− }
459− }
460−
461− private fun handleServerEvent(json: String) {
462− val event = Protocol.parseEvent(json) ?: return
463− // Only re-render if the event actually changed our state; applyEvent returns
464− // false for events that don't apply (other session, unknown message, …).
465− if (applyEvent(event)) emit()
466− }
467−
468− /** Apply a server event to the model. Returns true iff any state actually changed. */
469− private fun applyEvent(event: Protocol.Event): Boolean {
470− when (event) {
471− is Protocol.Event.MessageUpdated -> {
472− val info = event.info
473− if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474− // A MessageRemoved may have arrived before this (late/out-of-order) update;
475− // don't re-create a message the server has already deleted.
476− if (info.id in removedMessageIds) return false
477− val streaming = info.time.completed == null && info.error == null
478− val existing = messages.firstOrNull { it.id == info.id }
479− if (existing != null) {
480− existing.streaming = streaming
481− if (existing.time == 0L) existing.time = info.time.created
482− } else {
483− messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484− }
485− info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486− return true
487− }
488−
489− is Protocol.Event.PartUpdated -> {
490− val part = event.part
491− val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492− // Synthetic text parts are intentionally skipped; report no change so we
493− // don't trigger a needless re-render/emit for them.
494− return when (part) {
495− is Protocol.Part.Text -> if (part.synthetic) {
496− false
497− } else {
498− msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499− true
500− }
501− is Protocol.Part.Reasoning -> {
502− msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503− true
504− }
505− is Protocol.Part.Tool -> {
506− msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507− true
508− }
509− }
510− }
511−
512− is Protocol.Event.PartRemoved -> {
513− val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514− msg.remove(event.partId)
515− return true
516− }
517−
518− is Protocol.Event.MessageRemoved -> {
519− // Remember the ID so a late MessageUpdated can't resurrect the message.
520− removedMessageIds.add(event.messageId)
521− return messages.removeAll { it.id == event.messageId }
522− }
523−
524− is Protocol.Event.SessionIdle -> {
525− if (!isCurrent(event.sessionId)) return false
526− generating = false
527− messages.forEach { it.streaming = false }
528− return true
529− }
530−
531− is Protocol.Event.SessionError -> {
532− val scoped = event.sessionId == null || isCurrent(event.sessionId)
533− if (!scoped) return false
534− event.error?.let { error = it.message() }
535− generating = false
536− messages.forEach { it.streaming = false }
537− return true
538− }
539−
540− is Protocol.Event.SessionUpserted -> {
541− val title = displayTitle(event.info.title)
542− val existing = sessions.firstOrNull { it.id == event.info.id }
543− if (existing != null) {
544− sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545− } else {
546− sessions.add(0, SessionView(event.info.id, title))
547− }
548− // Keep the list alphabetically sorted (matching loadSessions) so an
549− // upserted session doesn't jump to the top and break the ordering.
550− sessions.sortBy { it.title.lowercase() }
551− if (isCurrent(event.info.id)) currentSessionTitle = title
552− return true
553− }
554−
555− is Protocol.Event.SessionDeleted -> {
556− val id = event.sessionId ?: return false
557− val removed = sessions.removeAll { it.id == id }
558− if (isCurrent(id)) {
559− currentSessionId = null
560− messages.clear()
561− screen = Screen.Sessions
562− return true
563− }
564− return removed
565− }
566−
567− Protocol.Event.Other -> return false
568− }
569− }
570−
571− // ── View / helpers ──────────────────────────────────────────────────────────
572− private fun snapshot() = UiState(
573− screen = screen,
574− serverUrl = serverUrl,
575− username = username,
576− password = password,
577− authRequired = authRequired,
578− connected = connected,
579− loading = loading,
580− error = error,
581− sessions = sessions.toList(),
582− currentSessionId = currentSessionId,
583− currentSessionTitle = currentSessionTitle,
584− messages = messages.map { it.toView() },
585− draft = draft,
586− generating = generating,
587− sseConnected = sseConnected,
588− )
589−
590− private fun emit() { _view.value = snapshot() }
591−
592− private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593− private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594−
595− private fun authHeader(): String? {
596− if (!hasCredentials()) return null
597− val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598− return "Basic $encoded"
599− }
600−}
601−
602−// ── Internal message state (parts tracked by id for streaming) ─────────────────
603−private class MsgState(val id: String, val role: String, var time: Long) {
604− var status = MessageStatus.Sent
605− var streaming = false
606− val textParts = LinkedHashMap<String, String>()
607− val reasoningParts = LinkedHashMap<String, String>()
608− val toolParts = LinkedHashMap<String, ToolView>()
609−
610− fun remove(partId: String) {
611− textParts.remove(partId)
612− reasoningParts.remove(partId)
613− toolParts.remove(partId)
614− }
615−
616− fun toView(): MessageView {
617− val reasoning = reasoningParts.values.joinToString("")
618− return MessageView(
619− id = id,
620− role = role,
621− text = textParts.values.joinToString(""),
622− reasoning = reasoning.ifEmpty { null },
623− tools = toolParts.values.toList(),
624− time = time,
625− status = status,
626− streaming = streaming,
627− )
628− }
629−}
630−
631−private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632− val info = env.info
633− val msg = MsgState(info.id, info.role, info.time.created)
634− msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635− for (raw in env.parts) {
636− val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637− when (val part = Protocol.parsePart(obj)) {
638− is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639− is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640− is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641− null -> Unit
642− }
643− }
644− return msg
645−}
646−
647−private fun pickText(text: String, delta: String?, prior: String?): String =
648− if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649−
650−private fun normalizeUrl(url: String): String {
651− val trimmed = url.trim().trimEnd('/')
652− // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653− // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654− return when {
655− trimmed.isEmpty() -> ""
656− "://" in trimmed -> trimmed
657− else -> "http://$trimmed"
658− }
659−}
660−
661−private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662−
663−// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664−@kotlinx.serialization.Serializable
665−private data class PromptBody(val parts: List<TextInput>)
666−
667−@kotlinx.serialization.Serializable
668−private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktdeleted−148
@@ -1,148 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.json.Json
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonObject
8−import kotlinx.serialization.json.JsonPrimitive
9−import kotlinx.serialization.json.booleanOrNull
10−import kotlinx.serialization.json.contentOrNull
11−import kotlinx.serialization.json.decodeFromJsonElement
12−import kotlinx.serialization.json.jsonObject
13−
14−/**
15− * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16− *
17− * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18− * deliberately forgiving: unknown JSON fields are ignored and every union has a
19− * catch-all so new server versions never break the stream parser.
20− */
21−object Protocol {
22− val json = Json {
23− ignoreUnknownKeys = true
24− isLenient = true
25− coerceInputValues = true
26− }
27−
28− // ─── Sessions ────────────────────────────────────────────────────────────
29−
30− @Serializable
31− data class WireSession(val id: String, val title: String = "")
32−
33− fun parseSessions(body: String): List<WireSession> =
34− runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35−
36− fun parseSession(body: String): WireSession? =
37− runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38−
39− // ─── Messages ────────────────────────────────────────────────────────────
40−
41− @Serializable
42− data class WireTime(val created: Long = 0, val completed: Long? = null)
43−
44− @Serializable
45− data class WireError(val name: String = "", val data: JsonElement? = null) {
46− fun message(): String {
47− val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48− return when {
49− !msg.isNullOrEmpty() -> msg
50− name.isNotEmpty() -> name
51− else -> "Unknown error"
52− }
53− }
54− }
55−
56− @Serializable
57− data class WireMessageInfo(
58− val id: String,
59− val role: String = "",
60− @SerialName("sessionID") val sessionId: String = "",
61− val time: WireTime = WireTime(),
62− val error: WireError? = null,
63− )
64−
65− @Serializable
66− data class WireMessageEnvelope(
67− val info: WireMessageInfo,
68− val parts: List<JsonElement> = emptyList(),
69− )
70−
71− fun parseMessages(body: String): List<WireMessageEnvelope> =
72− runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73−
74− // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75−
76− sealed interface Part {
77− val id: String
78− val messageId: String
79−
80− data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81− data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82− data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83− }
84−
85− /** Parse a single part JSON object; returns null for kinds we don't render. */
86− fun parsePart(obj: JsonObject): Part? {
87− val id = obj.str("id")
88− val messageId = obj.str("messageID")
89− return when (obj.str("type")) {
90− "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91− "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92− "tool" -> {
93− val state = obj["state"] as? JsonObject
94− val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95− val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96− Part.Tool(id, messageId, obj.str("tool"), status, title)
97− }
98− else -> null
99− }
100− }
101−
102− // ─── SSE `/event` stream ──────────────────────────────────────────────────
103−
104− sealed interface Event {
105− data class MessageUpdated(val info: WireMessageInfo) : Event
106− data class PartUpdated(val part: Part, val delta: String?) : Event
107− data class PartRemoved(val messageId: String, val partId: String) : Event
108− data class MessageRemoved(val messageId: String) : Event
109− data class SessionIdle(val sessionId: String) : Event
110− data class SessionError(val sessionId: String?, val error: WireError?) : Event
111− data class SessionUpserted(val info: WireSession) : Event
112− data class SessionDeleted(val sessionId: String?) : Event
113− data object Other : Event
114− }
115−
116− /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117− fun parseEvent(raw: String): Event? {
118− val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119− val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120− return when (root.str("type")) {
121− "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122− "message.part.updated" -> {
123− val partObj = props["part"] as? JsonObject ?: return Event.Other
124− parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125− }
126− "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127− "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128− "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129− "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130− "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131− "session.deleted" -> Event.SessionDeleted(
132− props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133− )
134− else -> Event.Other
135− }
136− }
137−
138− private inline fun <reified T> decode(el: JsonElement?): T? =
139− el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140−
141− // ─── Small JSON helpers ───────────────────────────────────────────────────
142−
143− private fun JsonObject.str(key: String): String =
144− (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145−
146− private fun JsonObject.bool(key: String): Boolean =
147− (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148−}
app/src/main/java/soy/iko/opencode/core/SseClient.ktdeleted−63
@@ -1,63 +0,0 @@
1−package soy.iko.opencode.core
2−
3−import okhttp3.OkHttpClient
4−import okhttp3.Request
5−import okhttp3.Response
6−import okhttp3.sse.EventSource
7−import okhttp3.sse.EventSourceListener
8−import okhttp3.sse.EventSources
9−import java.util.concurrent.TimeUnit
10−
11−/**
12− * Long-lived subscription to the opencode `/event` SSE stream.
13− *
14− * Crux only models request/response HTTP, so the streaming connection lives here
15− * in the shell: each decoded event's `data` payload (a JSON object whose own
16− * `type` field is the discriminant) is handed to [onEvent], which the core then
17− * parses via [Protocol.parseEvent].
18− */
19−class SseClient(
20− private val url: String,
21− private val auth: String?,
22− private val onEvent: (String) -> Unit,
23− private val onStateChange: (Boolean) -> Unit,
24−) {
25− // A dedicated client with no read timeout — the stream is meant to stay open.
26− private val client = OkHttpClient.Builder()
27− .connectTimeout(15, TimeUnit.SECONDS)
28− .readTimeout(0, TimeUnit.SECONDS)
29− .retryOnConnectionFailure(true)
30− .build()
31−
32− private var source: EventSource? = null
33−
34− fun connect() {
35− val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36− builder.header("User-Agent", "opencode-android/1.0")
37− if (auth != null) builder.header("Authorization", auth)
38− source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39− }
40−
41− fun close() {
42− source?.cancel()
43− source = null
44− }
45−
46− private val listener = object : EventSourceListener() {
47− override fun onOpen(eventSource: EventSource, response: Response) {
48− onStateChange(true)
49− }
50−
51− override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52− if (data.isNotBlank()) onEvent(data)
53− }
54−
55− override fun onClosed(eventSource: EventSource) {
56− onStateChange(false)
57− }
58−
59− override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60− onStateChange(false)
61− }
62− }
63−}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktdeleted−77
@@ -1,77 +0,0 @@
1−package soy.iko.opencode.core
2−
3−/**
4− * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5− * the two implementations stay behaviourally identical.
6− */
7−
8−enum class Screen { Connect, Sessions, Chat }
9−
10−enum class MessageStatus { Sent, Pending, Failed }
11−
12−data class SessionView(
13− val id: String,
14− val title: String,
15−)
16−
17−data class ToolView(
18− val name: String,
19− val status: String,
20− val title: String?,
21−)
22−
23−data class MessageView(
24− val id: String,
25− val role: String,
26− val text: String,
27− val reasoning: String?,
28− val tools: List<ToolView>,
29− val time: Long,
30− val status: MessageStatus,
31− val streaming: Boolean,
32−) {
33− val isUser: Boolean get() = role == "user"
34−}
35−
36−data class UiState(
37− val screen: Screen = Screen.Connect,
38− val serverUrl: String = "",
39− val username: String = "",
40− val password: String = "",
41− val authRequired: Boolean = false,
42− val connected: Boolean = false,
43− val loading: Boolean = false,
44− val error: String? = null,
45− val sessions: List<SessionView> = emptyList(),
46− val currentSessionId: String? = null,
47− val currentSessionTitle: String = "",
48− val messages: List<MessageView> = emptyList(),
49− val draft: String = "",
50− val generating: Boolean = false,
51− val sseConnected: Boolean = true,
52−)
53−
54−/**
55− * Everything the shell can ask the core to do. Internal HTTP results are not
56− * events here (unlike the Rust core, which routes them through `then_send`);
57− * the Kotlin driver awaits them inline in coroutines instead.
58− */
59−sealed interface Event {
60− data class ServerUrlChanged(val url: String) : Event
61− data class UsernameChanged(val value: String) : Event
62− data class PasswordChanged(val value: String) : Event
63− data object Connect : Event
64−
65− data object LoadSessions : Event
66− data class SelectSession(val id: String) : Event
67− data object CreateSession : Event
68− data class DeleteSession(val id: String) : Event
69−
70− data class DraftChanged(val text: String) : Event
71− data class SendMessage(val text: String) : Event
72− data object CancelGeneration : Event
73−
74− data object NavigateToSessions : Event
75− data object NavigateToConnect : Event
76− data object DismissError : Event
77−}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktnew file+32
@@ -0,0 +1,32 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /agent`. */
7+@Immutable
8+@Serializable
9+data class Agent(
10+ val name: String,
11+ val description: String? = null,
12+ val mode: String? = null,
13+ val builtIn: Boolean = false,
14+ val prompt: String? = null,
15+) {
16+ val isPrimary: Boolean get() = mode == "primary"
17+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
18+}
19+
20+/** Entry from `GET /command`. */
21+@Immutable
22+@Serializable
23+data class Command(
24+ val name: String,
25+ val description: String? = null,
26+ val agent: String? = null,
27+ val model: String? = null,
28+ val template: String = "",
29+ val subtask: Boolean = false,
30+) {
31+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
32+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+
6+/**
7+ * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8+ * Discriminated on `type` (the global discriminator). Unknown events decode to
9+ * [UnknownEvent] so the stream never dies on a new event type.
10+ *
11+ * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12+ * drift across opencode versions; the reducer only reads the fields it needs.
13+ */
14+@Serializable
15+sealed interface BusEvent
16+
17+@Serializable
18+@SerialName("server.connected")
19+data object ServerConnected : BusEvent
20+
21+@Serializable
22+@SerialName("message.updated")
23+data class MessageUpdated(val properties: Props) : BusEvent {
24+ @Serializable
25+ data class Props(val info: MessageInfo)
26+}
27+
28+@Serializable
29+@SerialName("message.part.updated")
30+data class MessagePartUpdated(val properties: Props) : BusEvent {
31+ @Serializable
32+ data class Props(
33+ val part: Part,
34+ val sessionID: String? = null,
35+ val messageID: String? = null,
36+ )
37+}
38+
39+@Serializable
40+@SerialName("message.part.removed")
41+data class MessagePartRemoved(val properties: Props) : BusEvent {
42+ @Serializable
43+ data class Props(
44+ val sessionID: String? = null,
45+ val messageID: String? = null,
46+ val partID: String? = null,
47+ )
48+}
49+
50+@Serializable
51+@SerialName("message.removed")
52+data class MessageRemoved(val properties: Props) : BusEvent {
53+ @Serializable
54+ data class Props(
55+ val sessionID: String? = null,
56+ val messageID: String? = null,
57+ )
58+}
59+
60+@Serializable
61+@SerialName("session.idle")
62+data class SessionIdle(val properties: Props) : BusEvent {
63+ @Serializable
64+ data class Props(val sessionID: String? = null)
65+}
66+
67+@Serializable
68+@SerialName("session.error")
69+data class SessionError(val properties: Props) : BusEvent {
70+ @Serializable
71+ data class Props(
72+ val sessionID: String? = null,
73+ val error: kotlinx.serialization.json.JsonElement? = null,
74+ )
75+}
76+
77+@Serializable
78+@SerialName("session.updated")
79+data class SessionUpdated(val properties: Props) : BusEvent {
80+ @Serializable
81+ data class Props(val info: Session)
82+}
83+
84+@Serializable
85+@SerialName("session.deleted")
86+data class SessionDeleted(val properties: Props) : BusEvent {
87+ @Serializable
88+ data class Props(val info: Session? = null, val sessionID: String? = null)
89+}
90+
91+@Serializable
92+@SerialName("permission.updated")
93+data class PermissionUpdated(val properties: Permission) : BusEvent
94+
95+@Serializable
96+@SerialName("permission.replied")
97+data class PermissionReplied(val properties: Props) : BusEvent {
98+ @Serializable
99+ data class Props(
100+ val sessionID: String? = null,
101+ val permissionID: String? = null,
102+ val response: String? = null,
103+ )
104+}
105+
106+/** Fallback for any event type we don't model. */
107+@Serializable
108+@SerialName("__unknown")
109+data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktnew file+28
@@ -0,0 +1,28 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+@Immutable
7+@Serializable
8+data class TimeInfo(
9+ val created: Long? = null,
10+ val updated: Long? = null,
11+ val completed: Long? = null,
12+)
13+
14+@Immutable
15+@Serializable
16+data class Tokens(
17+ val input: Long = 0,
18+ val output: Long = 0,
19+ val reasoning: Long = 0,
20+ val cache: Cache = Cache(),
21+) {
22+ @Serializable
23+ @Immutable
24+ data class Cache(
25+ val read: Long = 0,
26+ val write: Long = 0,
27+ )
28+}
app/src/main/java/soy/iko/opencode/data/model/Files.ktnew file+38
@@ -0,0 +1,38 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+
6+/** Entry from `GET /file?path=` (directory listing). */
7+@Immutable
8+@Serializable
9+data class FileNode(
10+ val name: String = "",
11+ val path: String = "",
12+ val type: String? = null,
13+) {
14+ val isDirectory: Boolean get() = type == "directory"
15+}
16+
17+/** Response of `GET /file/content?path=`. */
18+@Immutable
19+@Serializable
20+data class FileContent(
21+ val type: String? = null, // "text" | "binary"
22+ val content: String = "",
23+ val diff: String? = null,
24+ val mimeType: String? = null,
25+ val encoding: String? = null,
26+) {
27+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
28+}
29+
30+/** Entry from `GET /file/status` (VCS status). */
31+@Immutable
32+@Serializable
33+data class FileStatusEntry(
34+ val path: String = "",
35+ val added: Int = 0,
36+ val removed: Int = 0,
37+ val status: String? = null, // "added" | "deleted" | "modified"
38+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+65
@@ -0,0 +1,65 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The `info` object of a message. Discriminated on `role`, which differs from the
11+ * global `type` discriminator used by [Part], so this hierarchy overrides it.
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("role")
16+sealed interface MessageInfo {
17+ val id: String
18+ val sessionID: String
19+ val time: TimeInfo?
20+}
21+
22+@Immutable
23+@Serializable
24+@SerialName("user")
25+data class UserMessage(
26+ override val id: String,
27+ override val sessionID: String,
28+ override val time: TimeInfo? = null,
29+) : MessageInfo
30+
31+@Immutable
32+@Serializable
33+@SerialName("assistant")
34+data class AssistantMessage(
35+ override val id: String,
36+ override val sessionID: String,
37+ val parentID: String? = null,
38+ val providerID: String? = null,
39+ val modelID: String? = null,
40+ val cost: Double? = null,
41+ val tokens: Tokens? = null,
42+ val error: JsonObject? = null,
43+ override val time: TimeInfo? = null,
44+) : MessageInfo {
45+ /** A run is complete once the server stamps a completion time. */
46+ val isComplete: Boolean get() = time?.completed != null
47+}
48+
49+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50+@Immutable
51+@Serializable
52+@SerialName("__unknown")
53+data class UnknownMessage(
54+ override val id: String = "",
55+ override val sessionID: String = "",
56+ override val time: TimeInfo? = null,
57+) : MessageInfo
58+
59+/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
60+@Immutable
61+@Serializable
62+data class MessageWithParts(
63+ val info: MessageInfo,
64+ val parts: List<Part> = emptyList(),
65+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+99
@@ -0,0 +1,99 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+
7+/**
8+ * A message part — a discriminated union on `type` (the global class discriminator).
9+ * Unknown types decode to [UnknownPart] via the registered polymorphic default,
10+ * so the stream survives server additions.
11+ *
12+ * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
13+ * uses to attach streamed parts to the right message.
14+ */
15+@Serializable
16+sealed interface Part {
17+ val id: String
18+ val messageID: String?
19+ val sessionID: String?
20+}
21+
22+@Immutable
23+@Serializable
24+@SerialName("text")
25+data class TextPart(
26+ override val id: String = "",
27+ override val messageID: String? = null,
28+ override val sessionID: String? = null,
29+ val text: String = "",
30+ val synthetic: Boolean = false,
31+ val ignored: Boolean = false,
32+ val time: TimeInfo? = null,
33+) : Part
34+
35+@Immutable
36+@Serializable
37+@SerialName("reasoning")
38+data class ReasoningPart(
39+ override val id: String = "",
40+ override val messageID: String? = null,
41+ override val sessionID: String? = null,
42+ val text: String = "",
43+ val time: TimeInfo? = null,
44+) : Part
45+
46+@Immutable
47+@Serializable
48+@SerialName("tool")
49+data class ToolPart(
50+ override val id: String = "",
51+ override val messageID: String? = null,
52+ override val sessionID: String? = null,
53+ val callID: String = "",
54+ val tool: String = "",
55+ val state: ToolState = ToolPending,
56+) : Part
57+
58+@Immutable
59+@Serializable
60+@SerialName("file")
61+data class FilePart(
62+ override val id: String = "",
63+ override val messageID: String? = null,
64+ override val sessionID: String? = null,
65+ val mime: String? = null,
66+ val filename: String? = null,
67+ val url: String? = null,
68+ val source: String? = null,
69+) : Part
70+
71+@Immutable
72+@Serializable
73+@SerialName("step-start")
74+data class StepStartPart(
75+ override val id: String = "",
76+ override val messageID: String? = null,
77+ override val sessionID: String? = null,
78+) : Part
79+
80+@Immutable
81+@Serializable
82+@SerialName("step-finish")
83+data class StepFinishPart(
84+ override val id: String = "",
85+ override val messageID: String? = null,
86+ override val sessionID: String? = null,
87+ val cost: Double? = null,
88+ val tokens: Tokens? = null,
89+) : Part
90+
91+/** Fallback for an unrecognized `type`. */
92+@Immutable
93+@Serializable
94+@SerialName("__unknown")
95+data class UnknownPart(
96+ override val id: String = "",
97+ override val messageID: String? = null,
98+ override val sessionID: String? = null,
99+) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktnew file+49
@@ -0,0 +1,49 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.JsonElement
6+import kotlinx.serialization.json.JsonObject
7+import kotlinx.serialization.json.JsonArray
8+import kotlinx.serialization.json.JsonPrimitive
9+
10+/**
11+ * A permission request from `permission.updated`. The tool detail lives in [type] +
12+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
13+ * it's kept as a [JsonElement] and flattened for display.
14+ */
15+@Immutable
16+@Serializable
17+data class Permission(
18+ val id: String,
19+ val sessionID: String = "",
20+ val type: String? = null,
21+ val pattern: JsonElement? = null,
22+ val messageID: String? = null,
23+ val callID: String? = null,
24+ val title: String? = null,
25+ val metadata: JsonObject? = null,
26+ val time: TimeInfo? = null,
27+) {
28+ val patternText: String?
29+ get() = when (val p = pattern) {
30+ null -> null
31+ is JsonPrimitive -> p.content
32+ // Only a JSON array is flattened; any other shape (e.g. an object) would make
33+ // the previous unguarded `p.jsonArray` throw during composition, so treat it
34+ // as "no displayable pattern" instead.
35+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36+ else -> null
37+ }
38+}
39+
40+/** Allowed values for the permission response body. */
41+enum class PermissionResponse(val wire: String) {
42+ ONCE("once"),
43+ ALWAYS("always"),
44+ REJECT("reject"),
45+}
46+
47+/** Body for `POST /session/:id/permissions/:permissionID`. */
48+@Serializable
49+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktnew file+26
@@ -0,0 +1,26 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Body for `POST /session/:id/message`. */
6+@Serializable
7+data class PromptRequest(
8+ val parts: List<PromptPart>,
9+ val model: ModelRef? = null,
10+ val agent: String? = null,
11+)
12+
13+/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14+@Serializable
15+data class PromptPart(
16+ val type: String = "text",
17+ val text: String,
18+)
19+
20+/** Body for `POST /session/:id/command`. */
21+@Serializable
22+data class CommandRequest(
23+ val command: String,
24+ val arguments: String = "",
25+ val agent: String? = null,
26+)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktnew file+72
@@ -0,0 +1,72 @@
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(compareBy({ it.providerLabel.lowercase() }, { it.modelLabel.lowercase() }))
65+
66+/** The default option to preselect: the server's default for some provider, else the first model. */
67+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
68+ default.entries.firstOrNull()?.let { (providerID, modelID) ->
69+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
70+ }
71+ return options.firstOrNull()
72+}
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktnew file+21
@@ -0,0 +1,21 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+
5+/**
6+ * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7+ * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8+ */
9+@Immutable
10+data class ServerProfile(
11+ val id: String,
12+ val label: String,
13+ val baseUrl: String,
14+ val username: String? = null,
15+ val password: String? = null,
16+ val lastUsed: Long = 0,
17+) {
18+ val hasAuth: Boolean get() = !username.isNullOrBlank()
19+
20+ val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
21+}
app/src/main/java/soy/iko/opencode/data/model/Session.ktnew file+30
@@ -0,0 +1,30 @@
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+) {
16+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
17+}
18+
19+/** Body for `POST /session`. */
20+@Serializable
21+data class CreateSessionRequest(
22+ val parentID: String? = null,
23+ val title: String? = null,
24+)
25+
26+/** Body for `PATCH /session/:id`. */
27+@Serializable
28+data class UpdateSessionRequest(
29+ val title: String? = null,
30+)
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+53
@@ -0,0 +1,53 @@
1+package soy.iko.opencode.data.model
2+
3+import androidx.compose.runtime.Immutable
4+import kotlinx.serialization.SerialName
5+import kotlinx.serialization.Serializable
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonClassDiscriminator
8+
9+/**
10+ * The state machine of a tool call, discriminated on `status`.
11+ * pending -> running -> completed | error
12+ */
13+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14+@Serializable
15+@JsonClassDiscriminator("status")
16+sealed interface ToolState
17+
18+@Serializable
19+@SerialName("pending")
20+data object ToolPending : ToolState
21+
22+@Immutable
23+@Serializable
24+@SerialName("running")
25+data class ToolRunning(
26+ val input: JsonElement? = null,
27+ val title: String? = null,
28+ val time: TimeInfo? = null,
29+) : ToolState
30+
31+@Immutable
32+@Serializable
33+@SerialName("completed")
34+data class ToolCompleted(
35+ val input: JsonElement? = null,
36+ val output: String? = null,
37+ val title: String? = null,
38+ val time: TimeInfo? = null,
39+) : ToolState
40+
41+@Immutable
42+@Serializable
43+@SerialName("error")
44+data class ToolError(
45+ val input: JsonElement? = null,
46+ val error: String? = null,
47+ val time: TimeInfo? = null,
48+) : ToolState
49+
50+/** Fallback for an unrecognized `status`. */
51+@Serializable
52+@SerialName("__unknown")
53+data object ToolUnknown : ToolState
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktnew file+259
@@ -0,0 +1,259 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.util.safeExceptionSummary
5+import android.util.Log
6+import io.ktor.client.HttpClient
7+import io.ktor.client.plugins.timeout
8+import io.ktor.client.plugins.sse.sse
9+import io.ktor.sse.ServerSentEvent
10+import kotlinx.coroutines.CancellationException
11+import kotlinx.coroutines.CoroutineScope
12+import kotlin.coroutines.EmptyCoroutineContext
13+import kotlinx.coroutines.channels.BufferOverflow
14+import kotlinx.coroutines.channels.Channel
15+import kotlinx.coroutines.channels.ChannelResult
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.SharingStarted
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.buffer
23+import kotlinx.coroutines.flow.channelFlow
24+import kotlinx.coroutines.flow.shareIn
25+import kotlinx.coroutines.isActive
26+import kotlinx.coroutines.launch
27+import kotlinx.coroutines.selects.onTimeout
28+import kotlinx.coroutines.selects.select
29+import java.io.IOException
30+import kotlin.random.Random
31+
32+/**
33+ * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
34+ * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
35+ * subscribes (WhileSubscribed) and reconnects with backoff on drop.
36+ *
37+ * Ordering contract: callers that send a prompt must already be collecting [events]
38+ * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
39+ */
40+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
41+open class EventStreamClient(
42+ private val client: HttpClient,
43+ private val scope: CoroutineScope,
44+ private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
45+ private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
46+ private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
47+ private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
48+) {
49+ protected constructor() : this(
50+ HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51+ CoroutineScope(EmptyCoroutineContext),
52+ )
53+ enum class ConnectionState { Disconnected, Connecting, Connected, Failed }
54+
55+ private val _state = MutableStateFlow(ConnectionState.Disconnected)
56+ open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57+
58+ open val events: SharedFlow<BusEvent> by lazy {
59+ stream()
60+ .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
61+ .shareIn(
62+ scope = scope,
63+ started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
64+ replay = 0,
65+ )
66+ }
67+
68+ /** Conflated channel used to cut the reconnect backoff short when network returns. */
69+ private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70+
71+ /** Returns true if the exception is an SSE auth failure (401/403) that should stop retrying.
72+ * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
73+ * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
74+ * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
75+ * and walk the whole cause chain rather than matching a single exception type. */
76+ private fun isSseAuthFailure(e: Throwable): Boolean =
77+ generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
78+ .take(16)
79+ .mapNotNull { t ->
80+ when (t) {
81+ is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
82+ is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
83+ else -> null
84+ }
85+ }
86+ .any { it == 401 || it == 403 }
87+
88+ /** Request an immediate reconnect, skipping any in-progress backoff. */
89+ open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
90+
91+ /**
92+ * Read SSE events from [events], racing each read against an idle-timeout watchdog.
93+ * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
94+ * clean stream close or scope cancellation.
95+ */
96+ private suspend fun readSseEvents(
97+ scope: CoroutineScope,
98+ events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
99+ send: suspend (BusEvent) -> Unit,
100+ ) {
101+ while (scope.isActive) {
102+ // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
103+ // read), so a socket the peer never closed would otherwise hang until OS TCP
104+ // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
105+ // within the idle timeout, treat the connection as half-open and drop it.
106+ val result: ChannelResult<ServerSentEvent>? = select {
107+ events.onReceiveCatching { it }
108+ onTimeout(idleTimeoutMs) { null }
109+ }
110+ if (result == null) throw IOException("SSE idle timeout, reconnecting")
111+ val sse = result.getOrNull()
112+ if (sse == null) {
113+ // The producer channel is closed. A non-null cause means `incoming`
114+ // failed (e.g. a socket reset bridged from the producer) — rethrow it so
115+ // the outer loop logs and retries with backoff. A clean close (null
116+ // cause) just ends this attempt and lets the loop reconnect.
117+ result.exceptionOrNull()?.let { throw it }
118+ break
119+ }
120+ val data = sse.data
121+ if (data == null) {
122+ // A comment or keep-alive event (no data field) resets the idle watchdog
123+ // — servers send `: ping` comments to keep the connection alive through
124+ // proxies. Without this, a server that only sends comments would still
125+ // hit the idle timeout and reconnect unnecessarily.
126+ continue
127+ }
128+ val event = try {
129+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
130+ } catch (e: kotlinx.serialization.SerializationException) {
131+ Log.d("EventStream", "Skipping unparseable SSE event", e)
132+ continue
133+ }
134+ if (_state.value != ConnectionState.Connected) {
135+ _state.value = ConnectionState.Connected
136+ }
137+ send(event)
138+ }
139+ }
140+
141+ private fun stream(): Flow<BusEvent> = channelFlow {
142+ val scope = this
143+ // Drain any stale reconnect signal from a previous collection cycle so it
144+ // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
145+ // stopped the upstream while triggerReconnect() queued a signal).
146+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
147+ var backoffMs = initialBackoffMs
148+ while (isActive) {
149+ _state.value = ConnectionState.Connecting
150+ try {
151+ // The SSE stream is long-lived: disable the request-level and socket
152+ // timeouts (the client default is 60s for REST) in the request config
153+ // so the connection isn't killed mid-stream. The idle-timeout watchdog
154+ // below handles half-open connections instead.
155+ client.sse(
156+ "event",
157+ request = {
158+ timeout {
159+ requestTimeoutMillis = Long.MAX_VALUE
160+ // Disable the socket (read) timeout entirely for the SSE stream.
161+ // A finite value would kill the connection whenever the server
162+ // is quiet for longer than that (idle sessions, slow agent runs),
163+ // causing a reconnect storm. Half-open detection is handled by
164+ // the idle-timeout watchdog below plus OkHttp's pingInterval on
165+ // HTTP/2 connections.
166+ socketTimeoutMillis = Long.MAX_VALUE
167+ }
168+ },
169+ ) {
170+ // The SSE block is open — the connection is established. Mark as
171+ // Connected immediately so downstream re-seed logic fires even when
172+ // the server only sends keep-alive comments (no data events).
173+ if (_state.value != ConnectionState.Connected) {
174+ _state.value = ConnectionState.Connected
175+ }
176+ // Clear any stale reconnect signal from a triggerReconnect() call
177+ // made while the previous stream was healthy, so it doesn't suppress
178+ // the next legitimate backoff.
179+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
180+ // Bridge ktor's `incoming` flow to a channel so each element can be
181+ // raced against the idle-timeout watchdog via select. We collect it
182+ // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
183+ // the collector as a child of this channelFlow, so when `incoming`
184+ // throws on an abnormal socket close — a TCP reset, i.e. the common
185+ // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST — the child's
186+ // failure cancels the whole channelFlow and bypasses the retry `catch`
187+ // below. That killed reconnect outright and surfaced the IOException as
188+ // an uncaught crash through the (handler-less) shareIn scope. Collecting
189+ // into our own channel and closing it with the cause keeps the failure
190+ // local: readSseEvents sees the closed channel, rethrows the cause, and
191+ // the normal retry/backoff path handles it. (A clean EOF closes the
192+ // channel with no cause → readSseEvents breaks and reconnects as before.)
193+ val events = Channel<ServerSentEvent>(Channel.BUFFERED)
194+ val producer = scope.launch {
195+ try {
196+ incoming.collect { events.send(it) }
197+ events.close()
198+ } catch (c: CancellationException) {
199+ events.close(c)
200+ throw c
201+ } catch (t: Throwable) {
202+ // Deliver the failure via the channel rather than letting it
203+ // propagate up and cancel the parent channelFlow.
204+ events.close(t)
205+ }
206+ }
207+ // Reset the backoff only once real data actually arrives, not on
208+ // block entry: a server that accepts the connection then immediately
209+ // drops it would otherwise loop forever at the initial backoff and
210+ // never escalate the exponential ramp.
211+ var receivedAny = false
212+ try {
213+ readSseEvents(scope, events) {
214+ if (!receivedAny) {
215+ receivedAny = true
216+ backoffMs = initialBackoffMs
217+ }
218+ send(it)
219+ }
220+ } finally {
221+ producer.cancel()
222+ events.cancel()
223+ }
224+ }
225+ } catch (c: CancellationException) {
226+ _state.value = ConnectionState.Disconnected
227+ throw c
228+ } catch (e: Exception) {
229+ // If the scope was cancelled (e.g. connection close), the closed-client
230+ // exception is expected — don't log a spurious "stream error" warning.
231+ if (isSseAuthFailure(e)) {
232+ // Credentials are wrong — retrying won't help. Log a scrubbed
233+ // summary (class + status) instead of the full exception, whose
234+ // message carries the request URL and may include auth or paths.
235+ Log.w("EventStream", "SSE auth failed (401/403), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
236+ _state.value = ConnectionState.Failed
237+ if (!isActive) break
238+ // Don't complete the flow: a subscriber may fix the profile and call
239+ // triggerReconnect(). Suspend until an explicit reconnect signal
240+ // arrives (no backoff delay), then re-attempt the connection.
241+ reconnectSignal.receive()
242+ continue
243+ } else if (isActive) {
244+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
245+ }
246+ }
247+ _state.value = ConnectionState.Disconnected
248+ if (!isActive) break
249+ // Wait for the backoff, but allow a reconnect signal to cut it short.
250+ var signaled = false
251+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
252+ select {
253+ reconnectSignal.onReceive { signaled = true }
254+ onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
255+ }
256+ backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
257+ }
258+ }
259+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+114
@@ -0,0 +1,114 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import io.ktor.client.HttpClient
5+import io.ktor.client.engine.okhttp.OkHttp
6+import io.ktor.client.plugins.HttpTimeout
7+import io.ktor.client.plugins.auth.Auth
8+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9+import io.ktor.client.plugins.auth.providers.basic
10+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+import io.ktor.client.plugins.defaultRequest
12+import io.ktor.client.plugins.sse.SSE
13+import io.ktor.client.request.header
14+import io.ktor.http.URLBuilder
15+import io.ktor.http.takeFrom
16+import io.ktor.serialization.kotlinx.json.json
17+import java.util.concurrent.TimeUnit
18+
19+/**
20+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
21+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
22+ * SSE plugin used for `GET /event`.
23+ */
24+object HttpClientFactory {
25+
26+ fun create(profile: ServerProfile): HttpClient = HttpClient(OkHttp) {
27+ expectSuccess = true
28+
29+ engine {
30+ config {
31+ // A normal read timeout for REST calls. The SSE stream overrides this
32+ // to infinite inside the sse {} request block (see EventStreamClient),
33+ // so the long-lived event connection is never killed mid-stream.
34+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
35+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
36+ retryOnConnectionFailure(true)
37+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
38+ }
39+ }
40+
41+ install(ContentNegotiation) {
42+ json(OpencodeJson)
43+ }
44+
45+ install(SSE)
46+
47+ // Request-level timeout for REST calls. The SSE stream overrides this to
48+ // infinite inside the sse {} request block (see EventStreamClient) so the
49+ // long-lived /event connection isn't killed after 60s. The engine's connect
50+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
51+ install(HttpTimeout) {
52+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
53+ }
54+
55+ val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
57+
58+ if (profile.hasAuth) {
59+ if (isHttps) {
60+ install(Auth) {
61+ basic {
62+ credentials {
63+ BasicAuthCredentials(
64+ username = profile.username.orEmpty(),
65+ password = profile.password.orEmpty(),
66+ )
67+ }
68+ // Send eagerly so opencode doesn't need a 401 challenge round-trip.
69+ // The Auth plugin is only installed for HTTPS profiles — over HTTP,
70+ // even reactive (401-challenge) credential sending would leak
71+ // passwords in cleartext, so we don't install it at all.
72+ sendWithoutRequest { true }
73+ }
74+ }
75+ } else {
76+ // For HTTP profiles with auth, send credentials proactively in the
77+ // Authorization header via defaultRequest (below) so the user's intent
78+ // to connect over cleartext is honored, but we never install the Auth
79+ // plugin's reactive challenge-response which would silently re-send
80+ // credentials on any 401 without checking the protocol.
81+ }
82+ }
83+
84+ defaultRequest {
85+ url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
86+ // For HTTP profiles with auth, attach the Basic auth header proactively on
87+ // every request (the Auth plugin is skipped for non-HTTPS). The user chose
88+ // cleartext explicitly, so we honor that — but without the reactive challenge
89+ // logic that could re-send credentials silently.
90+ if (profile.hasAuth && !isHttps) {
91+ val credentials = java.util.Base64.getEncoder().encodeToString(
92+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
93+ )
94+ header("Authorization", "Basic $credentials")
95+ }
96+ }
97+ }
98+
99+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
100+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
101+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
102+ fun normalizeBaseUrl(raw: String): String {
103+ val trimmed = raw.trim()
104+ // Compare the scheme prefix case-insensitively (a lowercased copy) while
105+ // preserving the original casing of the rest of the URL in the output.
106+ val lowerTrimmed = trimmed.lowercase()
107+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
108+ trimmed
109+ } else {
110+ "https://$trimmed"
111+ }
112+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
113+ }
114+}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+151
@@ -0,0 +1,151 @@
1+package soy.iko.opencode.data.network
2+
3+/**
4+ * Centralized tuning parameters for the network and presentation layers. Keeping these
5+ * in one place makes them easy to find and adjust without hunting through source files,
6+ * and makes the intent of each value explicit rather than a bare magic number.
7+ */
8+object NetworkConfig {
9+
10+ // --- OkHttp engine timeouts (HttpClientFactory) ---
11+
12+ /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13+ const val connectTimeoutSeconds = 30L
14+ /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15+ const val readTimeoutSeconds = 60L
16+ /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17+ const val pingIntervalSeconds = 20L
18+ /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19+ const val restRequestTimeoutMs = 60_000L
20+
21+ // --- REST retry (OpencodeApiClient.withRetry) ---
22+
23+ /** Maximum number of attempts for a retriable REST call. */
24+ const val retryMaxAttempts = 3
25+ /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26+ const val retryInitialDelayMs = 500L
27+ /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28+ const val retryMaxDelayMs = 30_000L
29+ /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
30+ const val retryJitterFactor = 0.2
31+
32+ // --- SSE stream (EventStreamClient) ---
33+
34+ /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35+ const val sseInitialBackoffMs = 500L
36+ /** Cap on the SSE reconnect backoff. */
37+ const val sseMaxBackoffMs = 10_000L
38+ /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
39+ const val sseIdleTimeoutMs = 90_000L
40+ /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41+ const val sseEventBufferCapacity = 64
42+
43+ // --- Catalog cache (OpencodeApiClient) ---
44+
45+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
46+ const val catalogCacheTtlMs = 60_000L
47+
48+ // --- Session list (SessionListViewModel) ---
49+
50+ /** Max sessions to fetch previews for in one batch. */
51+ const val maxPreviewSessions = 50
52+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
53+ const val maxConcurrentPreviews = 8
54+ /** Max characters of a session's last message to keep as a list preview. */
55+ const val previewTextMaxLength = 200
56+ /** Debounce delay before fetching a session preview after an SSE update, so a burst
57+ * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
58+ * instead of launching one per event (each downloading the full message history). */
59+ const val previewDebounceMs = 500L
60+ /** Debounce for applying SessionUpdated SSE events to the session list state. During
61+ * active streaming the server emits these frequently; without debouncing each one
62+ * triggers a full list filter + sort + recomposition, causing scroll jank. */
63+ const val sessionUpdateDebounceMs = 300L
64+
65+ /** Grace period before a deferred session delete actually fires, during which an
66+ * Undo snackbar lets the user cancel it. */
67+ const val undoDeleteDelayMs = 5_000L
68+
69+ /** Grace period before a deferred server-profile delete actually fires, during which
70+ * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
71+ const val undoServerDeleteDelayMs = 5_000L
72+
73+ /** Grace period before a deferred crash-report delete actually fires, during which
74+ * an Undo snackbar lets the user cancel it. */
75+ const val undoReportDeleteDelayMs = 5_000L
76+
77+ // --- In-memory message store (SessionRepository.MessageStore) ---
78+
79+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
80+ const val maxInMemoryMessages = 500
81+
82+ // --- File browser (FileBrowserViewModel) ---
83+
84+ /** Debounce delay before firing a file-search request after the user stops typing. */
85+ const val fileSearchDebounceMs = 250L
86+
87+ // --- StateFlow sharing (WhileSubscribed) ---
88+
89+ /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
90+ const val stateFlowSubscriptionTimeoutMs = 5_000L
91+
92+ // --- Draft persistence (ChatViewModel) ---
93+
94+ /** Debounce delay before persisting a draft to disk after the user stops typing. */
95+ const val draftDebounceMs = 500L
96+
97+ /** Maximum characters allowed in the chat input field. A generous cap that prevents
98+ * a huge paste from stalling the UI (the field would otherwise buffer and lay out
99+ * an unbounded string) while leaving plenty of room for long prompts. */
100+ const val maxDraftLengthChars = 32_000
101+
102+ /** Show the "remaining characters" supportingText under the chat input once the
103+ * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
104+ * visible before it silently kicks in but doesn't clutter a normal short prompt. */
105+ const val draftCountdownThresholdFraction = 0.8f
106+
107+ /** Maximum characters allowed in a session title (rename dialog). Prevents the
108+ * server from rejecting an overly long title and keeps list rows readable. */
109+ const val maxSessionTitleChars = 200
110+
111+ // --- Profile store (ServerEditViewModel) ---
112+
113+ /** Timeout for loading a profile from DataStore before giving up. */
114+ const val profileLoadTimeoutMs = 5_000L
115+
116+ // --- SSE stream (EventStreamClient, AppContainer) ---
117+
118+ /** Delay before retrying the message-activity observer after a failure. */
119+ const val observerRetryDelayMs = 5_000L
120+ /** Max concurrent assistant runs to track for completion notifications. */
121+ const val activeRunsLimit = 200
122+
123+ // --- Markdown rendering (MarkdownText) ---
124+
125+ /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
126+ * library re-parses the full AST on every content change, so during a long streaming
127+ * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
128+ * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
129+ const val streamingThrottleMs = 50L
130+
131+ // --- Snackbar one-shot events (ViewModels) ---
132+
133+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
134+ const val snackbarEventBufferCapacity = 16
135+
136+ /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
137+ * after a tap, giving immediate feedback that the refresh was triggered even when
138+ * the connection state is already Connected (so the ConnectionBanner doesn't change). */
139+ const val refreshFeedbackMs = 1_200L
140+
141+ // --- UI layout constants ---
142+
143+ /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
144+ const val twoPaneWidthThresholdDp = 840
145+ /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
146+ const val userBubbleWidthFraction = 0.85f
147+ /** Left pane weight in the two-pane layout (session list). */
148+ const val twoPaneLeftWeight = 0.38f
149+ /** Right pane weight in the two-pane layout (chat detail). */
150+ const val twoPaneRightWeight = 0.62f
151+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+325
@@ -0,0 +1,325 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.Agent
4+import soy.iko.opencode.data.model.Command
5+import soy.iko.opencode.data.model.CommandRequest
6+import soy.iko.opencode.data.model.CreateSessionRequest
7+import soy.iko.opencode.data.model.FileContent
8+import soy.iko.opencode.data.model.FileNode
9+import soy.iko.opencode.data.model.FileStatusEntry
10+import soy.iko.opencode.data.model.MessageWithParts
11+import soy.iko.opencode.data.model.ModelRef
12+import soy.iko.opencode.data.model.PermissionReplyBody
13+import soy.iko.opencode.data.model.PermissionResponse
14+import soy.iko.opencode.data.model.PromptPart
15+import soy.iko.opencode.data.model.PromptRequest
16+import soy.iko.opencode.data.model.ProvidersResponse
17+import soy.iko.opencode.data.model.Session
18+import soy.iko.opencode.data.model.UpdateSessionRequest
19+import io.ktor.client.HttpClient
20+import io.ktor.client.call.body
21+import io.ktor.client.plugins.ClientRequestException
22+import io.ktor.client.plugins.ResponseException
23+import io.ktor.client.plugins.ServerResponseException
24+import io.ktor.client.request.delete
25+import io.ktor.client.request.get
26+import io.ktor.client.request.header
27+import io.ktor.client.request.parameter
28+import io.ktor.client.request.patch
29+import io.ktor.client.request.post
30+import io.ktor.client.request.setBody
31+import io.ktor.http.ContentType
32+import io.ktor.http.contentType
33+import kotlinx.coroutines.delay
34+import kotlinx.coroutines.sync.Mutex
35+import kotlinx.coroutines.sync.withLock
36+import kotlin.random.Random
37+import java.net.URLEncoder
38+
39+/**
40+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
41+ * SSE stream lives in [EventStreamClient]; everything else is here.
42+ */
43+open class OpencodeApiClient private constructor(
44+ private val client: HttpClient?,
45+ @Suppress("unused") private val testMode: Boolean,
46+) {
47+ constructor(client: HttpClient) : this(client, false)
48+ protected constructor() : this(null, true)
49+
50+ // --- Catalog cache (providers/agents/commands) ---
51+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
52+ // A short-lived in-memory cache avoids redundant network calls when navigating
53+ // between sessions on the same server.
54+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
55+ // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
56+ // The lock is held during the fetch to prevent a thundering-herd stampede when
57+ // multiple coroutines miss the cache simultaneously.
58+ private val providersMutex = Mutex()
59+ private val agentsMutex = Mutex()
60+ private val commandsMutex = Mutex()
61+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
62+ private var cachedAgents: CachedEntry<List<Agent>>? = null
63+ private var cachedCommands: CachedEntry<List<Command>>? = null
64+
65+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
66+ open suspend fun ping() {
67+ withRetry { client!!.get("global/health").body<String>() }
68+ }
69+
70+ open suspend fun listSessions(): List<Session> = withRetry {
71+ client!!.get("session").body()
72+ }
73+
74+ open suspend fun createSession(title: String? = null): Session {
75+ // Generate the idempotency key before entering withRetry so every retry attempt
76+ // shares it. Without this, a POST that reached the server but whose response was
77+ // lost (reset/timeout) would be retried and create a second orphan session.
78+ val idempotencyKey = java.util.UUID.randomUUID().toString()
79+ return withRetry {
80+ client!!.post("session") {
81+ contentType(ContentType.Application.Json)
82+ header("Idempotency-Key", idempotencyKey)
83+ setBody(CreateSessionRequest(title = title))
84+ }.body()
85+ }
86+ }
87+
88+ open suspend fun updateSession(id: String, title: String): Session = withRetry {
89+ client!!.patch("session/${encode(id)}") {
90+ contentType(ContentType.Application.Json)
91+ setBody(UpdateSessionRequest(title = title))
92+ }.body()
93+ }
94+
95+ open suspend fun deleteSession(id: String) {
96+ try {
97+ withRetry { client!!.delete("session/${encode(id)}").body<String>() }
98+ } catch (t: ClientRequestException) {
99+ // A DELETE that succeeded but whose response was lost gets retried; the retry
100+ // hits an already-gone session and returns 404. Deleting a non-existent session
101+ // is effectively success, so swallow 404 rather than surfacing a spurious failure.
102+ if (t.response.status.value != 404) throw t
103+ }
104+ }
105+
106+ open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
107+ client!!.get("session/${encode(sessionId)}/message").body()
108+ }
109+
110+ open suspend fun sendPrompt(
111+ sessionId: String,
112+ text: String,
113+ model: ModelRef? = null,
114+ agent: String? = null,
115+ ): MessageWithParts {
116+ // Generate the idempotency key *before* entering withRetry so all retry
117+ // attempts share the same key. If the key were generated inside withRetry,
118+ // each attempt would get a fresh UUID and the server couldn't deduplicate a
119+ // request that reached it but whose response was lost (e.g. timeout).
120+ val idempotencyKey = java.util.UUID.randomUUID().toString()
121+ return withRetry {
122+ client!!.post("session/${encode(sessionId)}/message") {
123+ contentType(ContentType.Application.Json)
124+ header("Idempotency-Key", idempotencyKey)
125+ setBody(
126+ PromptRequest(
127+ parts = listOf(PromptPart(text = text)),
128+ model = model,
129+ agent = agent,
130+ ),
131+ )
132+ }.body()
133+ }
134+ }
135+
136+ open suspend fun abort(sessionId: String) {
137+ withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
138+ }
139+
140+ /** Invoke a slash-command by name via `POST /session/:id/command`. */
141+ open suspend fun runCommand(
142+ sessionId: String,
143+ command: String,
144+ arguments: String = "",
145+ agent: String? = null,
146+ ): MessageWithParts {
147+ // Generate the idempotency key before entering withRetry so all retry
148+ // attempts share the same key, preventing duplicate command execution if
149+ // the server processed the request but the response was lost.
150+ val idempotencyKey = java.util.UUID.randomUUID().toString()
151+ return withRetry {
152+ client!!.post("session/${encode(sessionId)}/command") {
153+ contentType(ContentType.Application.Json)
154+ header("Idempotency-Key", idempotencyKey)
155+ setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
156+ }.body()
157+ }
158+ }
159+
160+ open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
161+ val now = System.currentTimeMillis()
162+ val cached = cachedProviders
163+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
164+ return@withLock cached.value
165+ }
166+ val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
167+ cachedProviders = CachedEntry(value, System.currentTimeMillis())
168+ value
169+ }
170+
171+ open suspend fun agents(): List<Agent> = agentsMutex.withLock {
172+ val now = System.currentTimeMillis()
173+ val cached = cachedAgents
174+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
175+ return@withLock cached.value
176+ }
177+ val value = withRetry { client!!.get("agent").body<List<Agent>>() }
178+ cachedAgents = CachedEntry(value, System.currentTimeMillis())
179+ value
180+ }
181+
182+ open suspend fun commands(): List<Command> = commandsMutex.withLock {
183+ val now = System.currentTimeMillis()
184+ val cached = cachedCommands
185+ if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
186+ return@withLock cached.value
187+ }
188+ val value = withRetry { client!!.get("command").body<List<Command>>() }
189+ cachedCommands = CachedEntry(value, System.currentTimeMillis())
190+ value
191+ }
192+
193+ /** Invalidate the catalog cache (e.g. on server switch). */
194+ open suspend fun invalidateCache() {
195+ providersMutex.withLock { cachedProviders = null }
196+ agentsMutex.withLock { cachedAgents = null }
197+ commandsMutex.withLock { cachedCommands = null }
198+ }
199+
200+ open suspend fun invalidateProvidersCache() {
201+ providersMutex.withLock { cachedProviders = null }
202+ }
203+
204+ open suspend fun invalidateAgentsCache() {
205+ agentsMutex.withLock { cachedAgents = null }
206+ }
207+
208+ open suspend fun invalidateCommandsCache() {
209+ commandsMutex.withLock { cachedCommands = null }
210+ }
211+
212+ /** Respond to a permission request so a paused tool run can proceed. */
213+ open suspend fun respondPermission(
214+ sessionId: String,
215+ permissionId: String,
216+ response: PermissionResponse,
217+ ): String = try {
218+ withRetry {
219+ client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
220+ contentType(ContentType.Application.Json)
221+ setBody(PermissionReplyBody(response.wire))
222+ }.body<String>()
223+ }
224+ } catch (t: ClientRequestException) {
225+ // A response that reached the server but whose reply was lost gets retried; the
226+ // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
227+ // the answer landed, so treat them as success instead of a spurious failure.
228+ val code = t.response.status.value
229+ if (code != 404 && code != 409) throw t
230+ ""
231+ }
232+
233+ // --- Files ---
234+
235+ open suspend fun findFiles(query: String): List<String> = withRetry {
236+ client!!.get("find/file") { parameter("query", query) }.body()
237+ }
238+
239+ open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
240+ client!!.get("file") { parameter("path", path) }.body()
241+ }
242+
243+ open suspend fun readFile(path: String): FileContent = withRetry {
244+ client!!.get("file/content") { parameter("path", path) }.body()
245+ }
246+
247+ open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
248+ client!!.get("file/status").body()
249+ }
250+}
251+
252+private suspend fun <T> withRetry(
253+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
254+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
255+ block: suspend () -> T,
256+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
257+
258+/**
259+ * Retry a suspending block up to [maxAttempts] times with exponential backoff.
260+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
261+ * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
262+ * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
263+ * server errors are retried. Exposed as `internal` so the retry/skip rules are
264+ * unit-testable without an HTTP server.
265+ */
266+internal suspend fun <T> withRetryInternal(
267+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
268+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
269+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
270+ block: suspend () -> T,
271+): T {
272+ var lastError: Throwable? = null
273+ for (attempt in 1..maxAttempts) {
274+ try {
275+ return block()
276+ } catch (c: kotlinx.coroutines.CancellationException) {
277+ throw c
278+ } catch (t: ClientRequestException) {
279+ // 429 (Too Many Requests) is transient — retry with backoff.
280+ // All other 4xx are non-retryable.
281+ if (t.response.status.value != 429) throw t
282+ lastError = t
283+ if (attempt < maxAttempts) {
284+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
285+ }
286+ } catch (t: ServerResponseException) {
287+ // 5xx server errors are retryable with backoff.
288+ lastError = t
289+ if (attempt < maxAttempts) {
290+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
291+ }
292+ } catch (t: ResponseException) {
293+ // 3xx (redirect limit exceeded) is non-retryable.
294+ throw t
295+ } catch (t: Exception) {
296+ lastError = t
297+ if (attempt < maxAttempts) {
298+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
299+ }
300+ }
301+ }
302+ throw lastError ?: IllegalStateException("withRetry failed without error")
303+}
304+
305+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
306+ * so the backoff formula is unit-testable without an HTTP server. */
307+internal fun jitteredBackoff(
308+ attempt: Int,
309+ initialDelayMs: Long,
310+ jitterFactor: Double,
311+): Long {
312+ // Guard against Int overflow from the shift and Long overflow from
313+ // the multiplication at high attempt counts.
314+ val shift = (attempt - 1).coerceAtMost(30)
315+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
316+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
317+ // shorter, sometimes longer — spreads concurrent client retries and
318+ // prevents thundering-herd reconnection storms.
319+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
320+ return (baseDelay + jitter).coerceAtLeast(0)
321+}
322+
323+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
324+private fun encode(segment: String): String =
325+ URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+37
@@ -0,0 +1,37 @@
1+package soy.iko.opencode.data.network
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.Part
6+import soy.iko.opencode.data.model.ToolState
7+import soy.iko.opencode.data.model.UnknownEvent
8+import soy.iko.opencode.data.model.UnknownMessage
9+import soy.iko.opencode.data.model.UnknownPart
10+import soy.iko.opencode.data.model.ToolUnknown
11+import kotlinx.serialization.ExperimentalSerializationApi
12+import kotlinx.serialization.json.Json
13+import kotlinx.serialization.modules.SerializersModule
14+
15+/**
16+ * Single shared Json for the whole app.
17+ *
18+ * The polymorphic default deserializers are the resilience layer: when the server
19+ * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20+ * Unknown variant instead of throwing — so a new opencode release can't break the
21+ * event stream or message decoding.
22+ */
23+@OptIn(ExperimentalSerializationApi::class)
24+val OpencodeJson: Json = Json {
25+ ignoreUnknownKeys = true
26+ isLenient = true
27+ coerceInputValues = true
28+ encodeDefaults = true
29+ explicitNulls = false
30+ classDiscriminator = "type"
31+ serializersModule = SerializersModule {
32+ polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33+ polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34+ polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35+ polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36+ }
37+}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktnew file+216
@@ -0,0 +1,216 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.content.pm.PackageManager
5+import android.os.Build
6+import android.util.Log
7+import androidx.core.content.pm.PackageInfoCompat
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.Job
11+import kotlinx.coroutines.SupervisorJob
12+import kotlinx.coroutines.cancel
13+import kotlinx.coroutines.delay
14+import kotlinx.coroutines.flow.MutableStateFlow
15+import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asStateFlow
17+import kotlinx.coroutines.launch
18+import java.io.File
19+import java.io.PrintWriter
20+import java.io.StringWriter
21+import java.text.SimpleDateFormat
22+import java.util.Date
23+import java.util.Locale
24+import soy.iko.opencode.util.runCatchingCancellable
25+
26+/**
27+ * A self-contained crash reporter with no network or third-party backend. An uncaught
28+ * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29+ * text report (timestamp, device/app metadata, and the full stack trace), then the
30+ * previous handler is re-invoked so the process still dies normally.
31+ *
32+ * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33+ * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34+ * without pulling in a hosted analytics SDK.
35+ */
36+class CrashLogger private constructor(private val appContext: Context) {
37+
38+ data class CrashReport(
39+ val fileName: String,
40+ val timestamp: Long,
41+ val preview: String,
42+ )
43+
44+ private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46+
47+ private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48+ val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
49+
50+ fun install() {
51+ val previous = Thread.getDefaultUncaughtExceptionHandler()
52+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53+ runCatching { writeReport(thread, throwable) }
54+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
55+ previous?.uncaughtException(thread, throwable)
56+ }
57+ // Load crash reports off the main thread so startup isn't blocked by file I/O
58+ // (listFiles + reading the first line of each report). The StateFlow updates
59+ // whenever the scan completes, so the Diagnostics screen reflects the result.
60+ scope.launch { runCatchingCancellable { refresh() } }
61+ }
62+
63+ /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
64+ * refresh() runs on the multi-threaded IO dispatcher from several call sites
65+ * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
66+ private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
67+
68+ fun refresh() {
69+ val generation = refreshGeneration.incrementAndGet()
70+ val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
71+ ?.sortedByDescending { it.lastModified() }
72+ ?.map { f ->
73+ CrashReport(
74+ fileName = f.name,
75+ timestamp = f.lastModified(),
76+ preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
77+ )
78+ }
79+ .orEmpty()
80+ // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
81+ // that started before a delete) can't momentarily re-show a just-removed report.
82+ if (generation == refreshGeneration.get()) _reports.value = scanned
83+ }
84+
85+ fun readReport(fileName: String): String? {
86+ val file = File(crashDir, fileName).canonicalFile
87+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
88+ return runCatching { file.readText() }.getOrNull()
89+ }
90+
91+ fun deleteReport(fileName: String) {
92+ scope.launch {
93+ val file = File(crashDir, fileName).canonicalFile
94+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
95+ file.delete()
96+ }
97+ refresh()
98+ }
99+ }
100+
101+ /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
103+
104+ /**
105+ * Schedule [fileName] for deletion after [delayMs], cancellable via
106+ * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
107+ * Diagnostics screen's composition scope — so navigating away during the undo window
108+ * still commits the delete instead of silently dropping it. A repeated schedule for
109+ * the same name replaces the prior timer.
110+ */
111+ fun scheduleDelete(fileName: String, delayMs: Long) {
112+ val job = scope.launch {
113+ delay(delayMs)
114+ // Atomically claim the delete by removing our own map entry BEFORE deleting.
115+ // The remove is the synchronization point with cancelScheduledDelete: whichever
116+ // removes the entry first wins. If an undo raced the timer and removed it first,
117+ // this returns false and we skip the delete — so the file survives, matching the
118+ // `true` that cancel returned to the UI. Deleting first (then removing) would let
119+ // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
120+ // remove(name, thisJob) also no-ops for a stale job after a reschedule.
121+ val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
122+ if (!claimed) return@launch
123+ val file = File(crashDir, fileName).canonicalFile
124+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
125+ file.delete()
126+ }
127+ refresh()
128+ }
129+ pendingDeletes.put(fileName, job)?.cancel()
130+ }
131+
132+ /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
133+ * pending (undo succeeded), false if the delete had already fired. */
134+ fun cancelScheduledDelete(fileName: String): Boolean {
135+ val job = pendingDeletes.remove(fileName) ?: return false
136+ job.cancel()
137+ return true
138+ }
139+
140+ fun clearAll() {
141+ scope.launch {
142+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
143+ refresh()
144+ }
145+ }
146+
147+ fun reportCount(): Int = _reports.value.size
148+
149+ /** Cancel the internal coroutine scope so background work doesn't outlive the
150+ * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
151+ * intentionally left installed — a crash during shutdown still needs to be logged. */
152+ fun shutdown() {
153+ scope.cancel()
154+ }
155+
156+ private fun writeReport(thread: Thread, throwable: Throwable) {
157+ val now = Date()
158+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
159+ // Append a short random suffix so two crashes in the same millisecond don't
160+ // overwrite each other.
161+ val suffix = java.lang.Long.toHexString(System.nanoTime())
162+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
163+ val sw = StringWriter()
164+ PrintWriter(sw).use { pw ->
165+ pw.println("opencode-android crash report")
166+ pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
167+ pw.println("Thread: ${thread.name}")
168+ pw.println("App version: ${appVersion()}")
169+ pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
170+ pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
171+ pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
172+ pw.println()
173+ pw.println(throwable.javaClass.name + ": " + throwable.message)
174+ throwable.printStackTrace(pw)
175+ }
176+ // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
177+ // message (and every "Caused by:" message) verbatim, so scrubbing only the
178+ // top-level message above is futile — Ktor/OkHttp embed the full request URL
179+ // in those messages, which may contain auth or internal paths. Scrubbing the
180+ // assembled text guarantees no URL survives anywhere in the stored report.
181+ file.writeText(scrubUrls(sw.toString()))
182+ }
183+
184+ private fun appVersion(): String = runCatching {
185+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
186+ appContext.packageManager.getPackageInfo(
187+ appContext.packageName,
188+ PackageManager.PackageInfoFlags.of(0),
189+ )
190+ } else {
191+ @Suppress("DEPRECATION")
192+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
193+ }
194+ "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
195+ }.getOrDefault("unknown")
196+
197+ companion object {
198+ /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
199+ internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
200+
201+ /**
202+ * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
203+ * redaction (used on the whole crash report, including exception messages and
204+ * stack traces produced by printStackTrace) can be unit-tested in isolation.
205+ */
206+ internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
207+
208+ @Volatile
209+ private var instance: CrashLogger? = null
210+
211+ fun get(context: Context): CrashLogger =
212+ instance ?: synchronized(this) {
213+ instance ?: CrashLogger(context.applicationContext).also { instance = it }
214+ }
215+ }
216+}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+266
@@ -0,0 +1,266 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import kotlinx.coroutines.CoroutineScope
5+import kotlinx.coroutines.Dispatchers
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.flow.update
10+import android.util.Log
11+import kotlinx.coroutines.launch
12+import kotlinx.coroutines.withContext
13+import java.util.concurrent.Executors
14+
15+/**
16+ * Persists the in-progress message draft for each session so it survives
17+ * back-navigation and process death. Backed by SharedPreferences because the
18+ * UI needs the initial value synchronously and drafts are low-stakes data.
19+ *
20+ * The prefs file is loaded on a background thread at construction time. Until
21+ * the load completes, [drafts] reports an empty map and [get] returns "" rather
22+ * than blocking the caller (previously a [CountDownLatch] could stall the main
23+ * thread for up to 2 seconds on a slow disk).
24+ */
25+open class DraftStore private constructor(
26+ private val appContext: Context?,
27+ private val scope: CoroutineScope?,
28+ @Suppress("unused") private val testMode: Boolean,
29+) {
30+ constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31+ protected constructor() : this(null, null, true)
32+
33+ private val prefs by lazy {
34+ val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35+ ?: error("No context — override methods in test subclass")
36+ prefsInitialized.set(true)
37+ sp
38+ }
39+
40+ /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
41+ * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
42+ * write on the main thread is safe or whether the write must be deferred to a
43+ * background thread to avoid blocking on first-time disk I/O. */
44+ private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
45+
46+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
47+ * so the main thread is never blocked on disk I/O. */
48+ private val flushExecutor by lazy {
49+ Executors.newSingleThreadExecutor { r ->
50+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
51+ }
52+ }
53+
54+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
55+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
56+ open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
57+
58+ /** Queued follow-up messages per session, persisted under a namespaced key so a
59+ * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
60+ * ChatViewModel would otherwise lose it, along with the cleared draft). */
61+ private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
62+
63+ private val _ready = MutableStateFlow(false)
64+ /** True once the background prefs load has completed. */
65+ open val ready: StateFlow<Boolean> = _ready.asStateFlow()
66+
67+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
68+ * finished. For these the in-memory state is authoritative — the load must not
69+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
70+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
71+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
72+ * but live under different prefs keys. */
73+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
74+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
75+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
76+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
77+
78+ private fun markDraftTouched(sessionId: String) {
79+ if (!_ready.value) touchedDraftKeys.add(sessionId)
80+ }
81+ private fun markFollowUpTouched(sessionId: String) {
82+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
83+ }
84+
85+ init {
86+ // Eagerly load the prefs file on a background thread so the first main-thread
87+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
88+ // so callers can observe readiness without blocking.
89+ if (scope != null && appContext != null) {
90+ scope.launch(Dispatchers.IO) {
91+ val all = runCatching {
92+ prefs.all.mapValues { it.value as? String ?: "" }
93+ }.getOrDefault(emptyMap())
94+ // Partition persisted keys: follow-up keys carry a prefix, everything else
95+ // is a plain per-session draft.
96+ val drafts = mutableMapOf<String, String>()
97+ val followUps = mutableMapOf<String, String>()
98+ all.forEach { (k, v) ->
99+ if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
100+ else drafts[k] = v
101+ }
102+ // Merge instead of overwriting: any in-memory writes that arrived before
103+ // the background load completed (e.g. a share-intent draft injected via
104+ // setImmediate) take priority over the persisted snapshot so they aren't
105+ // clobbered. For keys the user explicitly touched before the load finished,
106+ // drop the persisted entry first so a value they *cleared* isn't resurrected
107+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
108+ _drafts.update { current ->
109+ drafts.apply {
110+ touchedDraftKeys.forEach { remove(it) }
111+ putAll(current)
112+ }
113+ }
114+ _followUps.update { current ->
115+ followUps.apply {
116+ touchedFollowUpKeys.forEach { remove(it) }
117+ putAll(current)
118+ }
119+ }
120+ _ready.value = true
121+ }
122+ } else {
123+ _ready.value = true
124+ }
125+ }
126+
127+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
128+ open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
129+
130+ /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
131+ open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
132+
133+ /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
134+ * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
135+ * so a follow-up queued just before process death is flushed before the process exits.
136+ * A blank [text] clears the stored follow-up. */
137+ open fun flushFollowUp(sessionId: String, text: String) {
138+ markFollowUpTouched(sessionId)
139+ _followUps.update { current ->
140+ val updated = current.toMutableMap()
141+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
142+ updated
143+ }
144+ writeAsync(FOLLOWUP_PREFIX + sessionId, text)
145+ }
146+
147+ /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
148+ * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
149+ private fun writeAsync(key: String, text: String) {
150+ val write = {
151+ runCatching {
152+ prefs.edit().apply {
153+ if (text.isBlank()) remove(key) else putString(key, text)
154+ }.apply()
155+ }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
156+ }
157+ if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
158+ }
159+
160+ /** Update the in-memory draft immediately without persisting to disk.
161+ * Used when navigation must see the new value synchronously (e.g. share-intent
162+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
163+ open fun setImmediate(sessionId: String, text: String) {
164+ markDraftTouched(sessionId)
165+ _drafts.update { current ->
166+ val updated = current.toMutableMap()
167+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
168+ updated
169+ }
170+ }
171+
172+ open suspend fun set(sessionId: String, text: String) {
173+ markDraftTouched(sessionId)
174+ _drafts.update { current ->
175+ val updated = current.toMutableMap()
176+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
177+ updated
178+ }
179+ withContext(Dispatchers.IO) {
180+ runCatching {
181+ prefs.edit().apply {
182+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
183+ }.apply()
184+ }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
185+ }
186+ }
187+
188+ /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
189+ * Also clears any queued follow-up so a deleted session leaves nothing behind. */
190+ open suspend fun remove(sessionId: String) {
191+ markDraftTouched(sessionId)
192+ markFollowUpTouched(sessionId)
193+ _drafts.update { current ->
194+ val updated = current.toMutableMap()
195+ updated.remove(sessionId)
196+ updated
197+ }
198+ _followUps.update { current ->
199+ val updated = current.toMutableMap()
200+ updated.remove(sessionId)
201+ updated
202+ }
203+ withContext(Dispatchers.IO) {
204+ runCatching {
205+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
206+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
207+ }
208+ }
209+
210+ /**
211+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
212+ * viewModelScope is already cancelled and a suspending write can't be used.
213+ *
214+ * If the SharedPreferences file has already been opened (the normal case — the
215+ * background init load touches it shortly after construction), the write uses
216+ * [apply] (asynchronous) and returns immediately without blocking the main thread.
217+ *
218+ * If the prefs file has NOT been opened yet (a rare race where onCleared fires
219+ * before the background init), the write is dispatched to a background thread
220+ * instead of blocking the main thread on a synchronous disk read. The draft
221+ * may be lost if the process exits before the write completes, but this is
222+ * preferable to an ANR.
223+ */
224+ open fun flushDraft(sessionId: String, text: String) {
225+ markDraftTouched(sessionId)
226+ _drafts.update { current ->
227+ val updated = current.toMutableMap()
228+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
229+ updated
230+ }
231+ if (prefsInitialized.get()) {
232+ // Prefs already opened — the lazy resolves instantly and apply() is async.
233+ runCatching {
234+ prefs.edit().apply {
235+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
236+ }.apply()
237+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
238+ } else {
239+ // Prefs not yet opened — dispatch to a background thread to avoid a
240+ // synchronous disk read on the main thread (which would ANR). The draft
241+ // may be lost if the process exits before the write completes.
242+ flushExecutor.execute {
243+ runCatching {
244+ prefs.edit().apply {
245+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
246+ }.apply()
247+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
248+ }
249+ }
250+ }
251+
252+ private companion object {
253+ /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
254+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
255+ * id (which are non-blank, space-free tokens like "ses_..."). */
256+ const val FOLLOWUP_PREFIX = "followup"
257+ }
258+
259+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
260+ open fun shutdown() {
261+ flushExecutor.shutdown()
262+ // Await pending flushDraft writes so drafts aren't lost if the process exits
263+ // immediately after shutdown (e.g. ANR-triggered process kill).
264+ runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
265+ }
266+}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktnew file+78
@@ -0,0 +1,78 @@
1+package soy.iko.opencode.data.repo
2+
3+import io.ktor.client.plugins.ClientRequestException
4+import io.ktor.client.plugins.HttpRequestTimeoutException
5+import io.ktor.client.plugins.RedirectResponseException
6+import io.ktor.client.plugins.ResponseException
7+import io.ktor.client.plugins.ServerResponseException
8+import java.io.IOException
9+import java.net.ConnectException
10+import java.net.SocketTimeoutException
11+import java.net.UnknownHostException
12+
13+/**
14+ * User-facing categories for a thrown error. Extracted so the classification logic is
15+ * unit-testable without an Android [android.content.Context] (the mapping from a kind
16+ * to a localized string lives in [friendlyError]).
17+ */
18+enum class ErrorKind {
19+ /** Server host could not be resolved / connection refused. */
20+ NOT_REACHABLE,
21+
22+ /** Connect or read timed out. */
23+ TIMEOUT,
24+
25+ /** Server returned a 5xx response. */
26+ SERVER,
27+
28+ /** Server returned a 4xx response (auth, not-found, etc.). */
29+ CLIENT,
30+
31+ /** Any other network-level I/O failure. */
32+ NETWORK,
33+
34+ /** Anything that isn't network-related. */
35+ UNKNOWN,
36+}
37+
38+/**
39+ * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40+ * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41+ * by a library still classifies correctly.
42+ */
43+fun classifyError(t: Throwable): ErrorKind {
44+ var current: Throwable? = t
45+ // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46+ // IOException) still classify by their root.
47+ var hops = 0
48+ while (current != null && hops < 8) {
49+ when (current) {
50+ is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51+ is SocketTimeoutException, is HttpRequestTimeoutException ->
52+ return ErrorKind.TIMEOUT
53+ is ServerResponseException -> return ErrorKind.SERVER
54+ is ClientRequestException -> return ErrorKind.CLIENT
55+ is RedirectResponseException -> return ErrorKind.CLIENT
56+ is IOException -> return ErrorKind.NETWORK
57+ else -> {
58+ // Any other ResponseException (unrecognized status family).
59+ if (current is ResponseException) return ErrorKind.SERVER
60+ }
61+ }
62+ current = current.cause
63+ hops++
64+ }
65+ return ErrorKind.UNKNOWN
66+}
67+
68+/** The HTTP status code carried by a client/server error, if any. */
69+fun responseStatusCode(t: Throwable): Int? {
70+ var current: Throwable? = t
71+ var hops = 0
72+ while (current != null && hops < 8) {
73+ if (current is ResponseException) return current.response.status.value
74+ current = current.cause
75+ hops++
76+ }
77+ return null
78+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+237
@@ -0,0 +1,237 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import android.content.SharedPreferences
5+import android.util.Log
6+import androidx.datastore.preferences.core.edit
7+import androidx.datastore.preferences.core.stringPreferencesKey
8+import androidx.datastore.preferences.preferencesDataStore
9+import androidx.security.crypto.EncryptedSharedPreferences
10+import androidx.security.crypto.MasterKey
11+import soy.iko.opencode.data.model.ServerProfile
12+import soy.iko.opencode.data.network.OpencodeJson
13+import kotlinx.coroutines.Dispatchers
14+import kotlinx.coroutines.sync.Mutex
15+import kotlinx.coroutines.sync.withLock
16+import kotlinx.coroutines.flow.Flow
17+import kotlinx.coroutines.flow.flowOn
18+import kotlinx.coroutines.flow.map
19+import kotlinx.coroutines.withContext
20+import kotlinx.serialization.Serializable
21+import kotlinx.serialization.builtins.ListSerializer
22+
23+private val Context.dataStore by preferencesDataStore(name = "server_profiles")
24+
25+/**
26+ * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
27+ * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
28+ * by profile id, and merged back in on read.
29+ */
30+open class ProfileStore private constructor(
31+ private val appContext: Context?,
32+ @Suppress("unused") private val testMode: Boolean,
33+) {
34+ constructor(context: Context) : this(context.applicationContext, false)
35+ protected constructor() : this(null, true)
36+
37+ private val profilesKey = stringPreferencesKey("profiles_json")
38+
39+ /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
40+ * DataStore emission. The mutex serializes concurrent callers so a second collector waits
41+ * for the migration's writes to land (rather than skipping ahead and reading empty secure
42+ * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
43+ private val migrationMutex = Mutex()
44+ @Volatile private var migrationDone = false
45+
46+ private val securePrefs: SharedPreferences? by lazy {
47+ val ctx = appContext ?: return@lazy null
48+ runCatching {
49+ val masterKey = MasterKey.Builder(ctx)
50+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
51+ .build()
52+ EncryptedSharedPreferences.create(
53+ ctx,
54+ "server_secrets",
55+ masterKey,
56+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
57+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
58+ )
59+ }.getOrElse {
60+ Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
61+ null
62+ }
63+ }
64+
65+ /** True when EncryptedSharedPreferences could not be initialized. */
66+ open val securePrefsUnavailable: Boolean get() = securePrefs == null
67+
68+ /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
69+ * can still connect — but passwords won't survive an app reinstall. */
70+ private val fallbackPrefs: SharedPreferences by lazy {
71+ appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
72+ }
73+
74+ private fun prefsForPasswords(): SharedPreferences =
75+ securePrefs ?: fallbackPrefs
76+
77+ /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
78+ * then clear the fallback. Runs at most once per process — subsequent calls
79+ * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
80+ private suspend fun migrateFallbackPasswords() {
81+ if (migrationDone) return
82+ migrationMutex.withLock {
83+ // Re-check inside the lock: a concurrent caller may have completed the migration
84+ // while we were waiting. Serializing here (rather than a bare compareAndSet that
85+ // marks done before doing the work) ensures a second collector doesn't read the
86+ // secure store before the first caller's write has populated it.
87+ if (migrationDone) return
88+ val secure = securePrefs
89+ if (secure == null) {
90+ // No secure store to migrate into; nothing to do, and no point retrying.
91+ migrationDone = true
92+ return
93+ }
94+ withContext(Dispatchers.IO) {
95+ val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
96+ if (fallbackKeys.isNotEmpty()) {
97+ // Write passwords to the secure store FIRST, then remove them from the
98+ // plaintext fallback. SharedPreferences.apply() persists to disk
99+ // asynchronously, so removing from fallback before the secure batch lands
100+ // could lose the password if the process dies between the two writes.
101+ // Writing secure first makes the worst case a harmless duplicate (both
102+ // stores hold it; the migration is idempotent) rather than data loss.
103+ secure.edit().apply {
104+ for (key in fallbackKeys) {
105+ val pw = fallbackPrefs.getString(key, null) ?: continue
106+ putString(key, pw)
107+ }
108+ }.apply()
109+ fallbackPrefs.edit().apply {
110+ for (key in fallbackKeys) remove(key)
111+ }.apply()
112+ }
113+ }
114+ // Only mark done once the writes are in the secure store's in-memory cache (apply()
115+ // updates it synchronously), so the next collector past the mutex reads real values.
116+ migrationDone = true
117+ }
118+ }
119+
120+ /** Stored shape on DataStore (everything except the secret password). */
121+ @Serializable
122+ private data class StoredProfile(
123+ val id: String,
124+ val label: String,
125+ val baseUrl: String,
126+ val username: String? = null,
127+ val lastUsed: Long = 0,
128+ )
129+
130+ open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
131+ // Migrate any orphaned plaintext passwords to secure prefs on first load.
132+ migrateFallbackPasswords()
133+ val json = prefs[profilesKey] ?: return@map emptyList()
134+ val stored = runCatching {
135+ OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
136+ }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
137+ .getOrDefault(emptyList())
138+ .sortedByDescending { it.lastUsed }
139+ // Batch all password lookups into a single IO dispatch instead of one
140+ // withContext round-trip per profile (N dispatches → 1). DataStore already
141+ // runs this map block on IO, but each passwordFor() call still pays the
142+ // suspend + redispatch overhead.
143+ val pwPrefs = prefsForPasswords()
144+ stored.map { it.toProfile(pwPrefs) }
145+ }
146+ // The .map above runs in the *collector's* context, and every consumer collects on
147+ // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
148+ // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
149+ ?.flowOn(Dispatchers.IO)
150+ ?: kotlinx.coroutines.flow.flowOf(emptyList())
151+
152+ private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
153+ val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
154+ return ServerProfile(
155+ id = id,
156+ label = label,
157+ baseUrl = baseUrl,
158+ username = username,
159+ password = pw,
160+ lastUsed = lastUsed,
161+ )
162+ }
163+
164+ open suspend fun resolve(profile: ServerProfile): ServerProfile {
165+ val pw = if (profile.hasAuth) {
166+ withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
167+ } else {
168+ null
169+ }
170+ return profile.copy(password = pw)
171+ }
172+
173+ open suspend fun save(profile: ServerProfile) {
174+ // Write the password first so that if the DataStore write fails, we're left
175+ // with an orphaned password (harmless, cleaned up on next save/delete) rather
176+ // than a profile with no password (which would break authentication).
177+ withContext(Dispatchers.IO) {
178+ prefsForPasswords().edit().apply {
179+ val pw = profile.password
180+ if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
181+ putString(passwordKey(profile.id), pw)
182+ } else {
183+ remove(passwordKey(profile.id))
184+ }
185+ }.apply()
186+ }
187+ appContext!!.dataStore.edit { prefs ->
188+ val current = prefs[profilesKey]?.let {
189+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
190+ .getOrDefault(emptyList())
191+ } ?: emptyList()
192+ // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
193+ // couldn't read the existing value due to a DataStore timeout), preserve
194+ // the existing nonzero lastUsed so saving an edit doesn't reset the
195+ // profile's sort position in the server list.
196+ val preservedLastUsed = if (profile.lastUsed == 0L) {
197+ current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
198+ } else {
199+ profile.lastUsed
200+ }
201+ val stored = StoredProfile(
202+ id = profile.id,
203+ label = profile.label,
204+ baseUrl = profile.baseUrl,
205+ username = profile.username?.takeIf { it.isNotBlank() },
206+ lastUsed = preservedLastUsed,
207+ )
208+ val updated = current.filterNot { it.id == profile.id } + stored
209+ prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
210+ }
211+ }
212+
213+ open suspend fun delete(id: String) {
214+ // Delete the profile from DataStore first, then the password. If the process
215+ // dies between the two, an orphaned password remains (harmless, cleaned up on
216+ // next save/delete) rather than a profile with no password.
217+ appContext!!.dataStore.edit { prefs ->
218+ val current = prefs[profilesKey]?.let {
219+ runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
220+ .getOrDefault(emptyList())
221+ } ?: emptyList()
222+ prefs[profilesKey] = OpencodeJson.encodeToString(
223+ ListSerializer(StoredProfile.serializer()),
224+ current.filterNot { it.id == id },
225+ )
226+ }
227+ // Clean up the password in both secure and fallback prefs so no orphaned
228+ // plaintext password lingers if secure prefs were temporarily unavailable.
229+ withContext(Dispatchers.IO) {
230+ val key = passwordKey(id)
231+ securePrefs?.edit()?.remove(key)?.apply()
232+ fallbackPrefs.edit().remove(key).apply()
233+ }
234+ }
235+
236+ private fun passwordKey(id: String) = "pw_$id"
237+}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktnew file+460
@@ -0,0 +1,460 @@
1+package soy.iko.opencode.data.repo
2+
3+import soy.iko.opencode.data.model.BusEvent
4+import soy.iko.opencode.data.model.MessageInfo
5+import soy.iko.opencode.data.model.MessagePartRemoved
6+import soy.iko.opencode.data.model.MessagePartUpdated
7+import soy.iko.opencode.data.model.MessageRemoved
8+import soy.iko.opencode.data.model.MessageUpdated
9+import soy.iko.opencode.data.model.MessageWithParts
10+import soy.iko.opencode.data.model.ModelRef
11+import soy.iko.opencode.data.model.Part
12+import soy.iko.opencode.data.model.SessionError
13+import soy.iko.opencode.data.model.SessionIdle
14+import soy.iko.opencode.data.model.UnknownMessage
15+import soy.iko.opencode.data.network.EventStreamClient
16+import soy.iko.opencode.data.network.NetworkConfig
17+import soy.iko.opencode.data.network.OpencodeApiClient
18+import soy.iko.opencode.util.runCatchingCancellable
19+import soy.iko.opencode.util.safeExceptionSummary
20+import android.util.Log
21+import kotlinx.coroutines.Dispatchers
22+import kotlinx.coroutines.channels.Channel
23+import kotlinx.coroutines.flow.Flow
24+import kotlinx.coroutines.flow.channelFlow
25+import kotlinx.coroutines.flow.consumeAsFlow
26+import kotlinx.coroutines.flow.conflate
27+import kotlinx.coroutines.flow.flowOn
28+import kotlinx.coroutines.launch
29+import kotlinx.coroutines.sync.Mutex
30+import kotlinx.coroutines.sync.withLock
31+
32+/**
33+ * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
34+ * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
35+ * each keyed by message id with parts keyed by part id (idempotent upserts).
36+ */
37+open class SessionRepository(
38+ private val api: OpencodeApiClient,
39+ private val eventStream: EventStreamClient,
40+) {
41+ open suspend fun listSessions() = api.listSessions()
42+ open suspend fun createSession(title: String? = null) = api.createSession(title)
43+ open suspend fun deleteSession(id: String) = api.deleteSession(id)
44+ open suspend fun abort(sessionId: String) = api.abort(sessionId)
45+
46+ open suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
47+ api.sendPrompt(sessionId, text, model, agent)
48+
49+ open suspend fun runCommand(
50+ sessionId: String,
51+ command: String,
52+ arguments: String = "",
53+ agent: String? = null,
54+ ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
55+
56+ /**
57+ * A live, ordered view of [sessionId]'s messages. Begins collecting the event
58+ * stream before the initial REST load so no streamed part is missed, then reduces
59+ * events into the in-memory state. The flow is conflated so a fast burst of
60+ * streaming tokens (each publishing a fresh snapshot) never back-pressures the
61+ * event reducer — a slow collector simply sees the most recent snapshot.
62+ */
63+ open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
64+ val store = MessageStore()
65+ val lock = Mutex()
66+ // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
67+ // single snapshot+send per demand cycle. Previously every changed event called
68+ // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
69+ // those copies were immediately discarded by the outer .conflate() during fast
70+ // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
71+ // drain loop means N events in a burst allocate ~1 snapshot, not N.
72+ val dirty = Channel<Unit>(Channel.CONFLATED)
73+
74+ // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
75+ // collapses repeated marks into a single pending signal, so a tight burst of
76+ // events doesn't even allocate N marks.
77+ fun publish() { dirty.trySend(Unit) }
78+
79+ // Subscribe to events first so we don't miss early deltas during the initial load.
80+ launch {
81+ eventStream.events.collect { event ->
82+ if (lock.withLock { store.reduce(sessionId, event) }) publish()
83+ }
84+ }
85+
86+ // Seed generation counter, shared between the initial load and the reconnect
87+ // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
88+ // initial load) clobbering a newer seed. Only mutated/read under [lock].
89+ var seedGeneration = 0
90+
91+ // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
92+ // is performed *outside* the lock so events arriving via SSE during the fetch are
93+ // not blocked. The seed itself is under the lock; a seed generation counter ensures
94+ // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
95+ launch {
96+ // Re-seed from REST when the SSE stream reconnects after a drop. The
97+ // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
98+ // the first successful connection so a subsequent reconnect triggers the
99+ // re-seed. Resetting it on disconnect would make the condition always false
100+ // after the first cycle, turning the re-seed into dead code.
101+ //
102+ // A seed generation counter guards against a stale re-seed clobbering a
103+ // newer one: if two reconnects fire in quick succession, the older fetch
104+ // (which returns later) is discarded because its generation no longer matches.
105+ var hasConnectedBefore = false
106+ eventStream.state.collect { state ->
107+ if (state == EventStreamClient.ConnectionState.Connected) {
108+ if (hasConnectedBefore) {
109+ // Pivot before the fetch: parts already in memory predate this
110+ // snapshot and must yield to it, while parts that stream in during
111+ // the fetch (added to streamedSincePivot via reduce) outrank it.
112+ // Bump the generation under the same lock so the compare in the
113+ // initial seed sees a consistent value.
114+ val generation = lock.withLock {
115+ store.beginReseed()
116+ ++seedGeneration
117+ }
118+ val fresh = runCatchingCancellable { api.listMessages(sessionId) }
119+ .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
120+ .getOrDefault(emptyList())
121+ var seedChanged = false
122+ lock.withLock {
123+ if (generation == seedGeneration) {
124+ // Merge without pruning: SSE events arriving during the
125+ // REST fetch may have added messages not yet indexed by
126+ // the REST endpoint. Pruning would silently delete them.
127+ // Stale messages from deletions during the disconnect
128+ // gap are eventually removed via MessageRemoved events
129+ // or on the next app restart.
130+ seedChanged = store.seed(fresh, prune = false)
131+ }
132+ }
133+ if (seedChanged) publish()
134+ }
135+ hasConnectedBefore = true
136+ }
137+ }
138+ }
139+
140+ val initial = runCatchingCancellable { api.listMessages(sessionId) }
141+ .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
142+ .getOrDefault(emptyList())
143+ // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
144+ // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
145+ // returns after one has applied, seeding this older REST data would clobber the newer
146+ // state — reverting a completed run to "running", regressing cost/token/part state —
147+ // until the next SSE event or reconnect healed it.
148+ val seeded = lock.withLock {
149+ if (seedGeneration == 0) {
150+ store.seed(initial)
151+ true
152+ } else false
153+ }
154+ if (seeded) publish()
155+
156+ // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
157+ // taken under the lock so a reader never sees a half-reduced state; send() runs
158+ // outside the lock so a slow downstream collector can't stall the drain. This
159+ // loop also keeps the flow alive until the collector cancels; the launched jobs
160+ // above are children of this scope and are torn down with it.
161+ dirty.consumeAsFlow().collect {
162+ val snapshot = lock.withLock { store.snapshot() }
163+ send(snapshot)
164+ }
165+ // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
166+ // snapshot copy — on Dispatchers.Default rather than the collector's context. The
167+ // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
168+ // this every streamed token's reduce()/snapshot() would execute on the UI thread
169+ // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
170+ // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
171+ }.flowOn(Dispatchers.Default).conflate()
172+
173+ companion object {
174+ /** Convenience: is this event a run-completion signal for [sessionId]?
175+ * A null sessionID is NOT treated as a wildcard — an idle event with no
176+ * session id must not reset the running state of every open chat. */
177+ fun isIdle(event: BusEvent, sessionId: String): Boolean =
178+ event is SessionIdle && event.properties.sessionID == sessionId
179+
180+ fun isError(event: BusEvent, sessionId: String): Boolean =
181+ event is SessionError && event.properties.sessionID == sessionId
182+ }
183+}
184+
185+/**
186+ * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
187+ * Exposed as `internal` so the reducer logic can be unit-tested directly.
188+ */
189+internal class MessageStore {
190+ // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
191+ // insertion-ordered.
192+ private val messages = LinkedHashMap<String, Holder>()
193+
194+ /**
195+ * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
196+ * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
197+ * [MessageWithParts] view is materialized lazily and cached until the next mutation — so a
198+ * burst of streamed token updates rebuilds a message's part list at most once per published
199+ * [snapshot], not once per token. The cached view is also reference-stable across snapshots
200+ * while unchanged, so Compose skips recomposing bubbles that didn't change.
201+ */
202+ private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
203+ private var cached: MessageWithParts? = null
204+ fun view(): MessageWithParts =
205+ cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
206+ /** Adopt an existing immutable instance as the cache — avoids an immediate rebuild and
207+ * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
208+ fun adopt(m: MessageWithParts) { cached = m }
209+ fun invalidate() { cached = null }
210+ }
211+
212+ /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
213+ private fun holderOf(m: MessageWithParts): Holder {
214+ val parts = LinkedHashMap<String, Part>(m.parts.size)
215+ for (p in m.parts) parts[p.id] = p
216+ return Holder(m.info, parts).also { it.adopt(m) }
217+ }
218+
219+ /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
220+ * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
221+ * so they're newer than the snapshot and must win over it; every other part is taken
222+ * from the authoritative REST snapshot. Without this distinction the merge kept every
223+ * overlapping in-memory part, so a part that changed server-side during the disconnect
224+ * (e.g. a tool going running -> completed) stayed stale until app restart. */
225+ private val streamedSincePivot = mutableSetOf<String>()
226+
227+ /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
228+ * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
229+ * on a reconnect re-seed these are the messages whose info changed *during* the REST
230+ * fetch, so the in-memory info is newer than the snapshot and must win; every other
231+ * message's info is taken from the authoritative REST snapshot. Without this the merge
232+ * kept the in-memory info, so a run that finished server-side during the disconnect
233+ * (e.g. cost/token totals, completion time) stayed stale until app restart. */
234+ private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
235+
236+ /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
237+ * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
238+ fun beginReseed() {
239+ streamedSincePivot.clear()
240+ messageInfoUpdatedSincePivot.clear()
241+ }
242+
243+ /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
244+ private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
245+
246+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
247+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
248+
249+ fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
250+
251+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
252+ var changed = false
253+ // On re-seed (after SSE reconnect), remove messages that are no longer in the
254+ // server snapshot (e.g. deleted during the disconnection gap). This keeps the
255+ // in-memory state in sync with the authoritative REST snapshot rather than
256+ // accumulating stale messages forever. Skipped on the initial seed to avoid
257+ // racing with just-arrived SSE events whose messages may not yet be in REST.
258+ if (prune) {
259+ val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
260+ val before = messages.size
261+ messages.keys.retainAll(incomingIds)
262+ if (messages.size != before) changed = true
263+ }
264+
265+ for (m in initial) {
266+ val existing = messages[m.info.id]
267+ if (existing == null) {
268+ messages[m.info.id] = holderOf(m)
269+ changed = true
270+ } else {
271+ // A part streamed in between subscribe and this initial load may already
272+ // have populated this message (see observeMessages). Merge instead of
273+ // overwriting so that newer streamed part isn't discarded: take the
274+ // snapshot's part order, swap in the streamed version where ids overlap,
275+ // append any streamed-only parts, and adopt the authoritative REST info.
276+ // existing.parts is already keyed by part id (insertion-ordered).
277+ val streamedById = existing.parts
278+ val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
279+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
280+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
281+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
282+ // discarding an in-memory part that went stale during a disconnect.
283+ val ordered = m.parts.map { p ->
284+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
285+ }.toMutableList()
286+ for (p in existing.parts.values) {
287+ if (p.id !in snapshotIds) ordered.add(p)
288+ }
289+ // Adopt the authoritative REST info unless the in-memory info was itself
290+ // updated live since the pivot (i.e. during this fetch), in which case it's
291+ // newer than the snapshot. Otherwise the REST snapshot wins, discarding
292+ // in-memory info that went stale during a disconnect.
293+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
294+ val merged = MessageWithParts(info = info, parts = ordered)
295+ if (merged != existing.view()) {
296+ messages[m.info.id] = holderOf(merged)
297+ changed = true
298+ }
299+ }
300+ }
301+ // A part that streamed in *before* this initial load (observeMessages subscribes to
302+ // SSE before fetching) inserts its message at the front of the insertion-ordered map,
303+ // and the merge above keeps that position — pinning a running message above the older
304+ // history that seed() inserts afterward. Restore chronological order by creation time
305+ // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
306+ // removes from the map's front, which before reordering is the newest streamed message.
307+ if (reorderByTime()) changed = true
308+ if (evictOldMessages()) changed = true
309+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
310+ // that stream in *after* this seed can outrank the next reconnect's snapshot.
311+ streamedSincePivot.clear()
312+ messageInfoUpdatedSincePivot.clear()
313+ return changed
314+ }
315+
316+ /** Reorder the message map by message creation time (stable). Ties and entries with no
317+ * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
318+ * relative order, so such a message stays last. Preserves each entry's map key so the
319+ * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
320+ private fun reorderByTime(): Boolean {
321+ val entries = messages.entries.map { it.key to it.value }
322+ val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
323+ // Detect a reordering by comparing keys positionally, instead of allocating two more
324+ // N-sized key lists just to compare them.
325+ var changed = false
326+ for (i in sorted.indices) {
327+ if (sorted[i].first != entries[i].first) { changed = true; break }
328+ }
329+ if (!changed) return false
330+ messages.clear()
331+ for ((k, v) in sorted) messages[k] = v
332+ return true
333+ }
334+
335+ /** Returns true if the state changed (and a new snapshot should be published). */
336+ fun reduce(sessionId: String, event: BusEvent): Boolean {
337+ return when (event) {
338+ is MessageUpdated -> handleMessageUpdated(sessionId, event)
339+
340+ is MessagePartUpdated -> {
341+ val part = event.properties.part
342+ val messageId = part.messageID ?: event.properties.messageID
343+ val partSession = part.sessionID ?: event.properties.sessionID
344+ when {
345+ messageId == null -> false
346+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
347+ // No session id anywhere on the event: a null session is NOT a wildcard
348+ // (mirroring isIdle/isError). Every open session's store sees the shared
349+ // event stream, so accepting it unconditionally would leak the part into
350+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
351+ // only if this store already holds the parent message — MessageUpdated,
352+ // which always carries a session id, will have created it here first.
353+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
354+ }
355+ }
356+
357+ is MessagePartRemoved -> handlePartRemoved(sessionId, event)
358+
359+ is MessageRemoved -> handleMessageRemoved(sessionId, event)
360+
361+ else -> false
362+ }
363+ }
364+
365+ private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
366+ val info = event.properties.info
367+ if (info.sessionID != sessionId) return false
368+ // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
369+ // field) would collide with other such messages in the map. Generate a unique synthetic
370+ // key so each unknown message gets its own entry instead of overwriting others.
371+ val key = if (info.id.isEmpty() && info is UnknownMessage) {
372+ "unknown-${unknownCounter.incrementAndGet()}"
373+ } else {
374+ info.id
375+ }
376+ val existing = messages[key]
377+ // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
378+ // redundant snapshot (mirrors upsertPart's identical-part guard).
379+ if (existing != null && existing.info == info) return false
380+ if (existing == null) {
381+ messages[key] = Holder(info, LinkedHashMap())
382+ } else {
383+ existing.info = info
384+ existing.invalidate()
385+ }
386+ // Record that this message's info streamed in live, so a re-seed after a reconnect knows
387+ // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
388+ messageInfoUpdatedSincePivot.add(key)
389+ if (existing == null) evictOldMessages()
390+ return true
391+ }
392+
393+ /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
394+ private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
395+ eventSession == null || eventSession == sessionId
396+
397+ private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
398+ val messageId = event.properties.messageID ?: return false
399+ val partId = event.properties.partID ?: return false
400+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
401+ return removePart(messageId, partId)
402+ }
403+
404+ private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
405+ val id = event.properties.messageID ?: return false
406+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
407+ val removed = messages.remove(id) ?: return false
408+ messageInfoUpdatedSincePivot.remove(id)
409+ removed.parts.keys.forEach { streamedSincePivot.remove(it) }
410+ return true
411+ }
412+
413+ private fun upsertPart(messageId: String, part: Part): Boolean {
414+ val current = messages[messageId]
415+ if (current != null) {
416+ // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
417+ // on every streamed token. A LinkedHashMap re-put keeps the part's original
418+ // position (replace-in-place); a new id is appended — matching the old list order.
419+ if (current.parts[part.id] == part) return false
420+ current.parts[part.id] = part
421+ current.invalidate()
422+ } else {
423+ val parts = LinkedHashMap<String, Part>()
424+ parts[part.id] = part
425+ messages[messageId] = Holder(
426+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
427+ parts = parts,
428+ )
429+ }
430+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
431+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
432+ streamedSincePivot.add(part.id)
433+ if (current == null) evictOldMessages()
434+ return true
435+ }
436+
437+ private fun removePart(messageId: String, partId: String): Boolean {
438+ val current = messages[messageId] ?: return false
439+ if (current.parts.remove(partId) == null) return false
440+ current.invalidate()
441+ return true
442+ }
443+
444+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
445+ * Returns true if any messages were evicted. */
446+ private fun evictOldMessages(): Boolean {
447+ var evicted = false
448+ while (messages.size > maxMessages) {
449+ val oldestKey = messages.keys.iterator().next()
450+ val removed = messages.remove(oldestKey)
451+ // Prune the evicted message's pivot bookkeeping so these sets don't grow
452+ // unbounded over a long-running session that never reconnects (they're only
453+ // otherwise cleared on reseed/seed).
454+ messageInfoUpdatedSincePivot.remove(oldestKey)
455+ removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
456+ evicted = true
457+ }
458+ return evicted
459+ }
460+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+50
@@ -0,0 +1,50 @@
1+package soy.iko.opencode.data.repo
2+
3+import android.content.Context
4+import androidx.datastore.preferences.core.booleanPreferencesKey
5+import androidx.datastore.preferences.core.edit
6+import androidx.datastore.preferences.core.stringPreferencesKey
7+import androidx.datastore.preferences.preferencesDataStore
8+import kotlinx.coroutines.flow.Flow
9+import kotlinx.coroutines.flow.map
10+
11+enum class ThemeMode { SYSTEM, LIGHT, DARK }
12+
13+private val Context.settingsDataStore by preferencesDataStore(name = "settings")
14+
15+/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
16+class SettingsStore(context: Context) {
17+
18+ private val appContext = context.applicationContext
19+ private val themeKey = stringPreferencesKey("theme_mode")
20+ private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
21+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
22+
23+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
24+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
25+ .getOrDefault(ThemeMode.SYSTEM)
26+ }
27+
28+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
29+ prefs[dynamicColorKey] ?: true
30+ }
31+
32+ /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
33+ * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
34+ * true to match the prior hardcoded behavior. */
35+ val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
36+ prefs[sendOnEnterKey] ?: true
37+ }
38+
39+ suspend fun setThemeMode(mode: ThemeMode) {
40+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
41+ }
42+
43+ suspend fun setDynamicColor(enabled: Boolean) {
44+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
45+ }
46+
47+ suspend fun setSendOnEnter(enabled: Boolean) {
48+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
49+ }
50+}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+643
@@ -0,0 +1,643 @@
1+package soy.iko.opencode.di
2+
3+import android.content.Context
4+import android.net.ConnectivityManager
5+import android.net.Network
6+import android.net.NetworkCapabilities
7+import android.net.NetworkRequest
8+import android.util.Log
9+import soy.iko.opencode.data.model.AssistantMessage
10+import soy.iko.opencode.data.model.BusEvent
11+import soy.iko.opencode.data.model.MessagePartUpdated
12+import soy.iko.opencode.data.model.MessageUpdated
13+import soy.iko.opencode.data.model.StepFinishPart
14+import soy.iko.opencode.data.model.ServerProfile
15+import soy.iko.opencode.data.model.SessionIdle
16+import soy.iko.opencode.data.repo.DraftStore
17+import soy.iko.opencode.data.repo.ErrorKind
18+import soy.iko.opencode.data.repo.ProfileStore
19+import soy.iko.opencode.data.repo.SettingsStore
20+import soy.iko.opencode.data.repo.classifyError
21+import soy.iko.opencode.data.repo.responseStatusCode
22+import soy.iko.opencode.data.network.HttpClientFactory
23+import soy.iko.opencode.data.network.NetworkConfig
24+import soy.iko.opencode.data.network.OpencodeApiClient
25+import soy.iko.opencode.notification.NotificationChannels
26+import soy.iko.opencode.util.safeExceptionSummary
27+import soy.iko.opencode.notification.SessionNotifications
28+import soy.iko.opencode.R
29+import soy.iko.opencode.util.runCatchingCancellable
30+import kotlinx.coroutines.CoroutineScope
31+import kotlinx.coroutines.Dispatchers
32+import kotlinx.coroutines.ExperimentalCoroutinesApi
33+import kotlinx.coroutines.SupervisorJob
34+import kotlinx.coroutines.cancel
35+import kotlinx.coroutines.delay
36+import kotlinx.coroutines.flow.MutableStateFlow
37+import kotlinx.coroutines.flow.StateFlow
38+import kotlinx.coroutines.flow.asStateFlow
39+import kotlinx.coroutines.flow.emptyFlow
40+import kotlinx.coroutines.flow.first
41+import kotlinx.coroutines.Job
42+import kotlinx.coroutines.flow.flatMapLatest
43+import kotlinx.coroutines.flow.update
44+import kotlinx.coroutines.isActive
45+import kotlinx.coroutines.launch
46+import kotlinx.coroutines.sync.Mutex
47+import kotlinx.coroutines.sync.withLock
48+import kotlinx.coroutines.withTimeoutOrNull
49+import java.util.concurrent.atomic.AtomicBoolean
50+
51+/**
52+ * Result of probing a server URL to check connectivity and whether authentication
53+ * is required, without persisting a profile or opening a long-lived connection.
54+ */
55+sealed class ProbeResult {
56+ /** Server is reachable and accepts unauthenticated requests. */
57+ object Reachable : ProbeResult()
58+
59+ /** Server is reachable but rejected the probe with an authentication error. */
60+ object NeedsAuth : ProbeResult()
61+
62+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
63+ data class Unreachable(val error: String) : ProbeResult()
64+}
65+
66+/**
67+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
68+ * process-wide singletons and the currently active [OpencodeConnection].
69+ */
70+open class AppContainer private constructor(
71+ private val appContext: Context?,
72+ private val skipInit: Boolean,
73+ @Suppress("unused") private val testMode: Boolean,
74+) {
75+ constructor(context: Context) : this(context.applicationContext, false, false)
76+ protected constructor() : this(null, true, true)
77+
78+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
79+ open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
80+ open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
81+ open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
82+
83+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
84+ open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
85+
86+ /** True while a background auto-reconnect to the most-recent server is in flight. */
87+ private val _reconnecting = MutableStateFlow(false)
88+ open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
89+
90+ /**
91+ * Set once a startup auto-reconnect succeeds. The server list collects this and
92+ * (once) navigates straight to the session list so a returning user skips it.
93+ * [consumeAutoConnect] guards against re-firing on screen re-entry.
94+ */
95+ private val _autoConnectDone = MutableStateFlow(false)
96+ open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
97+ private val autoConnectConsumed = AtomicBoolean(false)
98+ open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
99+
100+ /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
101+ private val _pendingShare = MutableStateFlow<String?>(null)
102+ open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
103+ open fun setPendingShare(text: String) { _pendingShare.value = text }
104+ open fun consumePendingShare(): String? {
105+ val current = _pendingShare.value ?: return null
106+ return if (_pendingShare.compareAndSet(current, null)) current else null
107+ }
108+
109+ /**
110+ * A session id to open from an external trigger (a notification tap or a deep link).
111+ * The nav host consumes it once a connection is active.
112+ */
113+ private val _pendingOpenSession = MutableStateFlow<String?>(null)
114+ open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
115+ open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
116+ open fun consumePendingOpenSession(): String? {
117+ val current = _pendingOpenSession.value ?: return null
118+ return if (_pendingOpenSession.compareAndSet(current, null)) current else null
119+ }
120+
121+ /**
122+ * The session the user is currently viewing (or null when not in a chat). Drives the
123+ * unread tracker: messages arriving for any *other* session mark it unread, and
124+ * opening a session clears its unread state.
125+ */
126+ private val _currentSession = MutableStateFlow<String?>(null)
127+ open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
128+
129+ /** Session ids that received activity while not being viewed, mapped to the count
130+ * of unread message events. A count (not just a presence set) lets the session
131+ * list badge show "3 unread" instead of a bare dot, so the user can tell whether
132+ * one reply or a whole burst arrived. */
133+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
134+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
135+
136+ /** True when any assistant run is actively streaming across all sessions. Drives the
137+ * disconnect confirmation on the session list (disconnecting mid-run kills it). */
138+ private val _anyRunActive = MutableStateFlow(false)
139+ open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
140+
141+ /** Whether the device currently has network connectivity. Distinct from the SSE
142+ * connection state so the UI can tell "you're offline" (device) from "server
143+ * unreachable" (credentials/host). Updated by the [networkCallback]. */
144+ private val _isOnline = MutableStateFlow(true)
145+ open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
146+
147+ open fun setCurrentSession(id: String?) {
148+ _currentSession.value = id
149+ if (id != null) {
150+ _unread.update { it - id }
151+ unreadMessageIds.remove(id)
152+ appContext?.let { SessionNotifications.cancel(it, id) }
153+ }
154+ }
155+
156+ /** Restore a session's unread badge after a failed server switch reconnects.
157+ * Preserves the prior [count] so a session badged with "5 unread" before the
158+ * switch attempt still shows 5 (not 1) after the restore. No-op for the session
159+ * the user is currently viewing, since a viewed session is never badged. */
160+ open fun restoreUnread(id: String, count: Int) {
161+ if (id != _currentSession.value) {
162+ _unread.update { it + (id to count) }
163+ }
164+ }
165+
166+ /** Resolve a localized string — view models reach resources through the container. */
167+ open fun string(id: Int, vararg formatArgs: Any): String {
168+ if (appContext == null) return ""
169+ return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
170+ }
171+
172+ /**
173+ * Convert a throwable into a user-facing message. Classifies the error by concrete
174+ * type (network, timeout, HTTP status) rather than string-matching class names, so
175+ * the message reflects what actually went wrong without leaking internal URLs/state.
176+ */
177+ open fun friendlyError(t: Throwable): String =
178+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
179+
180+ /**
181+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
182+ * that aren't operating on the active connection (e.g. [probeServer]) can still
183+ * produce a user-facing message with the right server address.
184+ */
185+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
186+ when (classifyError(t)) {
187+ ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
188+ string(R.string.error_not_reachable, baseUrl)
189+ ErrorKind.TIMEOUT -> string(R.string.error_timeout)
190+ ErrorKind.SERVER -> string(R.string.error_server)
191+ // Show only the HTTP status (e.g. 401, 404) — never the request URL,
192+ // which a ClientRequestException carries in its message and which we
193+ // promised not to leak (it can include auth or internal paths).
194+ ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
195+ ?: string(R.string.error_generic)
196+ ErrorKind.UNKNOWN -> string(R.string.error_generic)
197+ }
198+
199+ init {
200+ if (!skipInit) {
201+ NotificationChannels.create(appContext!!)
202+ observeMessageActivity()
203+ appScope.launch { autoConnect() }
204+ }
205+ }
206+
207+ /** Release resources held for the process lifetime (network callback, app scope). */
208+ open fun shutdown() {
209+ // Cancel the app scope first so any coroutine holding the connection mutex
210+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
211+ // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
212+ // that can't be cancelled until after the runBlocking completes.
213+ appScope.cancel()
214+ // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
215+ // 2 seconds is generous: appScope.cancel() already triggered cancellation of
216+ // any coroutine holding the mutex; we're just waiting for it to unwind.
217+ kotlinx.coroutines.runBlocking {
218+ withTimeoutOrNull(2_000) {
219+ connectionMutex.withLock {
220+ runCatching { _activeConnection.value?.close() }
221+ _activeConnection.value = null
222+ }
223+ }
224+ // If the timeout fired, force-clear the connection so it isn't left dangling.
225+ _activeConnection.value = null
226+ }
227+ val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
228+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
229+ if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
230+ if (!skipInit) draftStore.shutdown()
231+ }
232+
233+ /**
234+ * Watch the SSE bus for new message activity and badge any session that isn't
235+ * currently open. This powers the unread dot on the session list so the user can
236+ * tell which conversations got a reply while they were elsewhere.
237+ *
238+ * Uses [flatMapLatest] so a server switch (which replaces the active connection)
239+ * re-subscribes to the new event stream and drops the old one.
240+ */
241+ @OptIn(ExperimentalCoroutinesApi::class)
242+ private fun observeMessageActivity() {
243+ appScope.launch {
244+ // Retry the observer if it fails for any reason (transient flow exception,
245+ // cancellation due to scope issues). Without this, a single failure would
246+ // permanently disable unread tracking and completion notifications.
247+ while (isActive) {
248+ runCatchingCancellable {
249+ activeConnection
250+ .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
251+ .collect { event ->
252+ // SessionIdle is not "message activity" (don't badge as unread)
253+ // but signals a run finished — fire a completion notification if the
254+ // session isn't currently being viewed and was actively streaming.
255+ if (event is SessionIdle) {
256+ val idleSid = event.properties.sessionID ?: return@collect
257+ // Clear the run state unconditionally — even for the session on
258+ // screen. Gating the removal on `idleSid != currentSession` (as
259+ // this once did) leaks the id in activeRuns and pins anyRunActive
260+ // true forever whenever a run finishes while being viewed. Only
261+ // the completion *notification* is suppressed for the viewed one.
262+ val wasRunning = synchronized(activeRuns) {
263+ activeRuns.remove(idleSid).also { removed ->
264+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
265+ }
266+ }
267+ if (wasRunning && idleSid != _currentSession.value) {
268+ // Fire this off the collector's coroutine: notifySessionCompleted
269+ // does a network listSessions() to resolve the title, and blocking
270+ // the shared-event collector here would stall the SharedFlow,
271+ // overflowing its DROP_OLDEST buffer and silently dropping live
272+ // parts/updates for every other subscriber (e.g. the message reducer).
273+ appScope.launch { notifySessionCompleted(idleSid) }
274+ }
275+ return@collect
276+ }
277+ val sid = sessionOf(event) ?: return@collect
278+ // Re-read currentSession here, right before mutating the badge state:
279+ // the user may have opened this session between when this event was
280+ // enqueued and now (setCurrentSession clears its badge and dedup set).
281+ // A stale earlier check would race a badge — and a dedup entry — back
282+ // in for the session that's actually on screen.
283+ if (sid != _currentSession.value) {
284+ // Increment the unread count once per distinct *message*, not per
285+ // event: a single reply emits many message.updated / message.part.
286+ // updated events (one per streamed token, plus cost/token refreshes),
287+ // which would otherwise inflate the badge into the dozens/hundreds.
288+ val messageId = messageIdOfEvent(event)
289+ val counted = unreadMessageIds.getOrPut(sid) {
290+ java.util.Collections.synchronizedSet(mutableSetOf())
291+ }
292+ if (messageId == null || counted.add(messageId)) {
293+ _unread.update { current ->
294+ // Guard again inside the atomic update lambda so a retry
295+ // (or an open that landed just now) can't reintroduce the
296+ // badge for the session on screen.
297+ if (sid == _currentSession.value) current
298+ else current + (sid to (current[sid] ?: 0) + 1)
299+ }
300+ }
301+ }
302+ // Track sessions actively streaming so we know which idle events
303+ // represent a finished run worth notifying about.
304+ if (isLiveRunActivity(event)) {
305+ synchronized(activeRuns) {
306+ if (activeRuns.size >= activeRunsLimit) {
307+ activeRuns.remove(activeRuns.iterator().next())
308+ }
309+ activeRuns.add(sid)
310+ }
311+ _anyRunActive.value = true
312+ }
313+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
314+ if (!isActive) break
315+ delay(NetworkConfig.observerRetryDelayMs)
316+ }
317+ }
318+ }
319+
320+ /** True if [event] is live streaming activity worth tracking as an active run. A
321+ * trailing message.updated that lands *after* the run finished (final cost/token totals,
322+ * carrying a completion time) is NOT live streaming: with no further SessionIdle to
323+ * follow it, re-adding the session would pin anyRunActive true indefinitely. */
324+ private fun isLiveRunActivity(event: BusEvent): Boolean {
325+ if (event !is MessagePartUpdated && event !is MessageUpdated) return false
326+ val info = (event as? MessageUpdated)?.properties?.info
327+ if (info is AssistantMessage && info.isComplete) return false
328+ // A step-finish part is the trailing completion marker for a run (it carries the
329+ // final cost/token totals) and arrives after the last stream delta. Like a completed
330+ // message.updated, treating it as live would re-add the session to activeRuns with no
331+ // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
332+ // a part is replayed/reordered after the run already went idle.
333+ val part = (event as? MessagePartUpdated)?.properties?.part
334+ if (part is StepFinishPart) return false
335+ return true
336+ }
337+
338+ /** Session ids currently streaming an assistant run (best-effort, in-process). */
339+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
340+
341+ /** Per non-viewed session, the set of message ids already reflected in its unread
342+ * count. De-duplicates the many streaming events that share one message id so the
343+ * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
344+ private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
345+
346+ /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
347+ private val activeRunsLimit = NetworkConfig.activeRunsLimit
348+
349+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
350+ private val connectionMutex = Mutex()
351+
352+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
353+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
354+ private companion object {
355+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
356+ }
357+
358+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
359+ private suspend fun notifySessionCompleted(sessionId: String) {
360+ val conn = activeConnection.value ?: return
361+ val title = runCatchingCancellable {
362+ conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
363+ }.getOrNull() ?: sessionId
364+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
365+ }
366+
367+ /** Extract the session id an event pertains to, for message-activity events. */
368+ private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
369+
370+ /**
371+ * On cold start, transparently reconnect to the most recently used server so a
372+ * returning user lands in their session list instead of the empty server screen.
373+ */
374+ private suspend fun autoConnect() {
375+ val recent = runCatchingCancellable { profileStore.profiles.first() }
376+ .getOrDefault(emptyList())
377+ .firstOrNull() ?: return
378+ _reconnecting.value = true
379+ val ok = runCatchingCancellable {
380+ val conn = connect(recent)
381+ conn.api.ping()
382+ }.isSuccess
383+ _reconnecting.value = false
384+ if (ok) _autoConnectDone.value = true else disconnect()
385+ }
386+
387+ /**
388+ * When the device regains connectivity, nudge the active SSE stream to reconnect
389+ * immediately instead of waiting out the exponential backoff.
390+ */
391+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
392+
393+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
394+ if (appContext == null) return null
395+ val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
396+ ?: return null
397+ // Seed the online state from the current active network so the indicator is
398+ // correct on cold start rather than defaulting to "online" until a callback fires.
399+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
400+ val callback = object : ConnectivityManager.NetworkCallback() {
401+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
402+ override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
403+ }
404+ // Only match networks that actually provide internet — a capability-less request
405+ // fires onAvailable for transports that can't reach the server (and would trigger
406+ // spurious reconnects), so require NET_CAPABILITY_INTERNET.
407+ val request = NetworkRequest.Builder()
408+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
409+ .build()
410+ runCatching { cm.registerNetworkCallback(request, callback) }
411+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
412+ return callback
413+ }
414+
415+ /** Open (or re-open) a connection to [profile], replacing any current one.
416+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
417+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
418+ open suspend fun connect(profile: ServerProfile): OpencodeConnection =
419+ connectionMutex.withLock {
420+ _activeConnection.value?.close()
421+ _activeConnection.value = null
422+ activeRuns.clear()
423+ _anyRunActive.value = false
424+ _unread.value = emptyMap()
425+ unreadMessageIds.clear()
426+ val now = System.currentTimeMillis()
427+ val resolved = profileStore.resolve(profile)
428+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
429+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
430+ if (needsSave) profileStore.save(finalProfile)
431+ OpencodeConnection(finalProfile).also { _activeConnection.value = it }
432+ }
433+
434+ open suspend fun disconnect() {
435+ // Acquire the connection mutex so disconnect is serialized with connect().
436+ // Previously this was fire-and-forget (launch on appScope), which raced with
437+ // a subsequent connect(): connect() could acquire the mutex, create a new
438+ // connection, and then the pending disconnect() would close it.
439+ connectionMutex.withLock {
440+ _activeConnection.value?.close()
441+ _activeConnection.value = null
442+ // Reset run/unread state on disconnect too. With no SSE stream there's no
443+ // SessionIdle to drain activeRuns, so without this the working indicator
444+ // (anyRunActive) and unread badges would stay pinned until the next connect().
445+ activeRuns.clear()
446+ _anyRunActive.value = false
447+ _unread.value = emptyMap()
448+ unreadMessageIds.clear()
449+ }
450+ }
451+
452+ /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
453+ * ViewModel scope) so navigating away during the undo window still commits the delete
454+ * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
455+ private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
456+
457+ /**
458+ * Schedule the profile [id] for deletion after [delayMs], cancellable via
459+ * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
460+ * after the delete resolves so the caller can clear its optimistic-hide state. A
461+ * repeated schedule for the same id replaces the prior timer.
462+ */
463+ open fun scheduleProfileDelete(
464+ id: String,
465+ delayMs: Long,
466+ onDeleted: () -> Unit,
467+ onError: (Throwable) -> Unit,
468+ ) {
469+ val job = appScope.launch {
470+ delay(delayMs)
471+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
472+ // remove is the sync point with cancelProfileDelete. Losing the race (an undo
473+ // removed us first) means we skip, honouring cancel's `true`; deleting first
474+ // would let a cancel land in the gap and falsely report undo of a gone profile.
475+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
476+ val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
477+ if (!claimed) return@launch
478+ runCatchingCancellable { profileStore.delete(id) }
479+ .onSuccess { onDeleted() }
480+ .onFailure { onError(it) }
481+ }
482+ pendingProfileDeletes.put(id, job)?.cancel()
483+ }
484+
485+ /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
486+ * still pending (undo succeeded), false if the delete had already fired. */
487+ open fun cancelProfileDelete(id: String): Boolean {
488+ val job = pendingProfileDeletes.remove(id) ?: return false
489+ job.cancel()
490+ return true
491+ }
492+
493+ /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
494+ * ViewModel scope) so navigating away during the undo window still commits the delete
495+ * instead of silently cancelling it (which would leave the session deleted-in-UI but
496+ * alive on the server, reappearing on the next refresh). */
497+ private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
498+
499+ /**
500+ * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
501+ * (the Undo action). Also clears the session's stored draft on a successful delete.
502+ * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
503+ * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
504+ *
505+ * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
506+ * fires. Otherwise a server switch during the undo window would send the delete to whichever
507+ * server happens to be active at fire time: the original session would never be deleted (its
508+ * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
509+ * on the new server could be deleted instead. A disconnect closes the captured connection's
510+ * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
511+ * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
512+ * error) if there is no active connection at schedule time.
513+ */
514+ open fun scheduleSessionDelete(
515+ id: String,
516+ delayMs: Long,
517+ onDeleted: () -> Unit,
518+ onError: (Throwable) -> Unit,
519+ ) {
520+ val conn = _activeConnection.value
521+ val job = appScope.launch {
522+ delay(delayMs)
523+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
524+ // remove is the sync point with cancelSessionDelete. Losing the race (an undo
525+ // removed us first) means we skip, honouring cancel's `true`; deleting first
526+ // would let a cancel land in the gap and falsely report undo of a gone session.
527+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
528+ val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
529+ if (!claimed) return@launch
530+ if (conn == null) {
531+ onError(IllegalStateException("No active connection"))
532+ } else {
533+ runCatchingCancellable { conn.repository.deleteSession(id) }
534+ .onSuccess {
535+ draftStore.remove(id)
536+ onDeleted()
537+ }
538+ .onFailure { onError(it) }
539+ }
540+ }
541+ pendingSessionDeletes.put(id, job)?.cancel()
542+ }
543+
544+ /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
545+ * still pending (undo succeeded), false if the delete had already fired. */
546+ open fun cancelSessionDelete(id: String): Boolean {
547+ val job = pendingSessionDeletes.remove(id) ?: return false
548+ job.cancel()
549+ return true
550+ }
551+
552+ /**
553+ * Probe a server URL (without credentials) to check reachability and detect whether
554+ * the server requires authentication. Builds a short-lived HTTP client with the same
555+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
556+ * classifies the outcome:
557+ *
558+ * - 2xx → [ProbeResult.Reachable] (no auth needed)
559+ * - 401/403 → [ProbeResult.NeedsAuth]
560+ * - anything else → [ProbeResult.Unreachable] with a user-facing message
561+ *
562+ * The probe client is always closed afterwards so no resources linger. This does not
563+ * touch the active connection or the profile store.
564+ */
565+ open suspend fun probeServer(baseUrl: String): ProbeResult {
566+ return probeWithProfile(ServerProfile(
567+ id = "probe",
568+ label = "",
569+ baseUrl = baseUrl.trim(),
570+ username = null,
571+ password = null,
572+ ))
573+ }
574+
575+ /**
576+ * Probe a server URL *with* credentials to validate them. Returns true if the
577+ * server accepts the credentials (ping succeeds), false on 401/403, and throws
578+ * on other failures (unreachable, timeout) so the caller can surface a friendly
579+ * message. Mirrors [probeServer]'s short-lived-client pattern.
580+ */
581+ open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
582+ val probeProfile = ServerProfile(
583+ id = "probe-auth",
584+ label = "",
585+ baseUrl = baseUrl.trim(),
586+ username = username.trim().takeIf { it.isNotBlank() },
587+ password = password.trim().takeIf { it.isNotEmpty() },
588+ )
589+ val client = HttpClientFactory.create(probeProfile)
590+ return try {
591+ val api = OpencodeApiClient(client)
592+ api.ping()
593+ true
594+ } catch (e: Exception) {
595+ val status = responseStatusCode(e)
596+ if (status == 401 || status == 403) false else throw e
597+ } finally {
598+ runCatching { client.close() }
599+ }
600+ }
601+
602+ private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
603+ val client = HttpClientFactory.create(profile)
604+ return try {
605+ val api = OpencodeApiClient(client)
606+ api.ping()
607+ ProbeResult.Reachable
608+ } catch (e: Exception) {
609+ val status = responseStatusCode(e)
610+ if (status == 401 || status == 403) {
611+ ProbeResult.NeedsAuth
612+ } else {
613+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
614+ }
615+ } finally {
616+ runCatching { client.close() }
617+ }
618+ }
619+}
620+
621+/**
622+ * Extract the session id a message-activity event pertains to, or null for events that
623+ * don't describe message activity. This drives the unread badge: any session that
624+ * receives activity while it isn't the currently-viewed one gets badged. Extracted as
625+ * a top-level `internal` function so the rule is unit-testable without an Android
626+ * [Context] (the surrounding [AppContainer] needs one).
627+ */
628+internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
629+ is MessageUpdated -> event.properties.info.sessionID
630+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
631+ else -> null
632+}
633+
634+/**
635+ * The message id a message-activity event pertains to, used to de-duplicate the unread
636+ * badge so a reply's many streaming events count once. Null for events that carry no
637+ * message id (those fall back to being counted individually).
638+ */
639+internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
640+ is MessageUpdated -> event.properties.info.id
641+ is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
642+ else -> null
643+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+41
@@ -0,0 +1,41 @@
1+package soy.iko.opencode.di
2+
3+import soy.iko.opencode.data.model.ServerProfile
4+import soy.iko.opencode.data.network.EventStreamClient
5+import soy.iko.opencode.data.network.HttpClientFactory
6+import soy.iko.opencode.data.network.OpencodeApiClient
7+import soy.iko.opencode.data.repo.SessionRepository
8+import kotlinx.coroutines.CoroutineScope
9+import kotlinx.coroutines.Dispatchers
10+import kotlinx.coroutines.SupervisorJob
11+import kotlinx.coroutines.cancel
12+
13+/**
14+ * An active connection to one opencode server. Bundles the Ktor client, REST API,
15+ * SSE event stream, and the reducing repository, all sharing a connection-scoped
16+ * coroutine scope that is cancelled on [close].
17+ */
18+open class OpencodeConnection(val profile: ServerProfile) {
19+
20+ protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"))
21+
22+ private val scopeJob = SupervisorJob()
23+ private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
24+ private val client by lazy { HttpClientFactory.create(profile) }
25+
26+ open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
27+ open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
28+ open val repository: SessionRepository by lazy { SessionRepository(api, events) }
29+
30+ /**
31+ * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
32+ * to wind down before closing the HTTP client. Calling client.close() while the SSE
33+ * coroutine is still mid-request produces a spurious "stream error, will retry"
34+ * warning during normal teardown.
35+ */
36+ open suspend fun close() {
37+ scope.cancel()
38+ scopeJob.join()
39+ runCatching { client.close() }
40+ }
41+}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktnew file+34
@@ -0,0 +1,34 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.NotificationChannel
4+import android.app.NotificationManager
5+import android.content.Context
6+import soy.iko.opencode.R
7+
8+/**
9+ * The app's notification channels. Created once at startup (idempotent — recreating an
10+ * existing channel is a no-op). Two channels keep user control granular:
11+ *
12+ * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13+ * "agent running" indicator.
14+ * - [COMPLETED]: default importance. Used when a background session finishes a run.
15+ */
16+object NotificationChannels {
17+ const val STATUS = "run_status"
18+ const val COMPLETED = "session_completed"
19+
20+ fun create(context: Context) {
21+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
22+ nm.createNotificationChannel(
23+ NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
24+ description = context.getString(R.string.notif_channel_status_desc)
25+ setShowBadge(false)
26+ },
27+ )
28+ nm.createNotificationChannel(
29+ NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
30+ description = context.getString(R.string.notif_channel_completed_desc)
31+ },
32+ )
33+ }
34+}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktnew file+101
@@ -0,0 +1,101 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.Notification
4+import android.app.PendingIntent
5+import android.app.Service
6+import android.content.Context
7+import android.content.Intent
8+import android.content.pm.ServiceInfo
9+import android.os.Build
10+import android.os.IBinder
11+import android.util.Log
12+import androidx.core.app.NotificationCompat
13+import androidx.core.app.ServiceCompat
14+import soy.iko.opencode.MainActivity
15+import soy.iko.opencode.R
16+
17+/**
18+ * A foreground service kept alive while an opencode agent is actively running. Holding a
19+ * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
20+ * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
21+ *
22+ * The service is started when a run begins ([start]) and stopped when it goes idle
23+ * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
24+ * Started/stopped via Compose from the chat screen based on the `running` state.
25+ */
26+class RunForegroundService : Service() {
27+
28+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29+ val notification = buildNotification()
30+ // startForeground can throw ForegroundServiceStartNotAllowedException on
31+ // Android 12+ if the app is in the background when the service starts. Wrap
32+ // it so a backgrounded start (e.g. the user navigates away at the wrong
33+ // moment) doesn't crash the app — the SSE stream will just continue without
34+ // foreground priority and may be killed by the system sooner.
35+ runCatching {
36+ ServiceCompat.startForeground(
37+ this,
38+ NOTIF_ID,
39+ notification,
40+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
41+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
42+ } else {
43+ 0
44+ },
45+ )
46+ }.onFailure {
47+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
48+ stopSelf()
49+ }
50+ return START_NOT_STICKY
51+ }
52+
53+ private fun buildNotification(): Notification {
54+ // Tapping the notification opens the app so the user can see the running session.
55+ val openIntent = Intent(this, MainActivity::class.java).apply {
56+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57+ }
58+ val pendingIntent = PendingIntent.getActivity(
59+ this, 0, openIntent,
60+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61+ )
62+ return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63+ .setSmallIcon(R.drawable.ic_launcher_foreground)
64+ .setContentTitle(getString(R.string.notif_running_title))
65+ .setContentText(getString(R.string.notif_running_text))
66+ .setContentIntent(pendingIntent)
67+ .setOngoing(true)
68+ .setPriority(NotificationCompat.PRIORITY_LOW)
69+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
70+ .build()
71+ }
72+
73+ override fun onDestroy() {
74+ // Safety net: if the process is being killed while a run is in progress,
75+ // ensure the notification is removed rather than lingering.
76+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
77+ super.onDestroy()
78+ }
79+
80+ override fun onBind(intent: Intent?): IBinder? = null
81+
82+ companion object {
83+ private const val TAG = "RunForegroundService"
84+ private const val NOTIF_ID = 1
85+
86+ fun start(context: Context) {
87+ // startForegroundService can throw ForegroundServiceStartNotAllowedException
88+ // on Android 12+ if the app is in the background. Wrap it so a backgrounded
89+ // start (e.g. the user navigates away at the wrong moment) doesn't crash.
90+ runCatching {
91+ context.startForegroundService(Intent(context, RunForegroundService::class.java))
92+ }.onFailure {
93+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
94+ }
95+ }
96+
97+ fun stop(context: Context) {
98+ context.stopService(Intent(context, RunForegroundService::class.java))
99+ }
100+ }
101+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktnew file+95
@@ -0,0 +1,95 @@
1+package soy.iko.opencode.notification
2+
3+import android.app.PendingIntent
4+import android.content.Context
5+import android.content.Intent
6+import android.os.Build
7+import android.util.Log
8+import androidx.core.app.NotificationCompat
9+import androidx.core.app.NotificationManagerCompat
10+import soy.iko.opencode.MainActivity
11+import soy.iko.opencode.R
12+import java.security.MessageDigest
13+
14+/**
15+ * Builds and posts the "session completed" notification when a background session
16+ * finishes a run. Tapping it opens the session via [MainActivity] with an
17+ * [MainActivity.EXTRA_SESSION_ID] extra.
18+ *
19+ * Notification posting is guarded for Android 13+ runtime permission: if the user
20+ * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
21+ */
22+object SessionNotifications {
23+
24+ private const val NOTIF_ID_PREFIX = 4000
25+ private const val TAG = "SessionNotifications"
26+
27+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
28+
29+ /** Derive a stable, collision-resistant notification id from a session id.
30+ * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
31+ * bits makes accidental collisions astronomically unlikely. */
32+ private fun notifIdFor(sessionId: String): Int {
33+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
34+ .getOrNull()?.digest(sessionId.toByteArray()) ?: return NOTIF_ID_PREFIX
35+ // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
36+ // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
37+ // left-to-right, so the shifts MUST be parenthesized individually — otherwise
38+ // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
39+ // byte out of the Int entirely and collapsing the id's entropy.
40+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
41+ ((digest[1].toInt() and 0xFF) shl 16) or
42+ ((digest[2].toInt() and 0xFF) shl 8) or
43+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
44+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
45+ // Using modulo ensures the prefix is preserved without Int overflow.
46+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
47+ }
48+
49+ fun postCompleted(context: Context, sessionId: String, title: String) {
50+ // Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user
51+ // denied it, skip the post instead of throwing. Foreground-service notifications
52+ // are exempt, so the running indicator still shows during a run.
53+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
54+ androidx.core.content.ContextCompat.checkSelfPermission(
55+ context, android.Manifest.permission.POST_NOTIFICATIONS,
56+ ) != android.content.pm.PackageManager.PERMISSION_GRANTED
57+ ) {
58+ return
59+ }
60+ val notifId = notifIdFor(sessionId)
61+ // Sanitize the session id before embedding it in the URI so characters like
62+ // /, ?, # can't inject path segments or query parameters.
63+ val safeSessionId = notifIdRegex.replace(sessionId, "")
64+ val openIntent = Intent(context, MainActivity::class.java).apply {
65+ action = Intent.ACTION_VIEW
66+ data = android.net.Uri.parse("opencode://session/$safeSessionId")
67+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
68+ }
69+ val pendingIntent = PendingIntent.getActivity(
70+ context, notifId, openIntent,
71+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
72+ )
73+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
74+ .setSmallIcon(R.drawable.ic_launcher_foreground)
75+ .setContentTitle(context.getString(R.string.notif_completed_title))
76+ // No % escaping here: getString(id, arg) inserts the argument verbatim and
77+ // never re-scans it for format specifiers, so escaping the title would show
78+ // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
79+ .setContentText(context.getString(R.string.notif_completed_text, title))
80+ .setAutoCancel(true)
81+ .setContentIntent(pendingIntent)
82+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
83+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
84+ .build()
85+
86+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
87+ .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
88+ }
89+
90+ /** Cancel a session's completion notification (e.g. when the user opens it). */
91+ fun cancel(context: Context, sessionId: String) {
92+ val notifId = notifIdFor(sessionId)
93+ NotificationManagerCompat.from(context).cancel(notifId)
94+ }
95+}
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+215−100
@@ -1,124 +1,239 @@
11package soy.iko.opencode.ui
22
3−import androidx.activity.compose.BackHandler
4−import androidx.compose.animation.AnimatedContent
3+import androidx.compose.animation.core.tween
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
56import androidx.compose.animation.slideInHorizontally
67import androidx.compose.animation.slideOutHorizontally
7−import androidx.compose.animation.togetherWith
8−import androidx.compose.foundation.layout.fillMaxSize
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.material3.AlertDialog
11−import androidx.compose.material3.Scaffold
12−import androidx.compose.material3.SnackbarDuration
13−import androidx.compose.material3.SnackbarHost
14−import androidx.compose.material3.SnackbarHostState
15−import androidx.compose.material3.Text
16−import androidx.compose.material3.TextButton
8+import androidx.compose.foundation.layout.BoxWithConstraints
179import androidx.compose.runtime.Composable
1810import androidx.compose.runtime.LaunchedEffect
1911import androidx.compose.runtime.getValue
2012import androidx.compose.runtime.mutableStateOf
2113import androidx.compose.runtime.remember
14+import androidx.compose.runtime.rememberCoroutineScope
2215import androidx.compose.runtime.setValue
23−import androidx.compose.ui.Modifier
24−import soy.iko.opencode.core.Event
25−import soy.iko.opencode.core.Screen
26−import soy.iko.opencode.core.UiState
27−import soy.iko.opencode.ui.screens.ChatScreen
28−import soy.iko.opencode.ui.screens.ConnectScreen
29−import soy.iko.opencode.ui.screens.SessionsScreen
16+import androidx.compose.ui.unit.dp
17+import androidx.navigation.NavType
18+import androidx.navigation.compose.NavHost
19+import androidx.navigation.compose.composable
20+import androidx.navigation.compose.rememberNavController
21+import androidx.navigation.navArgument
22+import androidx.lifecycle.compose.collectAsStateWithLifecycle
23+import kotlinx.coroutines.launch
24+import soy.iko.opencode.di.AppContainer
25+import soy.iko.opencode.data.network.NetworkConfig
26+import soy.iko.opencode.ui.chat.ChatScreen
27+import soy.iko.opencode.ui.file.FileBrowserScreen
28+import soy.iko.opencode.ui.file.FileViewScreen
29+import soy.iko.opencode.ui.server.ServerEditScreen
30+import soy.iko.opencode.ui.server.ServerListScreen
31+import soy.iko.opencode.ui.session.SessionListScreen
32+import soy.iko.opencode.ui.session.TwoPaneSessionChat
33+import soy.iko.opencode.ui.settings.DiagnosticsScreen
34+import soy.iko.opencode.ui.settings.SettingsScreen
35+import soy.iko.opencode.util.runCatchingCancellable
3036
3137@Composable
32−fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
33− val snackbar = remember { SnackbarHostState() }
34− var showStopConfirm by remember { mutableStateOf(false) }
35− var showDisconnectConfirm by remember { mutableStateOf(false) }
38+fun OpencodeApp(container: AppContainer) {
39+ val navController = rememberNavController()
40+ val scope = rememberCoroutineScope()
41+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
42+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
43+ val connection by container.activeConnection.collectAsStateWithLifecycle()
3644
37− LaunchedEffect(state.error) {
38− state.error?.let {
39− snackbar.showSnackbar(
40− message = it,
41− actionLabel = "Dismiss",
42− duration = SnackbarDuration.Short,
43− )
44− // Always clear the error from state after showing.
45− dispatch(Event.DismissError)
45+ // When text is shared into the app, surface the session list so the user can pick
46+ // (or create) a conversation to drop it into. The chosen session's draft is set
47+ // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
48+ // before auto-reconnect completes is retried once the connection is established.
49+ // Tracks the last connection id seen so a server switch (a non-null -> non-null
50+ // change) doesn't re-bounce the user to the session list for a share they've
51+ // already decided to abandon; only the null -> non-null initial connect retries.
52+ var lastConnectionId by remember { mutableStateOf<String?>(null) }
53+ LaunchedEffect(pendingShare, connection) {
54+ if (pendingShare == null) return@LaunchedEffect
55+ if (connection == null) { lastConnectionId = null; return@LaunchedEffect }
56+ val currentId = connection?.profile?.id
57+ // Only (re)navigate on the initial null -> non-null connect. A subsequent
58+ // server switch while a share is pending (rare) leaves the user where they
59+ // are instead of yanking them back to the session list.
60+ if (lastConnectionId != null && lastConnectionId == currentId) return@LaunchedEffect
61+ lastConnectionId = currentId
62+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
63+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
4664 }
4765 }
4866
49− BackHandler(enabled = state.screen != Screen.Connect) {
50− when (state.screen) {
51− // Leaving mid-generation would silently drop the reply, so confirm first.
52− Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
53− // Disconnecting drops the server session, so guard the back button.
54− Screen.Sessions -> showDisconnectConfirm = true
55− Screen.Connect -> Unit
56− }
57− }
67+ // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
68+ // the open conversation side by side instead of a single-pane back stack.
69+ BoxWithConstraints {
70+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
5871
59− if (showStopConfirm) {
60− AlertDialog(
61− onDismissRequest = { showStopConfirm = false },
62− title = { Text("Stop generation?") },
63− text = { Text("Leaving will stop the current generation.") },
64− confirmButton = {
65− TextButton(onClick = {
66− showStopConfirm = false
67− dispatch(Event.CancelGeneration)
68− dispatch(Event.NavigateToSessions)
69− }) {
70− Text("Stop and leave")
72+ // Open a session requested by a notification tap or deep link, once connected.
73+ LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
74+ val id = pendingOpenSession ?: return@LaunchedEffect
75+ if (connection == null) return@LaunchedEffect
76+ // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
77+ // route) consumes the request and opens it in the detail pane. But it only
78+ // exists while the NavHost is on SESSIONS, so if the user is currently on
79+ // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
80+ // pending request is never consumed and the session never opens. Don't consume
81+ // here; let TwoPaneSessionChat's effect do it once it's composed.
82+ if (isTwoPane) {
83+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
84+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
7185 }
72− },
73− dismissButton = {
74− TextButton(onClick = { showStopConfirm = false }) {
75− Text("Stay")
76− }
77− },
78− )
79− }
86+ return@LaunchedEffect
87+ }
88+ container.consumePendingOpenSession()
89+ // Ensure SESSIONS sits on the back stack below the chat destination so backing
90+ // out of the chat lands on the session list (not the server list). Bring SESSIONS
91+ // to front (or push it if a cold-start deep link left only SERVERS on the stack),
92+ // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
93+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
94+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
95+ }
96+ navController.navigate(Routes.chat(id)) { launchSingleTop = true }
97+ }
8098
81− if (showDisconnectConfirm) {
82− AlertDialog(
83− onDismissRequest = { showDisconnectConfirm = false },
84− title = { Text("Disconnect?") },
85− text = { Text("You will be disconnected from the server.") },
86− confirmButton = {
87− TextButton(onClick = {
88− showDisconnectConfirm = false
89− dispatch(Event.NavigateToConnect)
90− }) {
91− Text("Disconnect")
92− }
93− },
94− dismissButton = {
95− TextButton(onClick = { showDisconnectConfirm = false }) {
96− Text("Cancel")
97− }
98− },
99− )
100− }
99+ NavHost(
100+ navController = navController,
101+ startDestination = Routes.SERVERS,
102+ // Slide horizontally for forward/back navigation to feel native; fade for
103+ // the root so the first frame doesn't slide in from off-screen.
104+ enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
105+ exitTransition = { fadeOut(tween(180)) },
106+ popEnterTransition = { fadeIn(tween(180)) },
107+ popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
108+ ) {
101109
102− Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
103− AnimatedContent(
104− targetState = state.screen,
105− modifier = Modifier.fillMaxSize().padding(padding),
106− transitionSpec = {
107− // Compare screen ordinals to decide slide direction: forward navigation
108− // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
109− if (targetState.ordinal > initialState.ordinal) {
110− slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
111− } else {
112− slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
113− }
114− },
115− label = "screen",
116− ) { screen ->
117− when (screen) {
118− Screen.Connect -> ConnectScreen(state, dispatch)
119− Screen.Sessions -> SessionsScreen(state, dispatch)
120− Screen.Chat -> ChatScreen(state, dispatch)
110+ composable(Routes.SERVERS) {
111+ ServerListScreen(
112+ container = container,
113+ onConnected = {
114+ // Only leave the server list if we're still on it. A pending deep-link /
115+ // notification open sets activeConnection (which drives the open-session
116+ // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
117+ // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
118+ // on top of the chat it just opened, burying it. Order-independent: if the
119+ // deep link already navigated away from SERVERS, this is a no-op.
120+ if (navController.currentDestination?.route == Routes.SERVERS) {
121+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
122+ }
123+ },
124+ onAddProfile = { navController.navigate(Routes.serverEdit()) },
125+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
126+ onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
127+ )
128+ }
129+
130+ composable(
131+ route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
132+ arguments = listOf(
133+ navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
134+ navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
135+ ),
136+ ) { entry ->
137+ ServerEditScreen(
138+ container = container,
139+ profileId = entry.arguments?.getString("id"),
140+ sourceId = entry.arguments?.getString("dup"),
141+ onDone = { navController.popBackStack() },
142+ )
143+ }
144+
145+ composable(Routes.SESSIONS) {
146+ if (isTwoPane) {
147+ TwoPaneSessionChat(
148+ container = container,
149+ onOpenFiles = { navController.navigate(Routes.FILES) },
150+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
151+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
152+ onDisconnect = {
153+ scope.launch { runCatchingCancellable { container.disconnect() } }
154+ navController.popBackStack(Routes.SERVERS, inclusive = false)
155+ },
156+ onAddServer = { navController.navigate(Routes.serverEdit()) },
157+ )
158+ } else {
159+ SessionListScreen(
160+ container = container,
161+ onOpenSession = { id ->
162+ // Set the draft synchronously in-memory before navigating so the
163+ // ChatScreen sees it on first composition. The async persistence
164+ // to disk happens in the background via draftStore.set.
165+ container.consumePendingShare()?.let { shareText ->
166+ container.draftStore.setImmediate(id, shareText)
167+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
168+ }
169+ navController.navigate(Routes.chat(id))
170+ },
171+ onDisconnect = {
172+ scope.launch { runCatchingCancellable { container.disconnect() } }
173+ navController.popBackStack(Routes.SERVERS, inclusive = false)
174+ },
175+ onOpenFiles = { navController.navigate(Routes.FILES) },
176+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
177+ onAddServer = { navController.navigate(Routes.serverEdit()) },
178+ )
121179 }
122180 }
181+
182+ composable(
183+ route = "${Routes.CHAT}/{sessionId}",
184+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
185+ ) { entry ->
186+ ChatScreen(
187+ container = container,
188+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
189+ onBack = { navController.popBackStack() },
190+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
191+ )
192+ }
193+
194+ composable(Routes.FILES) {
195+ FileBrowserScreen(
196+ container = container,
197+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
198+ onBack = { navController.popBackStack() },
199+ )
200+ }
201+
202+ composable(
203+ route = "${Routes.FILE_VIEW}?path={path}",
204+ arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
205+ ) { entry ->
206+ FileViewScreen(
207+ container = container,
208+ path = entry.arguments?.getString("path").orEmpty(),
209+ onBack = { navController.popBackStack() },
210+ )
211+ }
212+
213+ composable(Routes.SETTINGS) {
214+ SettingsScreen(
215+ container = container,
216+ onBack = { navController.popBackStack() },
217+ // Navigate to the server list as a new entry on top of Sessions so the
218+ // user can back out to their conversations. Previously this popped back
219+ // to the root SERVERS destination, which removed Sessions from the stack
220+ // and left the user unable to return to their conversations without
221+ // reconnecting. Pop only Settings, then push SERVERS; back from the
222+ // server list lands on Sessions (the screen the user was on before
223+ // Settings), and connecting pushes a fresh SESSIONS as usual.
224+ onManageServers = {
225+ navController.navigate(Routes.SERVERS) {
226+ popUpTo(Routes.SETTINGS) { inclusive = true }
227+ launchSingleTop = true
228+ }
229+ },
230+ onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
231+ )
232+ }
233+
234+ composable(Routes.DIAGNOSTICS) {
235+ DiagnosticsScreen(onBack = { navController.popBackStack() })
236+ }
237+ }
123238 }
124239}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+23
@@ -0,0 +1,23 @@
1+package soy.iko.opencode.ui
2+
3+/** String routes for the app's NavHost. Kept simple and centralized. */
4+object Routes {
5+ const val SERVERS = "servers"
6+ const val SESSIONS = "sessions"
7+ const val SETTINGS = "settings"
8+ const val DIAGNOSTICS = "diagnostics"
9+ const val FILES = "files"
10+
11+ const val SERVER_EDIT = "server_edit"
12+ fun serverEdit(id: String? = null) =
13+ if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
14+
15+ /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
16+ fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
17+
18+ const val CHAT = "chat"
19+ fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
20+
21+ const val FILE_VIEW = "file_view"
22+ fun fileView(path: String) = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
23+}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktnew file+16
@@ -0,0 +1,16 @@
1+package soy.iko.opencode.ui
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewmodel.CreationExtras
5+import androidx.lifecycle.viewmodel.initializer
6+import androidx.lifecycle.viewmodel.viewModelFactory
7+
8+/**
9+ * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10+ * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11+ */
12+inline fun <reified VM : ViewModel> vmFactory(
13+ crossinline create: (CreationExtras) -> VM,
14+) = viewModelFactory {
15+ initializer { create(this) }
16+}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktnew file+213
@@ -0,0 +1,213 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
10+import androidx.compose.foundation.lazy.LazyColumn
11+import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
27+import androidx.compose.runtime.Composable
28+import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.mutableStateOf
30+import androidx.compose.runtime.remember
31+import androidx.compose.runtime.saveable.rememberSaveable
32+import androidx.compose.runtime.setValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.Agent
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every agent and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun AgentPickerSheet(
52+ agents: List<Agent>,
53+ selected: String?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (Agent?) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.agent),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && agents.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (agents.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_agents),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_agents)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(agents, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) agents
116+ else agents.filter {
117+ it.name.contains(q, ignoreCase = true) ||
118+ it.displayDescription.contains(q, ignoreCase = true)
119+ }
120+ }
121+ // The "Default" option participates in the search too: show it when the query is
122+ // empty or matches its label/description. Without this it either always showed
123+ // (ignoring the filter) or, when a query matched no named agent, disappeared with
124+ // the whole list — leaving the default unselectable.
125+ val defaultLabel = stringResource(R.string.default_agent)
126+ val defaultDesc = stringResource(R.string.default_agent_desc)
127+ val q = query.trim()
128+ val defaultMatches = q.isEmpty() ||
129+ defaultLabel.contains(q, ignoreCase = true) ||
130+ defaultDesc.contains(q, ignoreCase = true)
131+ if (filtered.isEmpty() && !defaultMatches) {
132+ Text(
133+ stringResource(R.string.no_agents_match, query.trim()),
134+ style = MaterialTheme.typography.bodyMedium,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
137+ )
138+ } else {
139+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
140+ if (defaultMatches) {
141+ item(key = "__default") {
142+ Column(
143+ modifier = Modifier
144+ .fillMaxWidth()
145+ .semantics(mergeDescendants = true) {
146+ if (selected == null) this.selected = true
147+ }
148+ .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
149+ .padding(horizontal = 24.dp, vertical = 12.dp),
150+ ) {
151+ Row(verticalAlignment = Alignment.CenterVertically) {
152+ Text(
153+ stringResource(R.string.default_agent),
154+ style = MaterialTheme.typography.bodyLarge,
155+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
156+ modifier = Modifier.weight(1f),
157+ )
158+ if (selected == null) {
159+ Icon(
160+ Icons.Filled.Check,
161+ contentDescription = null,
162+ tint = MaterialTheme.colorScheme.primary,
163+ modifier = Modifier.size(18.dp),
164+ )
165+ }
166+ }
167+ Text(
168+ stringResource(R.string.default_agent_desc),
169+ style = MaterialTheme.typography.bodySmall,
170+ color = MaterialTheme.colorScheme.onSurfaceVariant,
171+ )
172+ }
173+ }
174+ }
175+ items(filtered, key = { it.name }) { agent ->
176+ val isSelected = agent.name == selected
177+ Column(
178+ modifier = Modifier
179+ .fillMaxWidth()
180+ .semantics(mergeDescendants = true) {
181+ if (isSelected) this.selected = true
182+ }
183+ .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
184+ .padding(horizontal = 24.dp, vertical = 12.dp),
185+ ) {
186+ Row(verticalAlignment = Alignment.CenterVertically) {
187+ Text(
188+ agent.name,
189+ style = MaterialTheme.typography.bodyLarge,
190+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
191+ modifier = Modifier.weight(1f),
192+ )
193+ if (isSelected) {
194+ Icon(
195+ Icons.Filled.Check,
196+ contentDescription = null,
197+ tint = MaterialTheme.colorScheme.primary,
198+ modifier = Modifier.size(18.dp),
199+ )
200+ }
201+ }
202+ Text(
203+ agent.displayDescription,
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ )
207+ }
208+ }
209+ }
210+ }
211+ }
212+ }
213+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+1173
@@ -0,0 +1,1173 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.Box
8+import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.FlowRow
10+import androidx.compose.foundation.layout.PaddingValues
11+import androidx.compose.foundation.layout.Row
12+import androidx.compose.foundation.layout.Spacer
13+import androidx.compose.foundation.layout.WindowInsets
14+import androidx.compose.foundation.layout.fillMaxSize
15+import androidx.compose.foundation.layout.fillMaxWidth
16+import androidx.compose.foundation.layout.imePadding
17+import androidx.compose.foundation.layout.navigationBars
18+import androidx.compose.foundation.layout.padding
19+import androidx.compose.foundation.layout.size
20+import androidx.compose.foundation.layout.union
21+import androidx.compose.foundation.layout.windowInsetsPadding
22+import androidx.compose.foundation.lazy.LazyColumn
23+import androidx.compose.foundation.lazy.items
24+import androidx.compose.foundation.lazy.rememberLazyListState
25+import androidx.compose.material.icons.Icons
26+import androidx.compose.material.icons.automirrored.filled.ArrowBack
27+import androidx.compose.material.icons.automirrored.filled.Send
28+import androidx.compose.material.icons.filled.AutoAwesome
29+import androidx.compose.material.icons.filled.Delete
30+import androidx.compose.material.icons.filled.Edit
31+import androidx.compose.material.icons.filled.KeyboardArrowDown
32+import androidx.compose.material.icons.filled.MoreVert
33+import androidx.compose.material.icons.filled.Refresh
34+import androidx.compose.material.icons.filled.Stop
35+import androidx.compose.material3.AlertDialog
36+import androidx.compose.material3.Button
37+import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
40+import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.ExtendedFloatingActionButton
42+import androidx.compose.material3.FloatingActionButton
43+import androidx.compose.material3.Icon
44+import androidx.compose.material3.IconButton
45+import androidx.compose.material3.MaterialTheme
46+import androidx.compose.material3.OutlinedTextField
47+import androidx.compose.material3.Scaffold
48+import androidx.compose.material3.SnackbarHost
49+import androidx.compose.material3.SnackbarHostState
50+import androidx.compose.material3.Surface
51+import androidx.compose.material3.Text
52+import androidx.compose.material3.TextButton
53+import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
55+import androidx.compose.runtime.Composable
56+import androidx.compose.runtime.CompositionLocalProvider
57+import androidx.compose.runtime.DisposableEffect
58+import androidx.compose.runtime.LaunchedEffect
59+import androidx.compose.runtime.derivedStateOf
60+import androidx.compose.runtime.getValue
61+import androidx.compose.runtime.mutableStateOf
62+import androidx.compose.runtime.remember
63+import androidx.compose.runtime.rememberCoroutineScope
64+import androidx.compose.runtime.rememberUpdatedState
65+import androidx.compose.runtime.saveable.rememberSaveable
66+import androidx.compose.runtime.setValue
67+import androidx.compose.runtime.snapshotFlow
68+import androidx.compose.foundation.text.KeyboardActions
69+import androidx.compose.foundation.text.KeyboardOptions
70+import androidx.compose.ui.text.input.KeyboardCapitalization
71+import androidx.compose.ui.Alignment
72+import androidx.compose.ui.Modifier
73+import androidx.compose.ui.focus.focusRequester
74+import androidx.compose.ui.input.key.Key
75+import androidx.compose.ui.input.key.KeyEventType
76+import androidx.compose.ui.input.key.isCtrlPressed
77+import androidx.compose.ui.input.key.isShiftPressed
78+import androidx.compose.ui.input.key.key
79+import androidx.compose.ui.input.key.onPreviewKeyEvent
80+import androidx.compose.ui.input.key.type
81+import androidx.compose.ui.platform.LocalContext
82+import androidx.compose.ui.platform.LocalHapticFeedback
83+import androidx.compose.ui.platform.LocalView
84+import androidx.compose.ui.platform.testTag
85+import androidx.compose.ui.res.stringResource
86+import androidx.compose.ui.semantics.Role
87+import androidx.compose.ui.semantics.contentDescription
88+import androidx.compose.ui.semantics.semantics
89+import androidx.compose.ui.text.input.ImeAction
90+import androidx.compose.ui.text.style.TextOverflow
91+import androidx.compose.ui.unit.dp
92+import androidx.lifecycle.compose.collectAsStateWithLifecycle
93+import androidx.lifecycle.viewmodel.compose.viewModel
94+import kotlinx.coroutines.Dispatchers
95+import kotlinx.coroutines.launch
96+import kotlinx.coroutines.withContext
97+import soy.iko.opencode.data.model.Part
98+import soy.iko.opencode.data.model.MessageWithParts
99+import soy.iko.opencode.data.model.ReasoningPart
100+import soy.iko.opencode.data.model.TextPart
101+import soy.iko.opencode.data.model.ToolCompleted
102+import soy.iko.opencode.data.model.ToolError
103+import soy.iko.opencode.data.model.ToolPart
104+import soy.iko.opencode.data.model.ToolRunning
105+import soy.iko.opencode.data.network.EventStreamClient
106+import soy.iko.opencode.data.network.NetworkConfig
107+import soy.iko.opencode.di.AppContainer
108+import soy.iko.opencode.R
109+import soy.iko.opencode.ui.components.ConnectionBanner
110+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
111+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
112+import soy.iko.opencode.ui.components.showToast
113+import soy.iko.opencode.ui.components.toImageContext
114+import soy.iko.opencode.ui.vmFactory
115+import soy.iko.opencode.util.runCatchingCancellable
116+
117+@OptIn(ExperimentalMaterial3Api::class)
118+@Composable
119+fun ChatScreen(
120+ container: AppContainer,
121+ sessionId: String,
122+ onBack: () -> Unit,
123+ onOpenFile: ((String) -> Unit)? = null,
124+) {
125+ val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
126+ val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
127+ val running by vm.running.collectAsStateWithLifecycle()
128+ val aborting by vm.aborting.collectAsStateWithLifecycle()
129+ val loading by vm.loading.collectAsStateWithLifecycle()
130+ val loadError by vm.loadError.collectAsStateWithLifecycle()
131+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
132+ val models by vm.models.collectAsStateWithLifecycle()
133+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
134+ val modelsError by vm.modelsError.collectAsStateWithLifecycle()
135+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
136+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
137+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
138+ val agents by vm.agents.collectAsStateWithLifecycle()
139+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
140+ val agentsError by vm.agentsError.collectAsStateWithLifecycle()
141+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
142+ val commands by vm.commands.collectAsStateWithLifecycle()
143+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
144+ val commandsError by vm.commandsError.collectAsStateWithLifecycle()
145+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
146+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
147+ val draft by vm.draft.collectAsStateWithLifecycle()
148+ val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
149+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
150+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
151+ val haptics = LocalHapticFeedback.current
152+ val scope = rememberCoroutineScope()
153+ val shareContext = LocalContext.current
154+ val defaultShareSubject = stringResource(R.string.share_subject)
155+ val sessionLabel = stringResource(R.string.session)
156+ val defaultModelLabel = stringResource(R.string.default_model)
157+ val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
158+ buildString {
159+ append(selectedModel?.modelLabel ?: defaultModelLabel)
160+ selectedAgent?.let { append(" · $it") }
161+ }
162+ }
163+
164+ // Connection profile (with auth) for rendering inline image attachments.
165+ // Collect as state so a server switch recomposes the image context.
166+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
167+ val imageContext = remember(activeConnection?.profile) {
168+ activeConnection?.profile?.toImageContext()
169+ }
170+
171+ // Mark this session as read while the user is viewing it; clear on leave so new
172+ // background activity can badge it again.
173+ DisposableEffect(sessionId) {
174+ container.setCurrentSession(sessionId)
175+ onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
176+ }
177+
178+ // Navigate away when the session is deleted via SSE so the user isn't left
179+ // on a zombie screen showing a conversation that no longer exists. Surface a
180+ // toast (not a snackbar) because the screen is about to be popped — a snackbar
181+ // would be torn down before the user could read it, while a toast survives.
182+ // Covers both SSE-driven deletion (deleted on another device) and a user-
183+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
184+ // sets the same flag on success).
185+ val deletedContext = LocalContext.current
186+ LaunchedEffect(sessionDeleted) {
187+ if (sessionDeleted) {
188+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
189+ onBack()
190+ }
191+ }
192+
193+ val snackbar = remember { SnackbarHostState() }
194+ val retryLabel = stringResource(R.string.retry)
195+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
196+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
197+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
198+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
199+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
200+ var showStopConfirm by rememberSaveable { mutableStateOf(false) }
201+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
202+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
203+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
204+
205+ // Keep the screen awake and hold a foreground priority while the agent is working,
206+ // so backgrounding mid-run doesn't let Doze choke the SSE stream.
207+ val currentView = LocalView.current
208+ val appContext = LocalContext.current.applicationContext
209+ DisposableEffect(running) {
210+ currentView.keepScreenOn = running
211+ if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
212+ onDispose {
213+ currentView.keepScreenOn = false
214+ soy.iko.opencode.notification.RunForegroundService.stop(appContext)
215+ }
216+ }
217+
218+ LaunchedEffect(Unit) {
219+ vm.errorEvents.collect { event ->
220+ // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
221+ // message-load failure) would silently re-submit the last prompt on tap.
222+ val result = if (event.retryable) {
223+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
224+ } else {
225+ snackbar.showSnackbar(event.message)
226+ }
227+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
228+ }
229+ }
230+
231+ fun doSend() {
232+ if (vm.send(draft)) {
233+ // TextHandleMove (a light tick) is semantically right for a send commit;
234+ // LongPress is reserved for long-press/swipe gestures.
235+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
236+ }
237+ }
238+
239+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
240+
241+ Scaffold(
242+ topBar = {
243+ TopAppBar(
244+ title = {
245+ val changeLabel = stringResource(R.string.change_model_agent)
246+ Row(
247+ modifier = Modifier
248+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
249+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
250+ verticalAlignment = Alignment.CenterVertically,
251+ ) {
252+ Column(modifier = Modifier.weight(1f, fill = false)) {
253+ // maxLines=1 + Ellipsis so a long session title can't wrap and
254+ // push the top bar to multiple lines, shifting the whole layout.
255+ Text(
256+ sessionTitle ?: sessionLabel,
257+ maxLines = 1,
258+ overflow = TextOverflow.Ellipsis,
259+ )
260+ Text(
261+ subtitle,
262+ style = MaterialTheme.typography.labelSmall,
263+ color = MaterialTheme.colorScheme.onSurfaceVariant,
264+ maxLines = 1,
265+ overflow = TextOverflow.Ellipsis,
266+ )
267+ }
268+ // Visual affordance that the title opens the model picker;
269+ // without it the tappable title is indistinguishable from plain
270+ // text and the picker is effectively undiscoverable.
271+ if (models.isNotEmpty()) {
272+ Icon(
273+ Icons.Filled.KeyboardArrowDown,
274+ contentDescription = null,
275+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
276+ modifier = Modifier.size(20.dp),
277+ )
278+ }
279+ }
280+ },
281+ navigationIcon = {
282+ IconButton(onClick = {
283+ if (running) showExitConfirm = true else onBack()
284+ }) {
285+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
286+ }
287+ },
288+ actions = {
289+ // At-a-glance run status in the top bar. When the user is scrolled
290+ // up (jump-to-latest FAB visible), the input-bar Stop button and the
291+ // trailing "working…" row are off-screen, so a top-bar spinner is the
292+ // only signal the agent is still working.
293+ if (running) {
294+ val workingLabel = stringResource(R.string.working)
295+ CircularProgressIndicator(
296+ Modifier.size(18.dp).semantics { contentDescription = workingLabel },
297+ strokeWidth = 2.dp,
298+ )
299+ }
300+ // Manual refresh: forces an SSE reconnect, which re-seeds messages
301+ // from REST. A recovery path when the stream silently drops and the
302+ // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
303+ // immediate tap feedback — the SSE reconnect may not visibly change
304+ // the connection state when already Connected, so without it the tap
305+ // appears to do nothing.
306+ val refreshLabel = stringResource(R.string.refresh)
307+ IconButton(
308+ onClick = { vm.refreshMessages() },
309+ enabled = activeConnection != null && !refreshing,
310+ ) {
311+ if (refreshing) {
312+ CircularProgressIndicator(
313+ Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
314+ strokeWidth = 2.dp,
315+ )
316+ } else {
317+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
318+ }
319+ }
320+ // Overflow menu consolidates the less-frequent actions (share,
321+ // commands, agent, model, rename, delete) so the top bar stays
322+ // scannable on narrow phones — previously 5 icons crowded the bar
323+ // alongside the back button and tappable title. Catalog-loading
324+ // spinners stay inline (next to the overflow) so a load state is
325+ // visible without opening the menu.
326+ val moreLabel = stringResource(R.string.more)
327+ val loadingLabel = stringResource(R.string.loading)
328+ val shareLabel = stringResource(R.string.share_conversation)
329+ val commandsLabel = stringResource(R.string.commands)
330+ val agentLabel = stringResource(R.string.choose_agent)
331+ val modelLabel = stringResource(R.string.choose_model)
332+ val renameLabel = stringResource(R.string.rename_session_chat)
333+ val deleteLabel = stringResource(R.string.delete_session_chat)
334+ Box {
335+ IconButton(onClick = { showOverflowMenu = true }) {
336+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
337+ }
338+ DropdownMenu(
339+ expanded = showOverflowMenu,
340+ onDismissRequest = { showOverflowMenu = false },
341+ ) {
342+ DropdownMenuItem(
343+ text = { Text(shareLabel) },
344+ enabled = hasMessages,
345+ onClick = {
346+ showOverflowMenu = false
347+ scope.launch {
348+ val md = withContext(Dispatchers.Default) {
349+ buildConversationMarkdown(vm.messages.value, sessionTitle)
350+ }
351+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
352+ type = "text/markdown"
353+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
354+ putExtra(android.content.Intent.EXTRA_TEXT, md)
355+ }
356+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
357+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
358+ }
359+ },
360+ )
361+ DropdownMenuItem(
362+ text = { Text(commandsLabel) },
363+ enabled = !commandsLoading || commands.isNotEmpty(),
364+ onClick = { showOverflowMenu = false; showCommandPicker = true },
365+ )
366+ DropdownMenuItem(
367+ text = { Text(agentLabel) },
368+ enabled = !agentsLoading || agents.isNotEmpty(),
369+ onClick = { showOverflowMenu = false; showAgentPicker = true },
370+ )
371+ DropdownMenuItem(
372+ text = { Text(modelLabel) },
373+ enabled = !modelsLoading || models.isNotEmpty(),
374+ onClick = { showOverflowMenu = false; showModelPicker = true },
375+ )
376+ androidx.compose.material3.HorizontalDivider()
377+ DropdownMenuItem(
378+ text = { Text(renameLabel) },
379+ onClick = { showOverflowMenu = false; showRenameDialog = true },
380+ )
381+ DropdownMenuItem(
382+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
383+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
384+ )
385+ }
386+ // While a catalog is initially loading (empty list + loading flag),
387+ // show a tiny inline spinner beside the overflow so the user can see
388+ // a load is in progress without opening the menu. Once loaded the
389+ // spinner disappears; the menu items are always enabled (tapping a
390+ // picker with no items shows the "no models/agents/commands" state).
391+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
392+ (agentsLoading && agents.isEmpty()) ||
393+ (modelsLoading && models.isEmpty())
394+ if (catalogLoading) {
395+ CircularProgressIndicator(
396+ Modifier
397+ .align(Alignment.TopEnd)
398+ .size(10.dp)
399+ .semantics { contentDescription = loadingLabel },
400+ strokeWidth = 1.dp,
401+ )
402+ }
403+ }
404+ },
405+ )
406+ },
407+ snackbarHost = { SnackbarHost(snackbar) },
408+ bottomBar = {
409+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
410+ ChatInputBar(
411+ value = draft,
412+ onValueChange = vm::updateDraft,
413+ running = running,
414+ aborting = aborting,
415+ enabled = activeConnection != null,
416+ sendOnEnter = sendOnEnter,
417+ onSend = ::doSend,
418+ onAbort = { showStopConfirm = true },
419+ queuedFollowUp = queuedFollowUp,
420+ onQueueFollowUp = vm::queueFollowUp,
421+ onCancelQueue = { vm.queueFollowUp("") },
422+ focusRequester = inputFocusRequester,
423+ )
424+ },
425+ ) { padding ->
426+ // Collect messages inside the content lambda so streaming token updates
427+ // recompose only this subtree, not the top bar / input bar / 20+ other
428+ // state reads in the ChatScreen body.
429+ val messages by vm.messages.collectAsStateWithLifecycle()
430+ val listState = rememberLazyListState()
431+ val contentScope = rememberCoroutineScope()
432+ val timeTick = rememberRelativeTimeTick()
433+ // Group messages by day and interleave date separators so a long conversation
434+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
435+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
436+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
437+ // the same list the LazyColumn renders (separators shift the indices vs. the
438+ // raw messages list, so scroll targets must be in listItems space).
439+ val todayLabel = stringResource(R.string.today)
440+ val yesterdayLabel = stringResource(R.string.yesterday)
441+ val listItems = remember(messages, todayLabel, yesterdayLabel) {
442+ buildMessageListItems(messages, todayLabel, yesterdayLabel)
443+ }
444+ // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
445+ // derivedStateOf and snapshotFlow lambdas below are created once (keyless
446+ // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
447+ // would capture the first composition's list — which is empty, since `messages`
448+ // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
449+ // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
450+ // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
451+ // whose value tracks the latest list.
452+ val currentListItems by rememberUpdatedState(listItems)
453+
454+ val isPinnedToBottom by remember {
455+ derivedStateOf {
456+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
457+ // When a run is active, the LazyColumn has one trailing "__typing"
458+ // item after the last list item. Account for it so "pinned to bottom"
459+ // includes the working indicator — otherwise the pin check targets
460+ // listItems.lastIndex, the typing row sits just below the viewport,
461+ // and the user never sees the progress indicator even when pinned.
462+ val items = currentListItems
463+ val effectiveLast = if (running) items.size else items.lastIndex
464+ lastVisible >= effectiveLast || items.isEmpty()
465+ }
466+ }
467+
468+ // Jump to the newest message once, when the conversation first loads. Guarded by a
469+ // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
470+ // (rotation) — rememberLazyListState restores the user's scroll offset, and an
471+ // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
472+ var didInitialScroll by rememberSaveable { mutableStateOf(false) }
473+ LaunchedEffect(listItems.isNotEmpty()) {
474+ if (listItems.isNotEmpty() && !didInitialScroll) {
475+ // When a run is active the LazyColumn has a trailing "__typing" row at
476+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
477+ // running session lands truly pinned to bottom and streaming auto-scroll engages.
478+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
479+ didInitialScroll = true
480+ }
481+ }
482+
483+ // When a run starts, the LazyColumn gains a trailing "__typing" row, so
484+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
485+ // isPinnedToBottom check flips to false the instant you send — freezing
486+ // auto-scroll. If the user was pinned to the last item just before the run
487+ // began, bring the typing row into view so the pin (and streaming follow) is
488+ // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
489+ // recomputed to false by now) so we don't scroll when the user had scrolled up.
490+ LaunchedEffect(running) {
491+ if (running && listItems.isNotEmpty()) {
492+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
493+ if (lastVisible >= listItems.lastIndex) {
494+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
495+ }
496+ }
497+ }
498+
499+ LaunchedEffect(Unit) {
500+ snapshotFlow {
501+ // Track list size + the streaming length of the last part so we auto-scroll
502+ // as content arrives, plus the pinned flag. A small data class with primitive
503+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
504+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
505+ // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
506+ // and tool output — not just TextPart — keeps a pinned view following a long
507+ // reasoning/tool block while it streams (previously it stalled at 0).
508+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
509+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
510+ }.collect { signal ->
511+ if (signal.size > 0 && signal.pinned) {
512+ // Scroll to the effective last index, including the trailing
513+ // "__typing" row when a run is active so the working indicator
514+ // is brought into view (not just the last message).
515+ val items = currentListItems
516+ val target = if (running) items.size else items.lastIndex
517+ listState.scrollToItem(target)
518+ }
519+ }
520+ }
521+
522+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
523+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
524+ // content padding accounts for the bottomBar's raised height, so the message
525+ // list is already above the keyboard. Adding imePadding here would double-apply
526+ // the IME inset and push messages too far up.
527+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
528+ if (activeConnection == null) {
529+ Column(
530+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
531+ horizontalAlignment = Alignment.CenterHorizontally,
532+ ) {
533+ Text(
534+ stringResource(R.string.not_connected),
535+ color = MaterialTheme.colorScheme.error,
536+ )
537+ Spacer(Modifier.size(12.dp))
538+ Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
539+ if (reconnecting) {
540+ CircularProgressIndicator(
541+ Modifier.size(20.dp),
542+ color = MaterialTheme.colorScheme.onPrimary,
543+ strokeWidth = 2.dp,
544+ )
545+ } else {
546+ Text(stringResource(R.string.reconnect))
547+ }
548+ }
549+ }
550+ } else {
551+ ConnectionBanner(
552+ state = connectionState,
553+ modifier = Modifier.align(Alignment.TopCenter),
554+ isOnline = isOnline,
555+ // On a hard failure, retry by forcing an SSE reconnect (which
556+ // re-seeds from REST). refreshMessages is the right recovery path
557+ // when the connection is present but the stream died; reconnect()
558+ // is the path when the whole connection is gone (handled by the
559+ // separate "Not connected" state below).
560+ onRetry = { vm.refreshMessages() },
561+ )
562+ if (loading && messages.isEmpty()) {
563+ val loadingLabel = stringResource(R.string.loading)
564+ CircularProgressIndicator(
565+ Modifier
566+ .align(Alignment.Center)
567+ .semantics { contentDescription = loadingLabel },
568+ )
569+ } else if (loadError && messages.isEmpty()) {
570+ // A failed load with nothing to show. Distinct from the empty
571+ // conversation state below — the conversation isn't empty, it
572+ // failed to load, and offering "Start a conversation" here is
573+ // misleading. Offer a Retry instead, mirroring SessionListScreen.
574+ Column(
575+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
576+ horizontalAlignment = Alignment.CenterHorizontally,
577+ ) {
578+ Text(
579+ stringResource(R.string.load_messages_failed),
580+ color = MaterialTheme.colorScheme.error,
581+ )
582+ Spacer(Modifier.size(12.dp))
583+ Button(onClick = { vm.refreshMessages() }) {
584+ Text(stringResource(R.string.retry))
585+ }
586+ }
587+ } else if (messages.isEmpty() && running) {
588+ // A run just started but no parts have arrived yet — the empty
589+ // list with only the trailing "working…" row looks broken, so
590+ // surface a clear starting state until the first part streams in.
591+ val workingText = stringResource(R.string.working)
592+ Column(
593+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
594+ horizontalAlignment = Alignment.CenterHorizontally,
595+ ) {
596+ CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
597+ Spacer(Modifier.size(12.dp))
598+ Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
599+ }
600+ } else if (messages.isEmpty()) {
601+ EmptyConversation(
602+ onSuggestion = {
603+ vm.updateDraft(it)
604+ // Focus the input so the user can edit the suggestion before
605+ // sending, instead of having to manually tap the field.
606+ runCatching { inputFocusRequester.requestFocus() }
607+ },
608+ modifier = Modifier.align(Alignment.Center),
609+ )
610+ } else {
611+ // Pull-to-refresh wraps the message list so the user can force an SSE
612+ // reconnect + re-seed with the same gesture they use on the session and
613+ // file lists, instead of hunting for the top-bar refresh icon. The empty
614+ // / loading / error states above stay outside the PTR box since there's
615+ // no list content to pull against.
616+ PullToRefreshBox(
617+ isRefreshing = refreshing,
618+ onRefresh = { vm.refreshMessages() },
619+ modifier = Modifier.fillMaxSize(),
620+ ) {
621+ val lastMessageId = messages.lastOrNull()?.info?.id
622+ LazyColumn(
623+ state = listState,
624+ modifier = Modifier.fillMaxSize(),
625+ contentPadding = PaddingValues(16.dp),
626+ verticalArrangement = Arrangement.spacedBy(16.dp),
627+ ) {
628+ items(
629+ items = listItems,
630+ key = { it.key },
631+ contentType = { it.contentType },
632+ ) { item ->
633+ when (item) {
634+ is MessageListItem.Separator -> DateSeparator(item.label)
635+ is MessageListItem.Message -> {
636+ val message = item.message
637+ // Only the last (streaming) message needs isRunning — it drives the
638+ // reasoning-block spinner. Passing the live flag to every bubble
639+ // makes all visible messages recompose whenever a run starts or stops.
640+ val modelLabel = remember(message.info, models) {
641+ (message.info as? soy.iko.opencode.data.model.AssistantMessage)
642+ ?.let { resolveModelLabel(it, models) }
643+ }
644+ MessageBubble(
645+ message,
646+ isRunning = running && message.info.id == lastMessageId,
647+ imageContext = imageContext,
648+ modelLabel = modelLabel,
649+ onOpenFile = onOpenFile,
650+ )
651+ }
652+ }
653+ }
654+ if (running) {
655+ item(key = "__typing") {
656+ val workingText = stringResource(R.string.working)
657+ Row(
658+ verticalAlignment = Alignment.CenterVertically,
659+ modifier = Modifier.semantics {
660+ contentDescription = workingText
661+ },
662+ ) {
663+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
664+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
665+ }
666+ }
667+ }
668+ }
669+ }
670+ // Jump-to-latest affordance when the user has scrolled away during a stream.
671+ AnimatedVisibility(
672+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
673+ modifier = Modifier.align(Alignment.BottomEnd),
674+ ) {
675+ ExtendedFloatingActionButton(
676+ onClick = {
677+ if (listItems.isNotEmpty()) {
678+ // Scroll to the effective last index, including the
679+ // trailing "__typing" row when a run is active.
680+ val target = if (running) listItems.size else listItems.lastIndex
681+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
682+ }
683+ },
684+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
685+ text = { Text(stringResource(R.string.latest)) },
686+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
687+ )
688+ }
689+ }
690+ }
691+ }
692+ }
693+ }
694+
695+ if (showExitConfirm) {
696+ AlertDialog(
697+ onDismissRequest = { showExitConfirm = false },
698+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
699+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
700+ confirmButton = {
701+ TextButton(onClick = {
702+ showExitConfirm = false
703+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
704+ vm.abort()
705+ onBack()
706+ }) { Text(stringResource(R.string.stop_and_exit)) }
707+ },
708+ dismissButton = {
709+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
710+ },
711+ )
712+ }
713+
714+ if (showStopConfirm) {
715+ AlertDialog(
716+ onDismissRequest = { showStopConfirm = false },
717+ title = { Text(stringResource(R.string.stop_run_title)) },
718+ text = { Text(stringResource(R.string.stop_run_text)) },
719+ confirmButton = {
720+ TextButton(onClick = {
721+ showStopConfirm = false
722+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
723+ vm.abort()
724+ }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
725+ },
726+ dismissButton = {
727+ TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
728+ },
729+ )
730+ }
731+
732+ if (showRenameDialog) {
733+ RenameSessionChatDialog(
734+ initialTitle = sessionTitle ?: "",
735+ onDismiss = { showRenameDialog = false },
736+ onConfirm = { newName ->
737+ showRenameDialog = false
738+ vm.renameSession(newName)
739+ },
740+ )
741+ }
742+
743+ if (showDeleteDialog) {
744+ AlertDialog(
745+ onDismissRequest = { showDeleteDialog = false },
746+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
747+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
748+ confirmButton = {
749+ TextButton(onClick = {
750+ showDeleteDialog = false
751+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
752+ vm.deleteSession()
753+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
754+ },
755+ dismissButton = {
756+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
757+ },
758+ )
759+ }
760+
761+ if (showModelPicker) {
762+ ModelPickerSheet(
763+ options = models,
764+ selected = selectedModel,
765+ loading = modelsLoading,
766+ error = modelsError,
767+ onSelect = { vm.selectModel(it) },
768+ onRetry = { vm.reloadModels() },
769+ onDismiss = { showModelPicker = false },
770+ )
771+ }
772+
773+ if (showAgentPicker) {
774+ AgentPickerSheet(
775+ agents = agents,
776+ selected = selectedAgent,
777+ loading = agentsLoading,
778+ error = agentsError,
779+ onSelect = { vm.selectAgent(it?.name) },
780+ onRetry = { vm.reloadAgents() },
781+ onDismiss = { showAgentPicker = false },
782+ )
783+ }
784+
785+ if (showCommandPicker) {
786+ CommandPickerSheet(
787+ commands = commands,
788+ loading = commandsLoading,
789+ error = commandsError,
790+ onSelect = { vm.runCommand(it) },
791+ onRetry = { vm.reloadCommands() },
792+ onDismiss = { showCommandPicker = false },
793+ )
794+ }
795+
796+ pendingPermission?.let { permission ->
797+ PermissionDialog(
798+ permission = permission,
799+ onRespond = { response -> vm.respondPermission(permission, response) },
800+ )
801+ }
802+}
803+
804+@Composable
805+private fun ChatInputBar(
806+ value: String,
807+ onValueChange: (String) -> Unit,
808+ running: Boolean,
809+ aborting: Boolean,
810+ enabled: Boolean,
811+ sendOnEnter: Boolean,
812+ onSend: () -> Unit,
813+ onAbort: () -> Unit,
814+ queuedFollowUp: String?,
815+ onQueueFollowUp: (String) -> Unit,
816+ onCancelQueue: () -> Unit,
817+ focusRequester: androidx.compose.ui.focus.FocusRequester,
818+) {
819+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
820+ Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
821+ // A queued follow-up replaces the Stop button with a "queued" chip so the
822+ // user sees their message will be sent when the run finishes, and can cancel.
823+ if (queuedFollowUp != null) {
824+ Row(
825+ modifier = Modifier
826+ .fillMaxWidth()
827+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
828+ verticalAlignment = Alignment.CenterVertically,
829+ ) {
830+ Column(modifier = Modifier.weight(1f)) {
831+ Text(
832+ stringResource(R.string.queued),
833+ style = MaterialTheme.typography.labelSmall,
834+ color = MaterialTheme.colorScheme.onSurfaceVariant,
835+ )
836+ Text(
837+ queuedFollowUp,
838+ style = MaterialTheme.typography.bodySmall,
839+ color = MaterialTheme.colorScheme.onSurfaceVariant,
840+ maxLines = 1,
841+ overflow = TextOverflow.Ellipsis,
842+ )
843+ }
844+ TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
845+ Text(stringResource(R.string.cancel))
846+ }
847+ }
848+ }
849+ Row(
850+ modifier = Modifier.fillMaxWidth().padding(8.dp),
851+ verticalAlignment = Alignment.Bottom,
852+ ) {
853+ OutlinedTextField(
854+ value = value,
855+ onValueChange = { v ->
856+ // Cap input length so a huge paste can't stall the UI. Shares are
857+ // capped separately in MainActivity; this guards typed/pasted drafts.
858+ onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
859+ },
860+ modifier = Modifier
861+ .weight(1f)
862+ .focusRequester(focusRequester)
863+ .testTag("chat_input")
864+ .onPreviewKeyEvent { event ->
865+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
866+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
867+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
868+ val send = when {
869+ !enabled || value.isBlank() -> false
870+ sendOnEnter -> !event.isShiftPressed
871+ else -> event.isCtrlPressed
872+ }
873+ if (send) {
874+ if (running) onQueueFollowUp(value) else onSend()
875+ true
876+ } else {
877+ false
878+ }
879+ },
880+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
881+ enabled = enabled,
882+ maxLines = 6,
883+ // Show a "N / max" countdown once the draft crosses a high fraction of
884+ // the cap, so the user knows a paste is about to be truncated instead
885+ // of being silently cut off. Hidden for normal short prompts to avoid
886+ // clutter under a typical one-line message.
887+ supportingText = {
888+ val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
889+ if (value.length >= threshold) {
890+ Text(
891+ stringResource(
892+ R.string.draft_chars_remaining,
893+ NetworkConfig.maxDraftLengthChars - value.length,
894+ NetworkConfig.maxDraftLengthChars,
895+ ),
896+ style = MaterialTheme.typography.labelSmall,
897+ color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
898+ else MaterialTheme.colorScheme.onSurfaceVariant,
899+ )
900+ }
901+ },
902+ keyboardOptions = KeyboardOptions(
903+ imeAction = ImeAction.Send,
904+ capitalization = KeyboardCapitalization.Sentences,
905+ ),
906+ keyboardActions = KeyboardActions(onSend = {
907+ if (enabled && value.isNotBlank()) {
908+ if (running) onQueueFollowUp(value) else onSend()
909+ }
910+ }),
911+ )
912+ if (running) {
913+ // Show a spinner while the abort REST call is in flight so the user
914+ // sees the stop was sent, and disable to prevent a double-tap.
915+ IconButton(
916+ onClick = onAbort,
917+ enabled = !aborting,
918+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
919+ ) {
920+ if (aborting) {
921+ val stopLabel = stringResource(R.string.stop)
922+ CircularProgressIndicator(
923+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
924+ strokeWidth = 2.dp,
925+ )
926+ } else {
927+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
928+ }
929+ }
930+ } else {
931+ IconButton(
932+ onClick = onSend,
933+ enabled = enabled && value.isNotBlank(),
934+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
935+ ) {
936+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
937+ }
938+ }
939+ }
940+ }
941+ }
942+}
943+
944+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
945+@Composable
946+private fun EmptyConversation(
947+ onSuggestion: (String) -> Unit,
948+ modifier: Modifier = Modifier,
949+) {
950+ // A few example prompts so a first-time user has a one-tap path to their first
951+ // message instead of staring at a blank input. Tapping a chip fills the draft
952+ // (without sending) so the user can edit it before sending.
953+ val suggestions = remember {
954+ listOf(
955+ R.string.empty_chat_suggest_1,
956+ R.string.empty_chat_suggest_2,
957+ R.string.empty_chat_suggest_3,
958+ )
959+ }
960+ Column(
961+ modifier = modifier.padding(32.dp),
962+ horizontalAlignment = Alignment.CenterHorizontally,
963+ ) {
964+ Icon(
965+ Icons.Filled.AutoAwesome,
966+ contentDescription = null,
967+ modifier = Modifier.size(48.dp),
968+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
969+ )
970+ Spacer(Modifier.size(16.dp))
971+ Text(
972+ stringResource(R.string.empty_chat_title),
973+ style = MaterialTheme.typography.titleMedium,
974+ )
975+ Spacer(Modifier.size(8.dp))
976+ Text(
977+ stringResource(R.string.empty_chat_hint),
978+ style = MaterialTheme.typography.bodyMedium,
979+ color = MaterialTheme.colorScheme.onSurfaceVariant,
980+ )
981+ Spacer(Modifier.size(20.dp))
982+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
983+ // so a longer localized prompt doesn't push the chips off-screen on a phone.
984+ FlowRow(
985+ modifier = Modifier.fillMaxWidth(),
986+ horizontalArrangement = Arrangement.spacedBy(8.dp),
987+ verticalArrangement = Arrangement.spacedBy(8.dp),
988+ ) {
989+ suggestions.forEach { resId ->
990+ val text = stringResource(resId)
991+ androidx.compose.material3.AssistChip(
992+ onClick = { onSuggestion(text) },
993+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
994+ )
995+ }
996+ }
997+ }
998+}
999+
1000+/**
1001+ * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1002+ * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1003+ * equality is structural so `snapshotFlow` emits only when something relevant changes.
1004+ */
1005+private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
1006+
1007+/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
1008+ * Covers text, reasoning, and tool output so a pinned view keeps following non-text
1009+ * streaming, not just plain assistant text. O(1). */
1010+private fun streamingContentLength(part: Part?): Int = when (part) {
1011+ is TextPart -> part.text.length
1012+ is ReasoningPart -> part.text.length
1013+ is ToolPart -> when (val st = part.state) {
1014+ is ToolCompleted -> st.output?.length ?: 0
1015+ is ToolError -> st.error?.length ?: 0
1016+ is ToolRunning -> st.title?.length ?: 0
1017+ else -> 0
1018+ }
1019+ else -> 0
1020+}
1021+
1022+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1023+ * dialog but lives in the chat screen so the user can rename without backing out to
1024+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
1025+@Composable
1026+private fun RenameSessionChatDialog(
1027+ initialTitle: String,
1028+ onDismiss: () -> Unit,
1029+ onConfirm: (String) -> Unit,
1030+) {
1031+ var title by rememberSaveable(initialTitle) { mutableStateOf(initialTitle) }
1032+ AlertDialog(
1033+ onDismissRequest = onDismiss,
1034+ title = { Text(stringResource(R.string.rename_session_chat)) },
1035+ text = {
1036+ OutlinedTextField(
1037+ value = title,
1038+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1039+ modifier = Modifier.fillMaxWidth(),
1040+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
1041+ label = { Text(stringResource(R.string.rename_session_chat)) },
1042+ singleLine = true,
1043+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1044+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1045+ )
1046+ },
1047+ confirmButton = {
1048+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1049+ Text(stringResource(R.string.save))
1050+ }
1051+ },
1052+ dismissButton = {
1053+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1054+ },
1055+ )
1056+}
1057+
1058+/**
1059+ * A single item in the chat message list — either a [Message] bubble or a day
1060+ * [Separator] inserted before the first message of a new day. The separator shifts
1061+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1062+ * the [List] of these items (not `messages.size`/`lastIndex`).
1063+ */
1064+private sealed interface MessageListItem {
1065+ val key: Any
1066+ val contentType: Any
1067+
1068+ data class Message(val message: MessageWithParts) : MessageListItem {
1069+ override val key: Any get() = message.info.id
1070+ override val contentType: Any get() = message.info::class
1071+ }
1072+
1073+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1074+ // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1075+ // can share a label (e.g. an untimestamped message — empty label — between two
1076+ // same-day messages, or two untimestamped groups), and a label-only key would
1077+ // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1078+ // stable for a given message ordering so slots aren't needlessly recreated.
1079+ override val key: Any get() = "sep_${ordinal}_$label"
1080+ // Separators share a contentType so the LazyColumn can recycle their slots.
1081+ override val contentType: Any get() = "separator"
1082+ }
1083+}
1084+
1085+/**
1086+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1087+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1088+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1089+ * Messages with no timestamp are grouped under an empty-label separator only if they
1090+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
1091+ *
1092+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1093+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1094+ * labels; older days fall back to a locale-stable medium-date format.
1095+ */
1096+private fun buildMessageListItems(
1097+ messages: List<MessageWithParts>,
1098+ todayLabel: String,
1099+ yesterdayLabel: String,
1100+): List<MessageListItem> {
1101+ if (messages.isEmpty()) return emptyList()
1102+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1103+ val dateFmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM)
1104+ val result = ArrayList<MessageListItem>(messages.size + 4)
1105+ var lastDayKey: String? = null
1106+ var sepOrdinal = 0
1107+ for (message in messages) {
1108+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1109+ val dayKey = ts?.let { dayKey(it) } ?: ""
1110+ if (dayKey != lastDayKey) {
1111+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1112+ lastDayKey = dayKey
1113+ }
1114+ result.add(MessageListItem.Message(message))
1115+ }
1116+ return result
1117+}
1118+
1119+/** A stable bucket key for a timestamp's calendar day (epoch-days as a string). */
1120+private fun dayKey(epochMillis: Long): String {
1121+ val instant = java.time.Instant.ofEpochMilli(epochMillis)
1122+ val date = instant.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
1123+ return date.toEpochDay().toString()
1124+}
1125+
1126+/**
1127+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1128+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1129+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1130+ * messages.
1131+ */
1132+private fun dayLabel(
1133+ dayKey: String,
1134+ ts: Long,
1135+ today: java.time.LocalDate,
1136+ todayLabel: String,
1137+ yesterdayLabel: String,
1138+ dateFmt: java.text.DateFormat,
1139+): String {
1140+ if (dayKey.isEmpty()) return ""
1141+ val epochDay = dayKey.toLongOrNull() ?: return ""
1142+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1143+ return when {
1144+ date == today -> todayLabel
1145+ date == today.minusDays(1) -> yesterdayLabel
1146+ else -> dateFmt.format(java.util.Date(ts))
1147+ }
1148+}
1149+
1150+/**
1151+ * A centered day divider in the message list. Renders nothing for an empty label
1152+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1153+ */
1154+@Composable
1155+private fun DateSeparator(label: String) {
1156+ if (label.isBlank()) return
1157+ Row(
1158+ modifier = Modifier.fillMaxWidth(),
1159+ horizontalArrangement = Arrangement.Center,
1160+ ) {
1161+ Surface(
1162+ color = MaterialTheme.colorScheme.surfaceVariant,
1163+ shape = MaterialTheme.shapes.small,
1164+ ) {
1165+ Text(
1166+ label,
1167+ style = MaterialTheme.typography.labelSmall,
1168+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1169+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1170+ )
1171+ }
1172+ }
1173+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+767
@@ -0,0 +1,767 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Agent
6+import soy.iko.opencode.data.model.Command
7+import soy.iko.opencode.data.model.MessageWithParts
8+import soy.iko.opencode.data.model.ModelOption
9+import soy.iko.opencode.data.model.Permission
10+import soy.iko.opencode.data.model.PermissionReplied
11+import soy.iko.opencode.data.model.PermissionResponse
12+import soy.iko.opencode.data.model.PermissionUpdated
13+import soy.iko.opencode.data.model.SessionDeleted
14+import soy.iko.opencode.data.model.SessionUpdated
15+import soy.iko.opencode.data.model.TextPart
16+import soy.iko.opencode.data.model.defaultOption
17+import soy.iko.opencode.data.model.toOptions
18+import soy.iko.opencode.data.network.EventStreamClient
19+import soy.iko.opencode.data.network.NetworkConfig
20+import soy.iko.opencode.data.repo.SessionRepository
21+import soy.iko.opencode.di.AppContainer
22+import soy.iko.opencode.R
23+import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.util.safeExceptionSummary
25+import android.util.Log
26+import kotlinx.coroutines.ExperimentalCoroutinesApi
27+import kotlinx.coroutines.channels.BufferOverflow
28+import kotlinx.coroutines.channels.Channel
29+import kotlinx.coroutines.flow.Flow
30+import kotlinx.coroutines.flow.MutableStateFlow
31+import kotlinx.coroutines.flow.SharingStarted
32+import kotlinx.coroutines.flow.StateFlow
33+import kotlinx.coroutines.flow.asStateFlow
34+import kotlinx.coroutines.flow.receiveAsFlow
35+import kotlinx.coroutines.CancellationException
36+import kotlinx.coroutines.flow.collectLatest
37+import kotlinx.coroutines.flow.debounce
38+import kotlinx.coroutines.flow.drop
39+import kotlinx.coroutines.flow.first
40+import kotlinx.coroutines.flow.filterIsInstance
41+import kotlinx.coroutines.flow.flatMapLatest
42+import kotlinx.coroutines.flow.flowOf
43+import kotlinx.coroutines.flow.map
44+import kotlinx.coroutines.flow.merge
45+import kotlinx.coroutines.flow.onEach
46+import kotlinx.coroutines.flow.retryWhen
47+import kotlinx.coroutines.flow.stateIn
48+import kotlinx.coroutines.delay
49+import kotlinx.coroutines.launch
50+
51+/**
52+ * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
53+ * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
54+ * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
55+ * inherit a Retry that silently re-submits the last prompt.
56+ */
57+data class ChatError(val message: String, val retryable: Boolean = false)
58+
59+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
60+class ChatViewModel(
61+ private val container: AppContainer,
62+ private val sessionId: String,
63+) : ViewModel() {
64+
65+ private val connection get() = container.activeConnection.value
66+
67+ val connected: Boolean get() = connection != null
68+
69+ private val _loading = MutableStateFlow(true)
70+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
71+
72+ /** Set when the messages flow failed to load (and the list is empty), so the UI
73+ * can render a distinct error state instead of masquerading as an empty
74+ * conversation. Cleared on a successful non-empty emission. */
75+ private val _loadError = MutableStateFlow(false)
76+ val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
77+
78+ /** Tracks whether the messages flow has ever emitted a non-empty list, so
79+ * [messages]' retryWhen can decide whether a re-fetch failure should set
80+ * [loadError] (nothing shown yet → error screen) or just emit a snackbar
81+ * (already showing messages → don't replace the conversation with an error). */
82+ private var hasShownMessages = false
83+
84+ /** True once we've surfaced a snackbar for the current message-load failure streak.
85+ * Reset on any successful emission so a persistent failure doesn't spam a fresh
86+ * snackbar every retry cycle (every few seconds). */
87+ private var loadErrorSnackbarShown = false
88+
89+ /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
90+ * doesn't conflict with the messages flow's loading state. */
91+ private val _reconnecting = MutableStateFlow(false)
92+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
93+
94+ /** When true, the debounced draft collector skips persisting an empty draft so
95+ * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
96+ * debounce timer firing before the send completes. */
97+ private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
98+
99+ /** The draft value this VM last persisted, so the drafts observer can tell its own
100+ * (debounced, possibly stale) echo apart from a genuine external write. Without this
101+ * a debounced persist of an older value could echo back and overwrite newer text the
102+ * user has since typed. */
103+ @Volatile private var lastPersistedDraft: String? = null
104+
105+ /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
106+ * collectLatest to cancel any in-flight fetch and start a fresh one, so a
107+ * manual reload supersedes a stale observeCatalog fetch. */
108+ private val _modelsReload = MutableStateFlow(0)
109+ private val _agentsReload = MutableStateFlow(0)
110+ private val _commandsReload = MutableStateFlow(0)
111+
112+ val messages: StateFlow<List<MessageWithParts>> =
113+ container.activeConnection
114+ .flatMapLatest { conn ->
115+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
116+ }
117+ .onEach {
118+ _loading.value = false
119+ // A value flowed through: the stream recovered, so allow the next distinct
120+ // failure to surface a fresh snackbar again.
121+ loadErrorSnackbarShown = false
122+ if (it.isNotEmpty()) {
123+ hasShownMessages = true
124+ _loadError.value = false
125+ }
126+ }
127+ .retryWhen { cause, _ ->
128+ _loading.value = false
129+ // Only surface the persistent error state when there's nothing to
130+ // show — if we already have messages, a transient re-fetch failure
131+ // is better surfaced as a snackbar (via errorEvents) than by
132+ // replacing the visible conversation with an error screen.
133+ if (!hasShownMessages) _loadError.value = true
134+ // Surface the snackbar only once per failure streak — retryWhen loops
135+ // every retryInitialDelayMs, so emitting here unconditionally would spam a
136+ // new snackbar every few seconds on a persistent load failure.
137+ if (!loadErrorSnackbarShown) {
138+ _errorEvents.trySend(ChatError(container.friendlyError(cause)))
139+ loadErrorSnackbarShown = true
140+ }
141+ // Delay before retrying to avoid a tight loop on persistent errors.
142+ delay(NetworkConfig.retryInitialDelayMs)
143+ true
144+ }
145+ .stateIn(
146+ scope = viewModelScope,
147+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
148+ initialValue = emptyList(),
149+ )
150+
151+ /** Whether the conversation has any messages. Derived separately so the top bar
152+ * (share button enabled state) can observe this cheap boolean instead of the
153+ * full messages list, avoiding per-token recomposition of the app bar during
154+ * streaming. */
155+ val hasMessages: StateFlow<Boolean> = messages
156+ .map { it.isNotEmpty() }
157+ .stateIn(
158+ scope = viewModelScope,
159+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
160+ initialValue = false,
161+ )
162+
163+ private val _running = MutableStateFlow(false)
164+ val running: StateFlow<Boolean> = _running.asStateFlow()
165+
166+ /** True while an abort REST call is in flight, so the Stop button can show a
167+ * spinner and prevent double-taps from firing a second abort. */
168+ private val _aborting = MutableStateFlow(false)
169+ val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
170+
171+ /** A follow-up the user typed while a run was active. send() queues it here and
172+ * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
173+ * no way to send and no indication why the Send button is gone. */
174+ private val _queuedFollowUp = MutableStateFlow<String?>(null)
175+ val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
176+
177+ /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
178+ * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
179+ * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
180+ * composition). Each event is delivered exactly once and not replayed to a
181+ * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
182+ private val _errorEvents = Channel<ChatError>(
183+ capacity = NetworkConfig.snackbarEventBufferCapacity,
184+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
185+ )
186+ val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
187+
188+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
189+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
190+
191+ private val _modelsLoading = MutableStateFlow(true)
192+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
193+
194+ private val _modelsError = MutableStateFlow(false)
195+ val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
196+
197+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
198+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
199+
200+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
201+ container.activeConnection
202+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
203+ .stateIn(
204+ scope = viewModelScope,
205+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
206+ initialValue = EventStreamClient.ConnectionState.Disconnected,
207+ )
208+
209+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
210+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
211+
212+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
213+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
214+
215+ private val _agentsLoading = MutableStateFlow(true)
216+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
217+
218+ private val _agentsError = MutableStateFlow(false)
219+ val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
220+
221+ private val _selectedAgent = MutableStateFlow<String?>(null)
222+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
223+
224+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
225+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
226+
227+ private val _commandsLoading = MutableStateFlow(true)
228+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
229+
230+ private val _commandsError = MutableStateFlow(false)
231+ val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
232+
233+ private val _sessionTitle = MutableStateFlow<String?>(null)
234+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
235+
236+ /** Set when the current session is deleted via SSE, so the UI can navigate away. */
237+ private val _sessionDeleted = MutableStateFlow(false)
238+ val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
239+
240+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
241+ private val _failedDraft = MutableStateFlow<String?>(null)
242+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
243+
244+ /** Per-session draft, persisted so it survives navigation/process death. */
245+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
246+ val draft: StateFlow<String> = _draft.asStateFlow()
247+
248+ init {
249+ // DraftStore loads SharedPreferences asynchronously; on a cold start the
250+ // synchronous get() above returns "" until the background load completes.
251+ // Observe the ready signal and re-seed the draft so it appears in the UI.
252+ viewModelScope.launch {
253+ container.draftStore.ready.collect { ready ->
254+ if (!ready) return@collect
255+ if (_draft.value.isEmpty()) {
256+ _draft.value = container.draftStore.get(sessionId)
257+ }
258+ // Recover a follow-up queued before process death (its in-memory StateFlow
259+ // is gone). If the input is now free, drop it back there for review/send and
260+ // consume the stored copy; otherwise re-queue it so it auto-sends when the
261+ // current run next idles. Guarded on == null so it never clobbers a
262+ // follow-up the user queued after opening this session.
263+ val savedFollowUp = container.draftStore.getFollowUp(sessionId)
264+ if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
265+ if (_draft.value.isEmpty()) {
266+ _draft.value = savedFollowUp
267+ container.draftStore.flushFollowUp(sessionId, "")
268+ } else {
269+ _queuedFollowUp.value = savedFollowUp
270+ }
271+ }
272+ }
273+ }
274+ // Observe the drafts store for external mutations to this session's draft
275+ // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
276+ // before navigation in single-pane mode). Without this, a share injected into
277+ // an already-open session never appears in the input field — the init-time
278+ // read and the ready-only re-seed miss it when the draft is non-empty.
279+ // The VM's own debounced persist writes the same value _draft already holds,
280+ // so those updates are no-ops here (storeValue == _draft.value). The
281+ // suppressDraftPersist guard prevents a stale store value from clobbering
282+ // the deliberate draft clear during a send (the store still holds the
283+ // pre-send draft until the send resolves).
284+ viewModelScope.launch {
285+ container.draftStore.drafts.collect { drafts ->
286+ // Ignore emissions until the initial disk load completes: the load itself
287+ // emits the persisted snapshot, and applying it here would clobber text the
288+ // user typed during the async load (the `ready` observer above seeds an
289+ // empty draft). After ready, emissions are external writes.
290+ if (!container.draftStore.ready.value) return@collect
291+ val storeValue = drafts[sessionId].orEmpty()
292+ // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
293+ // genuine external write (e.g. a two-pane share injection) should overwrite
294+ // the live draft.
295+ if (storeValue == lastPersistedDraft) return@collect
296+ if (storeValue != _draft.value && !suppressDraftPersist.get()) {
297+ _draft.value = storeValue
298+ }
299+ }
300+ }
301+ }
302+
303+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
304+
305+ fun selectAgent(name: String?) { _selectedAgent.value = name }
306+
307+ fun reloadModels() {
308+ val conn = connection ?: return
309+ viewModelScope.launch {
310+ conn.api.invalidateProvidersCache()
311+ _modelsReload.value++
312+ }
313+ }
314+
315+ fun reloadAgents() {
316+ val conn = connection ?: return
317+ viewModelScope.launch {
318+ conn.api.invalidateAgentsCache()
319+ _agentsReload.value++
320+ }
321+ }
322+
323+ fun reloadCommands() {
324+ val conn = connection ?: return
325+ viewModelScope.launch {
326+ conn.api.invalidateCommandsCache()
327+ _commandsReload.value++
328+ }
329+ }
330+
331+ /**
332+ * Shared helper for init-block catalog observers: re-fetches whenever the active
333+ * connection or the catalog's reload trigger changes, invoking [onNull] to clear
334+ * state on null so stale data from the old server doesn't persist. The
335+ * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
336+ * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
337+ * via collectLatest, preventing a stale observe result from overwriting a fresh
338+ * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
339+ */
340+ private fun <T> observeCatalog(
341+ tag: String,
342+ loading: MutableStateFlow<Boolean>,
343+ error: MutableStateFlow<Boolean>,
344+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
345+ onSuccess: (T) -> Unit,
346+ onNull: () -> Unit,
347+ reloadTrigger: StateFlow<Int>,
348+ ) {
349+ viewModelScope.launch {
350+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
351+ val conn = container.activeConnection.value
352+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
353+ loading.value = true
354+ error.value = false
355+ runCatchingCancellable { fetch(conn.api) }
356+ // fetch() does HTTP via withRetry, so the failure can be a
357+ // ClientRequestException whose message embeds the full request URL
358+ // (may contain auth/paths). Log only a scrubbed summary.
359+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
360+ .getOrNull()?.let(onSuccess)
361+ loading.value = false
362+ }
363+ }
364+ }
365+
366+ fun updateDraft(text: String) {
367+ _draft.value = text
368+ }
369+
370+ init {
371+ // Debounce draft persistence so we don't write to disk on every keystroke.
372+ // Skip persisting empty drafts that were set by send() (suppressed via
373+ // suppressDraftPersist) — send() clears the in-memory draft immediately for
374+ // UI feedback but deliberately doesn't persist the clear until the send
375+ // succeeds, so a failed send can restore the draft.
376+ viewModelScope.launch {
377+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
378+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
379+ lastPersistedDraft = text
380+ runCatchingCancellable { container.draftStore.set(sessionId, text) }
381+ .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
382+ }
383+ }
384+ // Reset per-connection state when the active server changes so stale spinners,
385+ // permission dialogs, errors, and agent selections from the old server don't
386+ // persist into the new one.
387+ viewModelScope.launch {
388+ container.activeConnection.collectLatest { conn ->
389+ if (conn == null) {
390+ // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
391+ // arrive to deliver SessionIdle/SessionError, so reset the run state here —
392+ // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
393+ // the RunForegroundService alive, and the Stop button no-ops (abort() early-
394+ // returns with no connection) until a later reconnect happens to clear it.
395+ _running.value = false
396+ _aborting.value = false
397+ _pendingPermission.value = null
398+ return@collectLatest
399+ }
400+ _running.value = false
401+ _pendingPermission.value = null
402+ _failedDraft.value = null
403+ // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
404+ // user intent that is now persisted; wiping it on every (re)connect — which
405+ // this collector does, including transient SSE drops and the cold-start
406+ // restore path — would drop a legitimately queued/recovered follow-up.
407+ _selectedAgent.value = null
408+ _sessionTitle.value = null
409+ try {
410+ conn.events.events.collect { event ->
411+ if (SessionRepository.isIdle(event, sessionId)) {
412+ _running.value = false
413+ // Auto-send a queued follow-up once the previous run finishes,
414+ // so the user's drafted-while-running message isn't lost.
415+ val queued = _queuedFollowUp.value
416+ if (queued != null) {
417+ setQueuedFollowUp(null)
418+ send(queued)
419+ }
420+ }
421+ if (SessionRepository.isError(event, sessionId)) {
422+ _running.value = false
423+ // The run errored, so the queued follow-up won't auto-send.
424+ // Restore it to the input (if free) so the user's typed text
425+ // isn't silently lost; otherwise just clear the queue.
426+ val queued = _queuedFollowUp.value
427+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
428+ _draft.value = queued
429+ }
430+ setQueuedFollowUp(null)
431+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
432+ }
433+ when (event) {
434+ is PermissionUpdated ->
435+ if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
436+ is PermissionReplied ->
437+ if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
438+ is SessionUpdated ->
439+ if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
440+ is SessionDeleted ->
441+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
442+ _sessionDeleted.value = true
443+ }
444+ else -> {}
445+ }
446+ }
447+ } catch (e: CancellationException) {
448+ throw e
449+ } catch (e: Exception) {
450+ Log.w("ChatViewModel", "SSE event collector error", e)
451+ }
452+ }
453+ }
454+ // Load the model catalog; preselect the server default. Failure is non-fatal —
455+ // sending with no model just uses the server's default agent/model.
456+ observeCatalog(
457+ tag = "model catalog",
458+ loading = _modelsLoading,
459+ error = _modelsError,
460+ fetch = { it.providers() },
461+ onSuccess = { resp ->
462+ val options = resp.toOptions()
463+ _models.value = options
464+ // Only (re)apply the server default when there's no valid current
465+ // selection. On re-fetch/reconnect/Retry the user's chosen model must
466+ // survive — keep it as long as it's still present in the refreshed list.
467+ val current = _selectedModel.value
468+ if (current == null || options.none { it.ref == current.ref }) {
469+ _selectedModel.value = resp.defaultOption(options)
470+ }
471+ },
472+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
473+ reloadTrigger = _modelsReload,
474+ )
475+ // Load the agent catalog (non-fatal).
476+ observeCatalog(
477+ tag = "agent catalog",
478+ loading = _agentsLoading,
479+ error = _agentsError,
480+ fetch = { it.agents() },
481+ onSuccess = { _agents.value = it },
482+ onNull = { _agents.value = emptyList() },
483+ reloadTrigger = _agentsReload,
484+ )
485+ // Load the command catalog (non-fatal).
486+ observeCatalog(
487+ tag = "command catalog",
488+ loading = _commandsLoading,
489+ error = _commandsError,
490+ fetch = { it.commands() },
491+ onSuccess = { _commands.value = it },
492+ onNull = { _commands.value = emptyList() },
493+ reloadTrigger = _commandsReload,
494+ )
495+ // Resolve the human-readable session title for the app bar (non-fatal).
496+ // Skips the REST call when the SSE event collector already delivered the title
497+ // (SessionUpdated fires shortly after connect), avoiding a redundant full
498+ // session-list download.
499+ viewModelScope.launch {
500+ container.activeConnection.collectLatest { conn ->
501+ if (conn == null) return@collectLatest
502+ if (_sessionTitle.value != null) return@collectLatest
503+ runCatchingCancellable { conn.repository.listSessions() }
504+ .getOrNull()
505+ ?.firstOrNull { it.id == sessionId }
506+ ?.let { _sessionTitle.value = it.displayTitle }
507+ }
508+ }
509+ // If the SSE stream drops mid-run, the run indicator would spin forever;
510+ // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
511+ viewModelScope.launch {
512+ container.activeConnection.collectLatest { conn ->
513+ if (conn == null) return@collectLatest
514+ try {
515+ conn.events.state.collect { state ->
516+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
517+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
518+ _running.value = false
519+ }
520+ }
521+ } catch (e: CancellationException) {
522+ throw e
523+ } catch (e: Exception) {
524+ Log.w("ChatViewModel", "SSE state collector error", e)
525+ }
526+ }
527+ }
528+ }
529+
530+ /** Sends [text]; returns true on success so the caller can clear the draft only then. */
531+ fun send(text: String): Boolean {
532+ val conn = connection ?: return false
533+ val trimmed = text.trim()
534+ if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
535+ _failedDraft.value = null
536+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
537+ // yet — if the send fails and the process dies before we restore, the draft
538+ // would be lost forever. The persisted draft is cleared only on success.
539+ // suppressDraftPersist prevents the debounced collector from persisting the
540+ // empty draft before the send resolves.
541+ //
542+ // Only clear when the text being sent IS the current draft. send() is also
543+ // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
544+ // the user may have typed a NEW draft since — wiping it would lose that text.
545+ if (_draft.value.trim() == trimmed) {
546+ suppressDraftPersist.set(true)
547+ _draft.value = ""
548+ }
549+ viewModelScope.launch {
550+ runCatchingCancellable {
551+ conn.repository.sendPrompt(
552+ sessionId,
553+ trimmed,
554+ model = _selectedModel.value?.ref,
555+ agent = _selectedAgent.value,
556+ )
557+ }.onFailure {
558+ suppressDraftPersist.set(false)
559+ _failedDraft.value = trimmed
560+ // Only restore the draft if the user hasn't typed anything new since.
561+ if (_draft.value.isBlank()) updateDraft(trimmed)
562+ // Retryable: this is the failed send whose prompt Retry re-submits.
563+ _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
564+ _running.value = false
565+ }.onSuccess {
566+ suppressDraftPersist.set(false)
567+ // Send succeeded — persist the cleared draft, but only if the user hasn't
568+ // typed a new one while the send was in flight. Otherwise clearing the
569+ // store echoes back through the draft observer and wipes the in-progress
570+ // text (data loss). Mirrors the guard on the failure path above.
571+ if (_draft.value.isBlank()) {
572+ lastPersistedDraft = ""
573+ container.draftStore.set(sessionId, "")
574+ }
575+ // Don't reset _running here: the agent continues streaming via SSE.
576+ // _running is cleared on SessionIdle/SessionError (see event collector)
577+ // or when the SSE stream drops (see connection state watcher below).
578+ }
579+ }
580+ return true
581+ }
582+
583+ /** Re-send the last draft whose send failed, if any. */
584+ fun retryFailed() {
585+ val draft = _failedDraft.value ?: return
586+ // Don't clear _failedDraft until send() accepts the text — if _running is
587+ // already true, send() returns false and the draft would be lost forever.
588+ if (send(draft)) {
589+ _failedDraft.value = null
590+ }
591+ }
592+
593+ /**
594+ * Queue [text] to be sent automatically when the current run finishes, or clear any
595+ * queued follow-up when [text] is blank. Used when the user taps Send while a run
596+ * is already active — the Send button is replaced by Stop during a run, but the
597+ * input field stays enabled, so a follow-up typed mid-run would otherwise have
598+ * nowhere to go.
599+ */
600+ fun queueFollowUp(text: String) {
601+ val trimmed = text.trim()
602+ setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
603+ // The text now lives in the queued chip, so clear the input field just as a
604+ // normal send would. No-op for the cancel case (blank text), which must leave
605+ // whatever the user has since typed untouched.
606+ if (trimmed.isNotEmpty()) _draft.value = ""
607+ }
608+
609+ /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
610+ * queued mid-run survives process death (recovered on the next open of this session).
611+ * Passing null / blank clears both the in-memory value and the persisted copy. */
612+ private fun setQueuedFollowUp(text: String?) {
613+ _queuedFollowUp.value = text
614+ container.draftStore.flushFollowUp(sessionId, text.orEmpty())
615+ }
616+
617+ /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
618+ * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
619+ * refreshMessages may not visibly change [connectionState] (it's already
620+ * Connected), so without this the tap appears to do nothing. Clears after a
621+ * short delay or when the connection state next becomes Connected. */
622+ private val _refreshing = MutableStateFlow(false)
623+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
624+
625+ /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
626+ * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
627+ * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
628+ * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
629+ * existing generation-guarded merge. Also clears [loadError] optimistically and
630+ * sets [refreshing] for immediate tap feedback. */
631+ fun refreshMessages() {
632+ val conn = connection ?: return
633+ _loadError.value = false
634+ _refreshing.value = true
635+ conn.events.triggerReconnect()
636+ viewModelScope.launch {
637+ delay(NetworkConfig.refreshFeedbackMs)
638+ _refreshing.value = false
639+ }
640+ }
641+
642+ /** Invoke a slash-command by name via the server's /command endpoint. */
643+ fun runCommand(command: Command) {
644+ val conn = connection ?: return
645+ // A run is already in flight — surface feedback instead of silently dropping the
646+ // command (the picker stays openable during a run and dismisses on select).
647+ if (!_running.compareAndSet(false, true)) {
648+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
649+ return
650+ }
651+ viewModelScope.launch {
652+ runCatchingCancellable {
653+ conn.repository.runCommand(sessionId, command.name, agent = command.agent)
654+ }.onFailure {
655+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
656+ _running.value = false
657+ }
658+ }
659+ }
660+
661+ fun abort() {
662+ val conn = connection ?: return
663+ // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
664+ // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
665+ setQueuedFollowUp(null)
666+ if (!_aborting.compareAndSet(false, true)) return
667+ viewModelScope.launch {
668+ try {
669+ runCatchingCancellable { conn.repository.abort(sessionId) }
670+ .onSuccess { _running.value = false }
671+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
672+ } finally {
673+ _aborting.value = false
674+ }
675+ }
676+ }
677+
678+ /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
679+ * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
680+ * the caller keeps the dialog open so the user can retry without retyping. */
681+ fun renameSession(newTitle: String) {
682+ val conn = connection ?: return
683+ val title = newTitle.trim()
684+ if (title.isEmpty() || title == _sessionTitle.value) return
685+ viewModelScope.launch {
686+ runCatchingCancellable { conn.api.updateSession(sessionId, title) }
687+ .onSuccess { _sessionTitle.value = it.displayTitle }
688+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
689+ }
690+ }
691+
692+ /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
693+ * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
694+ * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
695+ * already viewing the session, so a confirmation dialog guards the action instead. */
696+ fun deleteSession() {
697+ val conn = connection ?: return
698+ viewModelScope.launch {
699+ runCatchingCancellable { conn.repository.deleteSession(sessionId) }
700+ .onSuccess {
701+ container.draftStore.remove(sessionId)
702+ _sessionDeleted.value = true
703+ }
704+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
705+ }
706+ }
707+
708+ /** Reconnect to the most recently used server profile (used when the connection is gone). */
709+ fun reconnect() {
710+ if (container.activeConnection.value != null) return
711+ // Guard against concurrent reconnect calls: the user can tap the button
712+ // multiple times while _reconnecting is true, which would launch parallel
713+ // connect() coroutines that race to set the active connection, leaking the
714+ // intermediate connections.
715+ if (!_reconnecting.compareAndSet(false, true)) return
716+ viewModelScope.launch {
717+ try {
718+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
719+ .getOrDefault(emptyList())
720+ .firstOrNull()
721+ if (recent == null) {
722+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
723+ return@launch
724+ }
725+ runCatchingCancellable {
726+ val conn = container.connect(recent)
727+ conn.api.ping()
728+ }.onFailure {
729+ container.disconnect()
730+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
731+ }
732+ } finally {
733+ _reconnecting.value = false
734+ }
735+ }
736+ }
737+
738+ fun respondPermission(permission: Permission, response: PermissionResponse) {
739+ val conn = connection ?: return
740+ // Clear optimistically; permission.replied will confirm. If the call fails we
741+ // restore the pending permission so the user can retry instead of being stuck
742+ // with a dismissed dialog and a paused tool run.
743+ _pendingPermission.value = null
744+ viewModelScope.launch {
745+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
746+ .onFailure {
747+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
748+ // Only restore if no new permission has arrived in the meantime.
749+ if (_pendingPermission.value == null) _pendingPermission.value = permission
750+ }
751+ }
752+ }
753+
754+ override fun onCleared() {
755+ super.onCleared()
756+ // Flush any pending debounced draft so it survives navigation. Uses an
757+ // asynchronous apply() (not a synchronous commit) so the main thread isn't
758+ // blocked on disk I/O — Android's SharedPreferences framework guarantees
759+ // pending apply() writes are flushed before the process exits.
760+ // Always flush — including an empty draft. Persistence is otherwise debounced, so if
761+ // the user cleared the input and navigated back within the debounce window, the
762+ // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
763+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so an
764+ // unconditional flush commits the clear instead of resurrecting the deleted text.
765+ container.draftStore.flushDraft(sessionId, _draft.value)
766+ }
767+}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+202
@@ -0,0 +1,202 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.foundation.text.KeyboardActions
11+import androidx.compose.foundation.text.KeyboardOptions
12+import androidx.compose.material.icons.Icons
13+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
14+import androidx.compose.material.icons.filled.Close
15+import androidx.compose.material.icons.filled.Search
16+import androidx.compose.material3.CircularProgressIndicator
17+import androidx.compose.material3.ExperimentalMaterial3Api
18+import androidx.compose.material3.Icon
19+import androidx.compose.material3.IconButton
20+import androidx.compose.material3.MaterialTheme
21+import androidx.compose.material3.ModalBottomSheet
22+import androidx.compose.material3.OutlinedTextField
23+import androidx.compose.material3.Text
24+import androidx.compose.material3.AlertDialog
25+import androidx.compose.material3.TextButton
26+import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.saveable.rememberSaveable
31+import androidx.compose.runtime.setValue
32+import androidx.compose.ui.Alignment
33+import androidx.compose.ui.Modifier
34+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.text.input.ImeAction
40+import androidx.compose.ui.text.style.TextOverflow
41+import androidx.compose.ui.unit.dp
42+import androidx.compose.foundation.layout.Box
43+import soy.iko.opencode.data.model.Command
44+import soy.iko.opencode.R
45+
46+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
47+@OptIn(ExperimentalMaterial3Api::class)
48+@Composable
49+fun CommandPickerSheet(
50+ commands: List<Command>,
51+ loading: Boolean,
52+ error: Boolean,
53+ onSelect: (Command) -> Unit,
54+ onRetry: () -> Unit,
55+ onDismiss: () -> Unit,
56+) {
57+ // Persist only the command *name* (a savable String), not the Command itself: a
58+ // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
59+ // routing it through rememberSaveable crashes on rotation while the confirm dialog
60+ // is open. Re-resolve the full Command from the current list for rendering.
61+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
62+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
63+ ModalBottomSheet(onDismissRequest = onDismiss) {
64+ Text(
65+ stringResource(R.string.commands),
66+ style = MaterialTheme.typography.titleMedium,
67+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
68+ )
69+ if (loading) {
70+ val loadingLabel = stringResource(R.string.loading)
71+ Box(
72+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
73+ contentAlignment = Alignment.Center,
74+ ) {
75+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
76+ }
77+ } else if (error && commands.isEmpty()) {
78+ Column(
79+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
80+ horizontalAlignment = Alignment.CenterHorizontally,
81+ ) {
82+ Text(
83+ stringResource(R.string.load_failed),
84+ style = MaterialTheme.typography.bodyMedium,
85+ color = MaterialTheme.colorScheme.error,
86+ )
87+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
88+ }
89+ } else if (commands.isEmpty()) {
90+ Text(
91+ stringResource(R.string.no_commands),
92+ style = MaterialTheme.typography.bodyMedium,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
95+ )
96+ } else {
97+ var query by rememberSaveable { mutableStateOf("") }
98+ val keyboardController = LocalSoftwareKeyboardController.current
99+ OutlinedTextField(
100+ value = query,
101+ onValueChange = { query = it },
102+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
103+ placeholder = { Text(stringResource(R.string.search_commands)) },
104+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105+ trailingIcon = if (query.isNotEmpty()) {
106+ {
107+ IconButton(onClick = { query = "" }) {
108+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
109+ }
110+ }
111+ } else null,
112+ singleLine = true,
113+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
114+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
115+ )
116+ val filtered = remember(commands, query) {
117+ val q = query.trim()
118+ if (q.isEmpty()) commands
119+ else commands.filter {
120+ it.name.contains(q, ignoreCase = true) ||
121+ it.displayDescription.contains(q, ignoreCase = true)
122+ }
123+ }
124+ if (filtered.isEmpty()) {
125+ Text(
126+ stringResource(R.string.no_commands_match, query.trim()),
127+ style = MaterialTheme.typography.bodyMedium,
128+ color = MaterialTheme.colorScheme.onSurfaceVariant,
129+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
130+ )
131+ } else {
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ items(filtered, key = { it.name }) { cmd ->
134+ Column(
135+ modifier = Modifier
136+ .fillMaxWidth()
137+ .clickable(role = Role.Button) {
138+ if (isDestructiveCommand(cmd.name)) {
139+ pendingCommandName = cmd.name
140+ } else {
141+ onSelect(cmd)
142+ onDismiss()
143+ }
144+ }
145+ .padding(horizontal = 24.dp, vertical = 12.dp),
146+ ) {
147+ androidx.compose.foundation.layout.Row(
148+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
149+ ) {
150+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
151+ Text(
152+ "/${cmd.name}",
153+ style = MaterialTheme.typography.bodyLarge,
154+ modifier = Modifier.padding(start = 8.dp),
155+ )
156+ }
157+ Text(
158+ cmd.displayDescription,
159+ style = MaterialTheme.typography.bodySmall,
160+ color = MaterialTheme.colorScheme.onSurfaceVariant,
161+ maxLines = 2,
162+ overflow = TextOverflow.Ellipsis,
163+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
164+ )
165+ }
166+ }
167+ }
168+ }
169+ }
170+ }
171+
172+ // Confirmation for commands whose name suggests they may discard work or reset state,
173+ // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
174+ pendingCommand?.let { cmd ->
175+ AlertDialog(
176+ onDismissRequest = { pendingCommandName = null },
177+ title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
178+ text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
179+ confirmButton = {
180+ TextButton(onClick = {
181+ pendingCommandName = null
182+ onSelect(cmd)
183+ onDismiss()
184+ }) { Text(stringResource(R.string.run)) }
185+ },
186+ dismissButton = {
187+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
188+ },
189+ )
190+ }
191+}
192+
193+/**
194+ * Heuristic for commands that may be destructive (discard work / reset state) and so
195+ * warrant a confirmation step before invocation. Matches common opencode slash-command
196+ * names; non-matching commands invoke immediately.
197+ */
198+private fun isDestructiveCommand(name: String): Boolean {
199+ val n = name.lowercase()
200+ return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
201+ n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
202+}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktnew file+124
@@ -0,0 +1,124 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.MessageWithParts
5+import soy.iko.opencode.data.model.ReasoningPart
6+import soy.iko.opencode.data.model.TextPart
7+import soy.iko.opencode.data.model.ToolCompleted
8+import soy.iko.opencode.data.model.ToolError
9+import soy.iko.opencode.data.model.ToolPart
10+import soy.iko.opencode.data.model.ToolRunning
11+import soy.iko.opencode.data.model.UserMessage
12+
13+/**
14+ * Render a conversation as Markdown for sharing/exporting. Each message becomes a
15+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
17+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
18+ */
19+fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
20+ val sb = StringBuilder(messages.size * 128)
21+ if (!title.isNullOrBlank()) {
22+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
23+ }
24+ for (message in messages) {
25+ val heading = when (message.info) {
26+ is UserMessage -> "## You"
27+ is AssistantMessage -> "## opencode"
28+ else -> null
29+ } ?: continue
30+ // The assistant's reply text is already Markdown (code fences, bold, headings);
31+ // escaping it would render the export as backslash noise. Only escape user-authored
32+ // text, which isn't expected to be Markdown.
33+ val escapeText = message.info is UserMessage
34+ val body = message.parts
35+ .mapNotNull { part ->
36+ when (part) {
37+ is TextPart -> part.text.takeIf { it.isNotBlank() }
38+ ?.let { if (escapeText) escapeMarkdown(it) else it }
39+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
40+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
41+ is ToolPart -> formatToolCall(part)
42+ else -> null
43+ }
44+ }
45+ .joinToString("\n\n")
46+ .trim()
47+ if (body.isEmpty()) continue
48+ sb.append(heading).append("\n\n").append(body).append("\n\n")
49+ }
50+ return sb.toString().trimEnd()
51+}
52+
53+/** Render a tool call as a compact, readable blockquote summary: the tool name, its
54+ * human-readable title (if any), and a truncated output/error. Keeps the exported
55+ * transcript useful when the agent's work (edits, command runs) is the substance. */
56+private fun formatToolCall(part: ToolPart): String? {
57+ val title = when (val s = part.state) {
58+ is ToolRunning -> s.title
59+ is ToolCompleted -> s.title
60+ else -> null
61+ }
62+ val detail = when (val s = part.state) {
63+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
64+ is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
65+ else -> null
66+ }
67+ val status = when (part.state) {
68+ is ToolCompleted -> null
69+ is ToolError -> " — error"
70+ is ToolRunning -> " — running"
71+ else -> null
72+ }
73+ val head = "**${part.tool}**${status ?: ""}"
74+ val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
75+ return buildList {
76+ add("> $head")
77+ if (titleLine != null) add("> $titleLine")
78+ // Prefix every detail line with "> " so the fenced code block stays inside the
79+ // blockquote — otherwise lines after the first fall out of the quote.
80+ if (detail != null) {
81+ val quoted = detail.lines().joinToString("\n") { "> $it" }
82+ // Use a fence longer than the longest backtick run in the content, so tool output
83+ // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
84+ // prematurely close the block and make the rest of the transcript render broken.
85+ val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
86+ add(">\n> $fence\n$quoted\n> $fence")
87+ }
88+ }.joinToString("\n").takeIf { it.isNotBlank() }
89+}
90+
91+/** Length of the longest consecutive run of backticks in [text] (0 if none). */
92+private fun longestBacktickRun(text: String): Int {
93+ var max = 0
94+ var current = 0
95+ for (c in text) {
96+ if (c == '`') {
97+ current++
98+ if (current > max) max = current
99+ } else {
100+ current = 0
101+ }
102+ }
103+ return max
104+}
105+
106+private fun truncateOutput(output: String): String {
107+ if (output.length <= TOOL_OUTPUT_LIMIT) return output
108+ return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
109+}
110+
111+private const val TOOL_OUTPUT_LIMIT = 2000
112+
113+/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
114+private fun escapeMarkdown(text: String): String {
115+ if (text.isEmpty()) return text
116+ val sb = StringBuilder(text.length + 16)
117+ for (c in text) {
118+ when (c) {
119+ '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
120+ }
121+ sb.append(c)
122+ }
123+ return sb.toString()
124+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+278
@@ -0,0 +1,278 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.HelpOutline
12+import androidx.compose.material.icons.filled.ContentCopy
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.IconButton
15+import androidx.compose.material3.MaterialTheme
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.remember
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.draw.clip
22+import androidx.compose.ui.platform.LocalContext
23+import androidx.compose.ui.res.stringResource
24+import androidx.compose.ui.unit.dp
25+import soy.iko.opencode.R
26+import soy.iko.opencode.data.model.AssistantMessage
27+import soy.iko.opencode.data.model.MessageWithParts
28+import soy.iko.opencode.data.model.ModelOption
29+import soy.iko.opencode.data.model.TextPart
30+import soy.iko.opencode.data.model.Tokens
31+import soy.iko.opencode.data.model.UnknownMessage
32+import soy.iko.opencode.data.model.UserMessage
33+import soy.iko.opencode.data.network.NetworkConfig
34+import soy.iko.opencode.ui.components.ImageLoadContext
35+import soy.iko.opencode.ui.components.copyToClipboard
36+import soy.iko.opencode.ui.components.RelativeTimeText
37+
38+/**
39+ * Resolve an assistant message's model id to a friendly label from the loaded catalog,
40+ * falling back to the raw id (so a model not in the catalog still shows something
41+ * meaningful instead of being hidden). Returns null only when the message has no model.
42+ */
43+fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
44+ val id = info.modelID ?: return null
45+ if (models.isEmpty()) return id
46+ val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
47+ return byPair?.modelLabel
48+ ?: models.firstOrNull { it.modelID == id }?.modelLabel
49+ ?: id
50+}
51+
52+/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
53+@Composable
54+fun MessageBubble(
55+ message: MessageWithParts,
56+ modifier: Modifier = Modifier,
57+ isRunning: Boolean = false,
58+ imageContext: ImageLoadContext? = null,
59+ modelLabel: String? = null,
60+ onOpenFile: ((String) -> Unit)? = null,
61+) {
62+ when (message.info) {
63+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile)
64+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
65+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile)
66+ }
67+}
68+
69+@Composable
70+private fun UnknownMessageBlock(
71+ message: MessageWithParts,
72+ imageContext: ImageLoadContext?,
73+ modifier: Modifier,
74+ onOpenFile: ((String) -> Unit)? = null,
75+) {
76+ // Forward-compat: a role the client doesn't model. Render a muted note so the user
77+ // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
78+ // server attached, so content isn't silently dropped.
79+ Column(
80+ modifier = modifier.fillMaxWidth(),
81+ verticalArrangement = Arrangement.spacedBy(8.dp),
82+ ) {
83+ Row(verticalAlignment = Alignment.CenterVertically) {
84+ Icon(
85+ Icons.AutoMirrored.Filled.HelpOutline,
86+ contentDescription = null,
87+ modifier = Modifier.size(14.dp),
88+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
89+ )
90+ Text(
91+ stringResource(R.string.unknown_message),
92+ style = MaterialTheme.typography.labelSmall,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ modifier = Modifier.padding(start = 6.dp),
95+ )
96+ }
97+ for (part in message.parts) {
98+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
99+ }
100+ MessageTimestampText(message.info)
101+ }
102+}
103+
104+@Composable
105+private fun UserBubble(
106+ message: MessageWithParts,
107+ imageContext: ImageLoadContext?,
108+ modifier: Modifier,
109+ onOpenFile: ((String) -> Unit)? = null,
110+) {
111+ val context = LocalContext.current
112+ val copyLabel = stringResource(R.string.copy)
113+ // Collect text from all TextParts for copying, so a user can reuse/repost their
114+ // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
115+ val textToCopy = remember(message.parts) {
116+ message.parts
117+ .filterIsInstance<TextPart>()
118+ .joinToString("\n\n") { it.text }
119+ .takeIf { it.isNotBlank() }
120+ }
121+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
122+ Column(
123+ modifier = Modifier
124+ .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
125+ .clip(MaterialTheme.shapes.medium)
126+ .background(MaterialTheme.colorScheme.primaryContainer)
127+ .padding(12.dp),
128+ verticalArrangement = Arrangement.spacedBy(6.dp),
129+ ) {
130+ for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
131+ Row(
132+ modifier = Modifier.fillMaxWidth(),
133+ horizontalArrangement = Arrangement.SpaceBetween,
134+ verticalAlignment = Alignment.CenterVertically,
135+ ) {
136+ MessageTimestampText(message.info)
137+ // Copy affordance for user prompts, mirroring the assistant block.
138+ // Without it, reusing a prior prompt requires discovering the
139+ // long-press on the markdown text (and only TextParts support that).
140+ if (textToCopy != null) {
141+ IconButton(
142+ onClick = { copyToClipboard(context, "message", textToCopy) },
143+ ) {
144+ Icon(
145+ Icons.Filled.ContentCopy,
146+ contentDescription = copyLabel,
147+ modifier = Modifier.size(18.dp),
148+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
149+ )
150+ }
151+ }
152+ }
153+ }
154+ }
155+}
156+
157+@Composable
158+private fun AssistantBlock(
159+ message: MessageWithParts,
160+ isRunning: Boolean,
161+ imageContext: ImageLoadContext?,
162+ modifier: Modifier,
163+ modelLabel: String? = null,
164+ onOpenFile: ((String) -> Unit)? = null,
165+) {
166+ Column(
167+ modifier = modifier.fillMaxWidth(),
168+ verticalArrangement = Arrangement.spacedBy(8.dp),
169+ ) {
170+ val info = message.info
171+ if (info is AssistantMessage) {
172+ val label = modelLabel ?: info.modelID
173+ if (label != null) {
174+ Text(
175+ label,
176+ style = MaterialTheme.typography.labelSmall,
177+ color = MaterialTheme.colorScheme.onSurfaceVariant,
178+ )
179+ }
180+ }
181+ message.parts.forEachIndexed { index, part ->
182+ // Only the final part of a running message is actively streaming; earlier parts
183+ // (a finished reasoning block, a completed tool call) are already done, so passing
184+ // isRunning to all of them would keep a completed reasoning block showing the
185+ // "Thinking…" spinner until the whole message finishes.
186+ val partStreaming = isRunning && index == message.parts.lastIndex
187+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
188+ }
189+ if (info is AssistantMessage) {
190+ val cost = info.cost
191+ val tokens = info.tokens
192+ val tokenFormat = stringResource(R.string.tokens_in_out)
193+ val costShort = stringResource(R.string.cost_format_short)
194+ val costLong = stringResource(R.string.cost_format_long)
195+ // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
196+ // state-flip recomposition doesn't re-run NumberFormat + buildList +
197+ // joinToString for every visible assistant bubble.
198+ val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
199+ if (!info.isComplete || (cost == null && tokens == null)) null
200+ else buildList {
201+ // Skip an all-zero token count (e.g. a completed message that reported no
202+ // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
203+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
204+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
205+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
206+ }
207+ Row(
208+ modifier = Modifier.fillMaxWidth(),
209+ horizontalArrangement = Arrangement.SpaceBetween,
210+ verticalAlignment = Alignment.CenterVertically,
211+ ) {
212+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
213+ costSummary?.let {
214+ Text(
215+ it,
216+ style = MaterialTheme.typography.labelSmall,
217+ color = MaterialTheme.colorScheme.onSurfaceVariant,
218+ )
219+ }
220+ MessageTimestampText(message.info)
221+ }
222+ val context = LocalContext.current
223+ val copyLabel = stringResource(R.string.copy)
224+ // Collect text from all TextParts for copying. Memoized so a
225+ // scroll-induced recomposition doesn't re-scan the parts list.
226+ val textToCopy = remember(message.parts) {
227+ message.parts
228+ .filterIsInstance<TextPart>()
229+ .joinToString("\n\n") { it.text }
230+ .takeIf { it.isNotBlank() }
231+ }
232+ if (textToCopy != null) {
233+ IconButton(
234+ onClick = { copyToClipboard(context, "message", textToCopy) },
235+ ) {
236+ Icon(
237+ Icons.Filled.ContentCopy,
238+ contentDescription = copyLabel,
239+ modifier = Modifier.size(18.dp),
240+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
241+ )
242+ }
243+ }
244+ }
245+ } else {
246+ MessageTimestampText(message.info)
247+ }
248+ }
249+}
250+
251+@Composable
252+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
253+ // Compact relative label by default; long-press reveals the full absolute timestamp
254+ // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
255+ RelativeTimeText(info.time?.created)
256+}
257+
258+// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
259+// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
260+// when the message list re-seeds after a reconnect and every visible assistant bubble
261+// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
262+// not thread-safe.
263+private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
264+ java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
265+}
266+
267+private fun formatTokens(tokens: Tokens, format: String): String {
268+ val nf = tokenNumberFormat.get()!!
269+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
270+}
271+
272+private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
273+ // Locale.US so the formatting is stable regardless of device locale (avoids
274+ // non-ASCII digits or comma decimal separators in a dollar amount). The format
275+ // string itself is localized via strings.xml so the currency symbol can be
276+ // adapted by translators.
277+ if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
278+ else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+180
@@ -0,0 +1,180 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.heightIn
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
10+import androidx.compose.foundation.lazy.LazyColumn
11+import androidx.compose.foundation.lazy.items
12+import androidx.compose.foundation.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
27+import androidx.compose.runtime.Composable
28+import androidx.compose.runtime.getValue
29+import androidx.compose.runtime.mutableStateOf
30+import androidx.compose.runtime.remember
31+import androidx.compose.runtime.saveable.rememberSaveable
32+import androidx.compose.runtime.setValue
33+import androidx.compose.ui.Alignment
34+import androidx.compose.ui.Modifier
35+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.ModelOption
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every provider/model and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun ModelPickerSheet(
52+ options: List<ModelOption>,
53+ selected: ModelOption?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (ModelOption) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.model),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && options.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (options.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_models),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_models)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(options, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) options
116+ else options.filter {
117+ it.modelLabel.contains(q, ignoreCase = true) ||
118+ it.providerLabel.contains(q, ignoreCase = true)
119+ }
120+ }
121+ if (filtered.isEmpty()) {
122+ Text(
123+ stringResource(R.string.no_models_match, query.trim()),
124+ style = MaterialTheme.typography.bodyMedium,
125+ color = MaterialTheme.colorScheme.onSurfaceVariant,
126+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
127+ )
128+ } else {
129+ // Group by provider so a long catalog is scannable: a provider header
130+ // followed by its models, instead of a flat list mixing providers.
131+ val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ grouped.forEach { (providerID, opts) ->
134+ item(key = "header_$providerID") {
135+ Text(
136+ opts.firstOrNull()?.providerLabel ?: providerID,
137+ style = MaterialTheme.typography.labelMedium,
138+ color = MaterialTheme.colorScheme.primary,
139+ modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
140+ )
141+ }
142+ items(opts, key = { it.providerID to it.modelID }) { option ->
143+ val isSelected = option.providerID == selected?.providerID &&
144+ option.modelID == selected?.modelID
145+ Column(
146+ modifier = Modifier
147+ .fillMaxWidth()
148+ .semantics(mergeDescendants = true) {
149+ if (isSelected) this.selected = true
150+ }
151+ .clickable(role = Role.RadioButton) {
152+ onSelect(option)
153+ onDismiss()
154+ }
155+ .padding(horizontal = 24.dp, vertical = 12.dp),
156+ ) {
157+ Row(verticalAlignment = Alignment.CenterVertically) {
158+ Text(
159+ option.modelLabel,
160+ style = MaterialTheme.typography.bodyLarge,
161+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
162+ modifier = Modifier.weight(1f),
163+ )
164+ if (isSelected) {
165+ Icon(
166+ Icons.Filled.Check,
167+ contentDescription = null,
168+ tint = MaterialTheme.colorScheme.primary,
169+ modifier = Modifier.size(18.dp),
170+ )
171+ }
172+ }
173+ }
174+ }
175+ }
176+ }
177+ }
178+ }
179+ }
180+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+492
@@ -0,0 +1,492 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.text.selection.SelectionContainer
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.HelpOutline
16+import androidx.compose.material.icons.filled.Bolt
17+import androidx.compose.material.icons.filled.CheckCircle
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.Description
20+import androidx.compose.material.icons.filled.Error
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.Psychology
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.saveable.rememberSaveable
35+import androidx.compose.runtime.setValue
36+import androidx.compose.ui.Alignment
37+import androidx.compose.ui.Modifier
38+import androidx.compose.ui.draw.clip
39+import androidx.compose.ui.platform.LocalContext
40+import androidx.compose.ui.semantics.Role
41+import androidx.compose.ui.semantics.contentDescription
42+import androidx.compose.ui.semantics.semantics
43+import androidx.compose.ui.semantics.stateDescription
44+import androidx.compose.ui.text.font.FontFamily
45+import androidx.compose.ui.res.stringResource
46+import androidx.compose.ui.unit.dp
47+import kotlinx.serialization.json.Json
48+import kotlinx.serialization.json.JsonElement
49+import soy.iko.opencode.data.model.FilePart
50+import soy.iko.opencode.data.model.Part
51+import soy.iko.opencode.data.model.ReasoningPart
52+import soy.iko.opencode.data.model.StepFinishPart
53+import soy.iko.opencode.data.model.StepStartPart
54+import soy.iko.opencode.data.model.TextPart
55+import soy.iko.opencode.data.model.ToolCompleted
56+import soy.iko.opencode.data.model.ToolError
57+import soy.iko.opencode.data.model.ToolPart
58+import soy.iko.opencode.data.model.ToolPending
59+import soy.iko.opencode.data.model.ToolRunning
60+import soy.iko.opencode.data.model.ToolState
61+import soy.iko.opencode.data.model.ToolUnknown
62+import soy.iko.opencode.data.model.UnknownPart
63+import soy.iko.opencode.ui.components.DiffView
64+import soy.iko.opencode.ui.components.ImageLoadContext
65+import soy.iko.opencode.ui.components.MarkdownText
66+import soy.iko.opencode.ui.components.RemoteImage
67+import soy.iko.opencode.ui.components.copyToClipboard
68+import soy.iko.opencode.ui.components.isImage
69+import soy.iko.opencode.ui.components.looksLikeDiff
70+import soy.iko.opencode.ui.components.showToast
71+import soy.iko.opencode.R
72+
73+private const val COLLAPSED_LIMIT = 600
74+
75+// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
76+// resilient decoding) so this stays human-readable; constructed lazily and memoized.
77+private val prettyJson: Json = Json { prettyPrint = true }
78+
79+/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
80+private fun ToolState.titleText(): String? = when (this) {
81+ is ToolRunning -> title
82+ is ToolCompleted -> title
83+ else -> null
84+}
85+
86+/** Extract the raw input arguments of a tool call, if any. */
87+private fun ToolState.inputJson(): JsonElement? = when (this) {
88+ is ToolRunning -> input
89+ is ToolCompleted -> input
90+ is ToolError -> input
91+ else -> null
92+}
93+
94+/**
95+ * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
96+ * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
97+ *
98+ * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
99+ * source path the viewer can open; null leaves the chip as a copy-path affordance
100+ * (used when no file navigation is wired, e.g. in tests).
101+ */
102+@Composable
103+fun PartView(
104+ part: Part,
105+ modifier: Modifier = Modifier,
106+ isRunning: Boolean = false,
107+ imageContext: ImageLoadContext? = null,
108+ onOpenFile: ((path: String) -> Unit)? = null,
109+) {
110+ when (part) {
111+ is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
112+ MarkdownText(part.text, modifier = modifier, streaming = isRunning)
113+ }
114+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
115+ is ToolPart -> ToolCallView(part, modifier)
116+ is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
117+ RemoteImage(part, imageContext, modifier)
118+ } else {
119+ FileChip(part, modifier, onOpenFile)
120+ }
121+ is StepStartPart -> {} // boundary marker — nothing to draw
122+ is StepFinishPart -> {} // metrics handled at message level
123+ is UnknownPart -> UnknownPartNote(modifier)
124+ }
125+}
126+
127+@Composable
128+private fun UnknownPartNote(modifier: Modifier) {
129+ // Forward-compat: a part type the client doesn't model. Render a muted note (matching
130+ // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
131+ // the part vanishing silently — important when a new server release adds a part type.
132+ Row(
133+ modifier = modifier.fillMaxWidth(),
134+ verticalAlignment = Alignment.CenterVertically,
135+ ) {
136+ Icon(
137+ Icons.AutoMirrored.Filled.HelpOutline,
138+ contentDescription = null,
139+ modifier = Modifier.size(14.dp),
140+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
141+ )
142+ Text(
143+ stringResource(R.string.unknown_part),
144+ style = MaterialTheme.typography.labelSmall,
145+ color = MaterialTheme.colorScheme.onSurfaceVariant,
146+ modifier = Modifier.padding(start = 6.dp),
147+ )
148+ }
149+}
150+
151+@Composable
152+private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
153+ if (text.isBlank()) return
154+ val context = LocalContext.current
155+ // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
156+ // inherit the positional saveable key of a prior block. A content-prefix key would
157+ // change on nearly every token while streaming and snap the block back to collapsed;
158+ // fall back to a content prefix only when the part has no id.
159+ val saveableKey = keyId.ifBlank { text.take(64) }
160+ var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
161+ val expandedState = stringResource(R.string.state_expanded)
162+ val collapsedState = stringResource(R.string.state_collapsed)
163+ val thinkingLabel = stringResource(R.string.thinking)
164+ val thoughtsLabel = stringResource(R.string.thoughts)
165+ Column(modifier = modifier.fillMaxWidth()) {
166+ Row(
167+ modifier = Modifier
168+ .fillMaxWidth()
169+ .defaultMinSize(minHeight = 48.dp)
170+ .clickable(role = Role.Button) { expanded = !expanded }
171+ .padding(vertical = 4.dp)
172+ .semantics {
173+ stateDescription = if (expanded) expandedState else collapsedState
174+ },
175+ verticalAlignment = Alignment.CenterVertically,
176+ ) {
177+ if (streaming) {
178+ CircularProgressIndicator(
179+ Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
180+ strokeWidth = 2.dp,
181+ color = MaterialTheme.colorScheme.onSurfaceVariant,
182+ )
183+ } else {
184+ Icon(
185+ Icons.Filled.Psychology,
186+ contentDescription = null,
187+ modifier = Modifier.size(16.dp),
188+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
189+ )
190+ }
191+ Text(
192+ if (streaming) thinkingLabel else thoughtsLabel,
193+ style = MaterialTheme.typography.labelMedium,
194+ color = MaterialTheme.colorScheme.onSurfaceVariant,
195+ modifier = Modifier.padding(start = 6.dp),
196+ )
197+ }
198+ AnimatedVisibility(visible = expanded) {
199+ Column {
200+ // Wrap in SelectionContainer so the user can select a portion of the
201+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
202+ // button below. Matches the markdown text's selectability.
203+ SelectionContainer {
204+ Text(
205+ text,
206+ style = MaterialTheme.typography.bodySmall,
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ )
209+ }
210+ TextButton(
211+ onClick = { copyToClipboard(context, "reasoning", text) },
212+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
213+ modifier = Modifier.semantics(mergeDescendants = true) {},
214+ ) {
215+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
216+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
217+ }
218+ }
219+ }
220+ }
221+}
222+
223+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
224+@Composable
225+private fun ToolCallView(part: ToolPart, modifier: Modifier) {
226+ val context = LocalContext.current
227+ Column(
228+ modifier = modifier
229+ .fillMaxWidth()
230+ .clip(MaterialTheme.shapes.small)
231+ .background(MaterialTheme.colorScheme.surfaceVariant)
232+ .padding(10.dp),
233+ ) {
234+ Row(verticalAlignment = Alignment.CenterVertically) {
235+ ToolStatusIcon(part.state)
236+ Text(
237+ part.tool,
238+ style = MaterialTheme.typography.labelLarge,
239+ fontFamily = FontFamily.Monospace,
240+ modifier = Modifier.padding(start = 6.dp),
241+ )
242+ }
243+ // A human-readable summary of what the tool was asked to do (e.g. "Reading
244+ // src/main.kt"), when the server provides one. Sits directly under the tool
245+ // name so the user understands the call before diving into input/output.
246+ part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
247+ Text(
248+ title,
249+ style = MaterialTheme.typography.bodySmall,
250+ color = MaterialTheme.colorScheme.onSurfaceVariant,
251+ modifier = Modifier.padding(start = 22.dp, top = 2.dp),
252+ )
253+ }
254+ // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
255+ // output so the call reads top-to-bottom: name → what → input → result.
256+ part.state.inputJson()?.let { input ->
257+ val inputLabel = stringResource(R.string.tool_input)
258+ val pretty = remember(input) {
259+ runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
260+ }
261+ CollapsibleDetail(
262+ label = inputLabel,
263+ detail = pretty,
264+ isDiff = false,
265+ keySuffix = "input",
266+ // The input block has a label row whose collapse toggle announces its state
267+ // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
268+ // rather than an empty state (the defaults are "").
269+ expandedState = stringResource(R.string.state_expanded),
270+ collapsedState = stringResource(R.string.state_collapsed),
271+ )
272+ }
273+ val detail = when (val s = part.state) {
274+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
275+ is ToolError -> s.error ?: stringResource(R.string.error_generic)
276+ else -> null
277+ }
278+ if (detail != null) {
279+ val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
280+ var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
281+ val expandedState = stringResource(R.string.state_expanded)
282+ val collapsedState = stringResource(R.string.state_collapsed)
283+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
284+ // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
285+ // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
286+ val isDiff = remember(display) { looksLikeDiff(display) }
287+ CollapsibleDetail(
288+ label = null,
289+ detail = display,
290+ isDiff = isDiff,
291+ keySuffix = "output",
292+ expanded = expanded || detail.length <= COLLAPSED_LIMIT,
293+ onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
294+ { expanded = !expanded }
295+ } else null,
296+ // How many lines are hidden while collapsed, so the expand affordance can
297+ // tell the user the scale of what's truncated instead of a bare "Show more".
298+ // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
299+ // 0 (a single long line cut mid-way) falls back to the generic label.
300+ moreLines = if (detail.length > COLLAPSED_LIMIT) {
301+ detail.lines().size - collapsed.lines().size
302+ } else 0,
303+ expandedState = expandedState,
304+ collapsedState = collapsedState,
305+ onCopy = { copyToClipboard(context, "output", detail) },
306+ )
307+ }
308+ }
309+}
310+
311+/**
312+ * A collapsible monospace detail block shared by tool input and output. [label] is an
313+ * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
314+ * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
315+ * with an expand/collapse affordance when [onToggleExpand] is supplied.
316+ */
317+@Composable
318+private fun CollapsibleDetail(
319+ label: String?,
320+ detail: String,
321+ isDiff: Boolean,
322+ keySuffix: String,
323+ expanded: Boolean = false,
324+ onToggleExpand: (() -> Unit)? = null,
325+ moreLines: Int = 0,
326+ expandedState: String = "",
327+ collapsedState: String = "",
328+ onCopy: (() -> Unit)? = null,
329+) {
330+ Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
331+ if (label != null) {
332+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
333+ Row(
334+ modifier = Modifier
335+ .fillMaxWidth()
336+ .defaultMinSize(minHeight = 48.dp)
337+ .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
338+ .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
339+ verticalAlignment = Alignment.CenterVertically,
340+ ) {
341+ Icon(
342+ if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
343+ contentDescription = null,
344+ modifier = Modifier.size(16.dp),
345+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
346+ )
347+ Text(
348+ label,
349+ style = MaterialTheme.typography.labelSmall,
350+ color = MaterialTheme.colorScheme.onSurfaceVariant,
351+ modifier = Modifier.padding(start = 4.dp),
352+ )
353+ }
354+ if (!inputExpanded) return@Column
355+ }
356+ if (isDiff) {
357+ DiffView(detail)
358+ } else {
359+ // SelectionContainer so the user can select a portion of the output (e.g.
360+ // a single line of stdout) instead of only copy-all via the button below.
361+ SelectionContainer {
362+ Text(
363+ detail,
364+ style = MaterialTheme.typography.bodySmall,
365+ fontFamily = FontFamily.Monospace,
366+ )
367+ }
368+ }
369+ if (onToggleExpand != null) {
370+ TextButton(
371+ onClick = onToggleExpand,
372+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
373+ modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
374+ ) {
375+ Icon(
376+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
377+ contentDescription = null,
378+ modifier = Modifier.size(16.dp),
379+ )
380+ Text(
381+ when {
382+ expanded -> stringResource(R.string.show_less)
383+ moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
384+ else -> stringResource(R.string.show_more)
385+ },
386+ style = MaterialTheme.typography.labelSmall,
387+ modifier = Modifier.padding(start = 4.dp),
388+ )
389+ }
390+ }
391+ if (onCopy != null) {
392+ TextButton(
393+ onClick = onCopy,
394+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
395+ modifier = Modifier.semantics(mergeDescendants = true) {},
396+ ) {
397+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
398+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
399+ }
400+ }
401+ }
402+}
403+
404+@Composable
405+private fun ToolStatusIcon(state: ToolState) {
406+ when (state) {
407+ is ToolPending, is ToolRunning -> {
408+ val label = stringResource(R.string.tool_running)
409+ CircularProgressIndicator(
410+ modifier = Modifier.size(16.dp).semantics { contentDescription = label },
411+ strokeWidth = 2.dp,
412+ )
413+ }
414+ is ToolCompleted ->
415+ Icon(
416+ Icons.Filled.CheckCircle,
417+ contentDescription = stringResource(R.string.tool_completed),
418+ modifier = Modifier.size(16.dp),
419+ tint = MaterialTheme.colorScheme.primary,
420+ )
421+ is ToolError ->
422+ Icon(
423+ Icons.Filled.Error,
424+ contentDescription = stringResource(R.string.tool_error),
425+ modifier = Modifier.size(16.dp),
426+ tint = MaterialTheme.colorScheme.error,
427+ )
428+ is ToolUnknown ->
429+ Icon(
430+ Icons.Filled.Bolt,
431+ contentDescription = stringResource(R.string.tool_unknown),
432+ modifier = Modifier.size(16.dp),
433+ )
434+ }
435+}
436+
437+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
438+@Composable
439+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
440+ val context = LocalContext.current
441+ val path = part.source ?: part.url ?: part.filename
442+ val copyLabel = stringResource(R.string.copy_path)
443+ val openLabel = stringResource(R.string.open_file)
444+ // When a source path is available and a navigation callback is wired, tapping the
445+ // chip opens the file in the viewer (the action a user tapping a file reference
446+ // most likely expects). Long-press still copies the path to the clipboard, so the
447+ // copy affordance is preserved without being the default tap action. When no
448+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
449+ val source = part.source
450+ val opener = onOpenFile
451+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
452+ val canOpen = openPath != null
453+ val semanticsLabel = if (canOpen) openLabel else copyLabel
454+ Row(
455+ modifier = modifier
456+ .clip(MaterialTheme.shapes.small)
457+ .background(MaterialTheme.colorScheme.surfaceVariant)
458+ .combinedClickable(
459+ role = Role.Button,
460+ onClick = {
461+ val toOpen = openPath
462+ if (toOpen != null) {
463+ opener?.invoke(toOpen)
464+ } else if (!path.isNullOrBlank()) {
465+ copyToClipboard(context, "path", path)
466+ showToast(context, context.getString(R.string.path_copied))
467+ }
468+ },
469+ onLongClick = {
470+ if (!path.isNullOrBlank()) {
471+ copyToClipboard(context, "path", path)
472+ showToast(context, context.getString(R.string.path_copied))
473+ }
474+ },
475+ )
476+ .semantics { contentDescription = semanticsLabel }
477+ .padding(horizontal = 12.dp, vertical = 6.dp),
478+ verticalAlignment = Alignment.CenterVertically,
479+ ) {
480+ Icon(
481+ Icons.Filled.Description,
482+ contentDescription = null,
483+ modifier = Modifier.size(16.dp),
484+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
485+ )
486+ Text(
487+ part.filename ?: part.url ?: stringResource(R.string.file),
488+ style = MaterialTheme.typography.bodyMedium,
489+ modifier = Modifier.padding(start = 6.dp),
490+ )
491+ }
492+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+118
@@ -0,0 +1,118 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.fillMaxWidth
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Shield
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.OutlinedButton
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.mutableStateOf
19+import androidx.compose.runtime.remember
20+import androidx.compose.runtime.saveable.rememberSaveable
21+import androidx.compose.runtime.getValue
22+import androidx.compose.runtime.setValue
23+import androidx.compose.ui.Modifier
24+import androidx.compose.ui.platform.LocalHapticFeedback
25+import androidx.compose.ui.res.stringResource
26+import androidx.compose.ui.text.font.FontFamily
27+import androidx.compose.ui.unit.dp
28+import soy.iko.opencode.data.model.Permission
29+import soy.iko.opencode.data.model.PermissionResponse
30+import soy.iko.opencode.R
31+
32+/**
33+ * Modal asking the user to approve a tool the agent wants to run. The agent run is
34+ * paused until one of these responses is sent. Non-dismissable by tap-outside so the
35+ * decision is explicit.
36+ */
37+@Composable
38+fun PermissionDialog(
39+ permission: Permission,
40+ onRespond: (PermissionResponse) -> Unit,
41+) {
42+ val haptics = LocalHapticFeedback.current
43+ // Guard against double-respond: back press + button tap, or rapid double-tap,
44+ // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45+ // Keyed on permission.id: the host mounts this dialog against a conflating StateFlow,
46+ // so a null -> permissionB transition can collapse to just permissionB, reusing this
47+ // composable for a different permission. Without the key `responded` would stay true
48+ // and every button (and back-press) would be a no-op — an undismissable modal.
49+ var responded by rememberSaveable(permission.id) { mutableStateOf(false) }
50+ val respond: (PermissionResponse) -> Unit = { response ->
51+ if (!responded) {
52+ responded = true
53+ onRespond(response)
54+ }
55+ }
56+ AlertDialog(
57+ // Back press routes here (dismissOnBackPress defaults true) and is treated as an
58+ // explicit reject — the safe default. Tap-outside is disabled below so it can't
59+ // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
60+ // renders in its own window whose back dispatcher never reaches the host.
61+ onDismissRequest = { respond(PermissionResponse.REJECT) },
62+ properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
63+ icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
64+ title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
65+ text = {
66+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
67+ permission.type?.let {
68+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
69+ }
70+ permission.patternText?.let {
71+ Text(
72+ it,
73+ style = MaterialTheme.typography.bodyMedium,
74+ fontFamily = FontFamily.Monospace,
75+ color = MaterialTheme.colorScheme.onSurfaceVariant,
76+ )
77+ }
78+ Text(
79+ stringResource(R.string.permission_text),
80+ style = MaterialTheme.typography.bodySmall,
81+ color = MaterialTheme.colorScheme.onSurfaceVariant,
82+ )
83+ }
84+ },
85+ confirmButton = {
86+ Column(
87+ modifier = Modifier.fillMaxWidth(),
88+ verticalArrangement = Arrangement.spacedBy(4.dp),
89+ ) {
90+ Button(
91+ onClick = {
92+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
93+ respond(PermissionResponse.ONCE)
94+ },
95+ modifier = Modifier.fillMaxWidth(),
96+ ) { Text(stringResource(R.string.allow_once)) }
97+ OutlinedButton(
98+ onClick = {
99+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
100+ respond(PermissionResponse.ALWAYS)
101+ },
102+ modifier = Modifier.fillMaxWidth(),
103+ ) { Text(stringResource(R.string.always_allow)) }
104+ // Reject is an OutlinedButton (not a low-prominence TextButton) in the
105+ // same column so the three actions are visually balanced — the prior
106+ // layout buried Reject as a small dismiss-button, nudging users toward
107+ // granting. Reject uses the error color to signal its consequence.
108+ OutlinedButton(
109+ onClick = {
110+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
111+ respond(PermissionResponse.REJECT)
112+ },
113+ modifier = Modifier.fillMaxWidth(),
114+ ) { Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error) }
115+ }
116+ },
117+ )
118+}
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktnew file+266
@@ -0,0 +1,266 @@
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+private 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+ val lang = languageFor(filename)
122+ if (lang == Language.NONE) return AnnotatedString(line)
123+ val keywords = keywordsFor(lang)
124+ val builder = AnnotatedString.Builder(line.length + 8)
125+ val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
126+ val mono = FontFamily.Monospace
127+ var i = 0
128+ val n = line.length
129+ while (i < n) {
130+ val c = line[i]
131+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
132+ if (emitted.consumed) {
133+ i = emitted.nextIndex
134+ } else {
135+ builder.withStyle(baseStyle) { append(c) }
136+ i++
137+ }
138+ }
139+ return builder.toAnnotatedString()
140+}
141+
142+/** Result of attempting to emit one token starting at [start]: the next scan index and
143+ * whether a token was consumed (vs. falling through to the default plain-text emit). */
144+private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
145+
146+private fun emitToken(
147+ builder: AnnotatedString.Builder,
148+ line: String,
149+ start: Int,
150+ lang: Language,
151+ keywords: Set<String>,
152+ palette: HighlightPalette,
153+ baseStyle: SpanStyle,
154+ mono: FontFamily,
155+): EmitResult {
156+ val c = line[start]
157+ if (isLineCommentStart(c, line, start, lang)) {
158+ builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
159+ append(line.substring(start))
160+ }
161+ return EmitResult(line.length, true)
162+ }
163+ if (c == '"' || c == '\'') {
164+ val end = findStringEnd(line, start, c)
165+ builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
166+ return EmitResult(end, true)
167+ }
168+ if (isNumberStart(c, line, start)) {
169+ val end = findNumberEnd(line, start)
170+ builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
171+ return EmitResult(end, true)
172+ }
173+ if (c.isLetter() || c == '_' || c == '@') {
174+ val end = findIdentifierEnd(line, start)
175+ val word = line.substring(start, end)
176+ when {
177+ c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
178+ word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
179+ else -> builder.withStyle(baseStyle) { append(word) }
180+ }
181+ return EmitResult(end, true)
182+ }
183+ if (lang == Language.MARKUP && isMarkupDelim(c)) {
184+ builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
185+ return EmitResult(start + 1, true)
186+ }
187+ return EmitResult(start + 1, false)
188+}
189+
190+private fun keywordsFor(lang: Language): Set<String> = when (lang) {
191+ Language.C_FAMILY -> cFamilyKeywords
192+ Language.PYTHON -> pythonKeywords
193+ Language.SHELL -> shellKeywords
194+ Language.MARKUP -> emptySet()
195+ Language.NONE -> emptySet()
196+}
197+
198+private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
199+ // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
200+ // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
201+ (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
202+ // `#` is a line comment only in Python and shell. It is NOT one in C-family
203+ // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
204+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
205+
206+private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
207+
208+private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
209+ c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
210+
211+/** Scan forward to the end of a string literal starting at [start] (the opening quote
212+ * is line[start]). Returns the index just past the closing quote (or line.length if
213+ * the string is unterminated on this line). Honors backslash escapes. */
214+private fun findStringEnd(line: String, start: Int, quote: Char): Int {
215+ var i = start + 1
216+ val n = line.length
217+ while (i < n && line[i] != quote) {
218+ if (line[i] == '\\' && i + 1 < n) i += 2 else i++
219+ }
220+ return if (i < n) i + 1 else n
221+}
222+
223+/** Scan forward to the end of a number literal starting at [start].
224+ *
225+ * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
226+ * and `123abc` must split rather than merge into one token.
227+ * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
228+ * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
229+ * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
230+ * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
231+ */
232+private fun findNumberEnd(line: String, start: Int): Int {
233+ val n = line.length
234+ // Detect a hex prefix ("0x"/"0X") right at the number start.
235+ val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
236+ var i = if (hex) start + 2 else start + 1
237+ var seenDot = line[start] == '.'
238+ var seenExp = false
239+ while (i < n) {
240+ val c = line[i]
241+ val prev = line[i - 1]
242+ when {
243+ c.isDigit() || c == '_' -> i++
244+ hex && isHexDigit(c) -> i++
245+ c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
246+ !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
247+ !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
248+ else -> break
249+ }
250+ }
251+ return i
252+}
253+
254+private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
255+
256+/** Scan forward to the end of an identifier starting at [start]. */
257+private fun findIdentifierEnd(line: String, start: Int): Int {
258+ var i = start
259+ if (line[i] == '@') i++
260+ val n = line.length
261+ while (i < n && isIdentifierPartChar(line[i])) i++
262+ return i
263+}
264+
265+private fun isIdentifierPartChar(c: Char): Boolean =
266+ c.isLetterOrDigit() || c == '_' || c == '$'
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktnew file+121
@@ -0,0 +1,121 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.material3.CircularProgressIndicator
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Surface
14+import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.mutableStateOf
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.res.stringResource
22+import androidx.compose.ui.semantics.contentDescription
23+import androidx.compose.ui.semantics.liveRegion
24+import androidx.compose.ui.semantics.LiveRegionMode
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.unit.dp
27+import soy.iko.opencode.R
28+import soy.iko.opencode.data.network.EventStreamClient
29+
30+/**
31+ * Banner shown when the SSE event stream is not connected. Shared by the chat and the
32+ * session list so a dropped stream is visible everywhere, not just mid-conversation.
33+ *
34+ * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
35+ * when [onRetry] is supplied, so the user can re-connect without hunting for the
36+ * chat screen's reconnect button or navigating to the server list. Transient
37+ * connecting/reconnecting states remain non-interactive (the system is already trying).
38+ */
39+@Composable
40+fun ConnectionBanner(
41+ state: EventStreamClient.ConnectionState,
42+ modifier: Modifier = Modifier,
43+ isOnline: Boolean = true,
44+ onRetry: (() -> Unit)? = null,
45+) {
46+ // When the device itself has no connectivity, surface that distinctly (instead of
47+ // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
48+ // credentials", neither of which reflects the real problem.
49+ val text = if (!isOnline) {
50+ stringResource(R.string.offline)
51+ } else {
52+ when (state) {
53+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
54+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
55+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
56+ EventStreamClient.ConnectionState.Connected -> null
57+ }
58+ }
59+ val isOffline = !isOnline
60+ // AnimatedVisibility so the banner slides/fades in and out instead of appearing
61+ // and disappearing instantly — a state change that's especially jarring when the
62+ // connection flaps, and was called out as a rough edge in the UX audit.
63+ // Retain the last non-null banner text so the exit fade-out has content to render.
64+ // The content lambda recomposes with the now-null `text` during the exit animation;
65+ // early-returning on null there would leave nothing to fade, so the banner would just
66+ // vanish instead of fading out.
67+ val lastText = remember { mutableStateOf(text) }
68+ if (text != null) lastText.value = text
69+ AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
70+ val shown = lastText.value ?: return@AnimatedVisibility
71+ // Distinguish a hard failure (e.g. bad credentials) and an offline device from
72+ // transient connecting/reconnecting states by switching to the error palette,
73+ // so the banner conveys urgency without relying on text alone.
74+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
75+ val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
76+ else MaterialTheme.colorScheme.tertiaryContainer
77+ val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
78+ else MaterialTheme.colorScheme.onTertiaryContainer
79+ Surface(
80+ modifier = modifier
81+ .fillMaxWidth()
82+ .semantics {
83+ // Announce connection state changes to TalkBack users so they're
84+ // aware the stream dropped/reconnecting without visual cues.
85+ liveRegion = LiveRegionMode.Polite
86+ contentDescription = shown
87+ },
88+ color = container,
89+ ) {
90+ Row(
91+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
92+ horizontalArrangement = Arrangement.Center,
93+ verticalAlignment = Alignment.CenterVertically,
94+ ) {
95+ if (!isFailed) {
96+ CircularProgressIndicator(
97+ Modifier.size(16.dp),
98+ strokeWidth = 2.dp,
99+ )
100+ }
101+ Text(
102+ shown,
103+ style = MaterialTheme.typography.labelMedium,
104+ color = onContainer,
105+ modifier = Modifier
106+ .padding(start = 6.dp)
107+ .weight(1f, fill = false),
108+ )
109+ // Inline retry on the Failed banner so the user can recover without
110+ // leaving the screen. Hidden during transient states (the system is
111+ // already reconnecting), when offline (retry can't help), and when no
112+ // callback is wired (callers that don't have a reconnect path).
113+ if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
114+ TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
115+ Text(stringResource(R.string.retry_now), color = onContainer)
116+ }
117+ }
118+ }
119+ }
120+ }
121+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+218
@@ -0,0 +1,218 @@
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+ val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
129+ val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
130+ val addText = MaterialTheme.colorScheme.primary
131+ val removeText = MaterialTheme.colorScheme.error
132+ val context = LocalContext.current
133+ val hScrollState = rememberScrollState()
134+ var expanded by rememberSaveable(diff) { mutableStateOf(false) }
135+
136+ Column(
137+ modifier = modifier
138+ .fillMaxWidth()
139+ .clip(MaterialTheme.shapes.small)
140+ .background(MaterialTheme.colorScheme.surfaceVariant),
141+ ) {
142+ Row(
143+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
144+ horizontalArrangement = Arrangement.End,
145+ ) {
146+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
147+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
148+ }
149+ }
150+ val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
151+ else lines.subList(0, COLLAPSED_DIFF_LINES)
152+ // One horizontalScroll on the container instead of one per row: each modifier
153+ // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
154+ // paying for 200 of them. The shared scroll state still synchronizes all rows.
155+ Column(modifier = Modifier.horizontalScroll(hScrollState)) {
156+ visibleLines.forEach { line ->
157+ Row(modifier = Modifier.padding(horizontal = 10.dp)) {
158+ when (line) {
159+ is DiffLine.Hunk -> Text(
160+ line.text,
161+ style = MaterialTheme.typography.bodySmall,
162+ fontFamily = FontFamily.Monospace,
163+ color = MaterialTheme.colorScheme.tertiary,
164+ modifier = Modifier.padding(vertical = 2.dp),
165+ )
166+ is DiffLine.FileHeader -> Text(
167+ line.text,
168+ style = MaterialTheme.typography.bodySmall,
169+ fontFamily = FontFamily.Monospace,
170+ color = MaterialTheme.colorScheme.onSurfaceVariant,
171+ modifier = Modifier.padding(vertical = 1.dp),
172+ )
173+ is DiffLine.Meta -> Text(
174+ line.text,
175+ style = MaterialTheme.typography.bodySmall,
176+ fontFamily = FontFamily.Monospace,
177+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
178+ modifier = Modifier.padding(vertical = 1.dp),
179+ )
180+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
181+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
182+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
183+ }
184+ }
185+ }
186+ }
187+ if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
188+ TextButton(
189+ onClick = { expanded = true },
190+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
191+ ) {
192+ Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
193+ }
194+ }
195+ Spacer(Modifier.height(10.dp))
196+ }
197+}
198+
199+@Composable
200+private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
201+ Row(
202+ modifier = Modifier.background(bg),
203+ ) {
204+ Text(
205+ prefix,
206+ style = MaterialTheme.typography.bodySmall,
207+ fontFamily = FontFamily.Monospace,
208+ color = textColor,
209+ modifier = Modifier.width(16.dp),
210+ )
211+ Text(
212+ text,
213+ style = MaterialTheme.typography.bodySmall,
214+ fontFamily = FontFamily.Monospace,
215+ color = textColor,
216+ )
217+ }
218+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+227
@@ -0,0 +1,227 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.background
8+import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.layout.Box
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.rememberScrollState
14+import androidx.compose.foundation.text.selection.SelectionContainer
15+import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.ContentCopy
17+import androidx.compose.material3.Icon
18+import androidx.compose.material3.IconButton
19+import androidx.compose.material3.MaterialTheme
20+import androidx.compose.material3.Surface
21+import androidx.compose.material3.Text
22+import androidx.compose.runtime.Composable
23+import androidx.compose.runtime.LaunchedEffect
24+import androidx.compose.runtime.getValue
25+import androidx.compose.runtime.mutableStateOf
26+import androidx.compose.runtime.remember
27+import androidx.compose.runtime.rememberUpdatedState
28+import androidx.compose.runtime.setValue
29+import androidx.compose.runtime.snapshotFlow
30+import androidx.compose.ui.Alignment
31+import androidx.compose.ui.Modifier
32+import androidx.compose.ui.draw.clip
33+import androidx.compose.ui.platform.LocalContext
34+import androidx.compose.ui.text.TextStyle
35+import androidx.compose.ui.text.font.FontFamily
36+import androidx.compose.ui.unit.dp
37+import kotlinx.coroutines.flow.conflate
38+import kotlinx.coroutines.delay
39+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
40+import com.mikepenz.markdown.compose.components.markdownComponents
41+import com.mikepenz.markdown.m3.Markdown
42+import org.intellij.markdown.MarkdownElementTypes
43+import org.intellij.markdown.ast.ASTNode
44+import org.intellij.markdown.ast.getTextInNode
45+import soy.iko.opencode.R
46+import soy.iko.opencode.data.network.NetworkConfig
47+
48+/**
49+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
50+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
51+ * Code blocks/fences get an inline copy button.
52+ *
53+ * The rendered text is wrapped in a [SelectionContainer] so the user can select and
54+ * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
55+ * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
56+ * copies all TextParts; this complements it with partial selection.
57+ *
58+ * During streaming, the full markdown is re-parsed on every token (the library re-parses
59+ * whenever the content string changes). To avoid O(n²) work during long responses, the
60+ * rendered content is throttled — the latest [markdown] is committed to the renderer at
61+ * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
62+ *
63+ * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
64+ * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
65+ * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
66+ * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
67+ * starving the render and showing stale content until the stream pauses. The conflated
68+ * `collect` lets the delay run to completion, then picks up the most recent buffered value,
69+ * so rendering always progresses even under continuous fast streaming.
70+ *
71+ * When [streaming] is false (the common case: every non-active message in the chat list),
72+ * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
73+ * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
74+ */
75+@Composable
76+fun MarkdownText(
77+ markdown: String,
78+ modifier: Modifier = Modifier,
79+ style: TextStyle = MaterialTheme.typography.bodyLarge,
80+ streaming: Boolean = false,
81+) {
82+ val context = LocalContext.current
83+ val components = remember {
84+ markdownComponents(
85+ codeFence = { CodeWithCopy(it) },
86+ codeBlock = { CodeWithCopy(it) },
87+ )
88+ }
89+ if (streaming) {
90+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
91+ val markdownState = rememberUpdatedState(markdown)
92+ // Keyed to a content-prefix so switching to a different message resets
93+ // immediately instead of showing the old message for one frame.
94+ val prefix = markdown.take(32)
95+ var renderedContent by remember(prefix) { mutableStateOf(markdown) }
96+ // conflate() coalesces a burst of token updates into a single emission so the
97+ // collector sees only the latest value after the previous delay completes. Plain
98+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
99+ // on every new value, reintroducing the same starvation the keyed effect had.
100+ LaunchedEffect(prefix) {
101+ snapshotFlow { markdownState.value }
102+ .conflate()
103+ .collect { md ->
104+ // Only throttle when the content is growing incrementally (streaming):
105+ // a content switch or initial render proceeds immediately. Checking
106+ // length is O(1) vs. startsWith which is O(n) in the rendered content
107+ // length — during a long streaming response this runs every throttle
108+ // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
109+ // content switches; within one keyed cycle, growing length means
110+ // appending (streaming), not a rewrite.
111+ if (renderedContent.isNotEmpty() &&
112+ md.length > renderedContent.length
113+ ) {
114+ delay(NetworkConfig.streamingThrottleMs)
115+ }
116+ renderedContent = md
117+ }
118+ }
119+ // SelectionContainer is intentionally omitted during streaming: the content is
120+ // changing every ~50ms, and an active selection would be invalidated (and the
121+ // selection handles would flicker) on each throttle commit. Partial selection
122+ // becomes available once the stream finishes; the per-message Copy button in
123+ // MessageBubble copies all TextParts at any time.
124+ Markdown(
125+ content = renderedContent,
126+ modifier = modifier,
127+ components = components,
128+ )
129+ } else {
130+ SelectionContainer {
131+ Markdown(
132+ content = markdown,
133+ modifier = modifier,
134+ components = components,
135+ )
136+ }
137+ }
138+}
139+
140+/**
141+ * Renders a fenced or indented code block with a small copy button in the top-right corner.
142+ * Self-contained so it doesn't depend on the library's internal code-block composables.
143+ */
144+@Composable
145+private fun CodeWithCopy(model: MarkdownComponentModel) {
146+ val context = LocalContext.current
147+ // Key on the content string + the AST node's offset range (stable ints) instead of
148+ // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
149+ // ASTNode), so remembering on it re-executes extractCode on every recomposition.
150+ val node = model.node
151+ val code = remember(model.content, node.startOffset, node.endOffset) {
152+ extractCode(model.content, node)
153+ }
154+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
155+ Box(
156+ modifier = Modifier
157+ .fillMaxWidth()
158+ .clip(MaterialTheme.shapes.small)
159+ .background(MaterialTheme.colorScheme.surfaceVariant),
160+ ) {
161+ Text(
162+ text = code,
163+ style = codeStyle,
164+ modifier = Modifier
165+ .horizontalScroll(rememberScrollState())
166+ .fillMaxWidth()
167+ .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
168+ )
169+ Surface(
170+ color = MaterialTheme.colorScheme.surface,
171+ shape = MaterialTheme.shapes.extraSmall,
172+ modifier = Modifier
173+ .align(Alignment.TopEnd)
174+ .padding(4.dp),
175+ ) {
176+ IconButton(
177+ onClick = { copyToClipboard(context, "code", code) },
178+ modifier = Modifier.size(48.dp),
179+ ) {
180+ Icon(
181+ Icons.Filled.ContentCopy,
182+ contentDescription = context.getString(R.string.copy),
183+ modifier = Modifier.size(14.dp),
184+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
185+ )
186+ }
187+ }
188+ }
189+}
190+
191+/**
192+ * Extract the raw code text from a markdown code node, stripping fence markers
193+ * (```/~~~ and the language tag) so only the code content is copied.
194+ */
195+private fun extractCode(content: String, node: ASTNode): String =
196+ extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
197+
198+/**
199+ * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
200+ * only the code content is returned. Extracted as a pure function for testability.
201+ *
202+ * @param raw the raw text of the code node (including fence markers for fenced blocks)
203+ * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
204+ */
205+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
206+ if (!isFenced) return raw.trimIndent()
207+ val lines = raw.lines()
208+ val body = lines.drop(1).toMutableList()
209+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
210+ body.removeAt(body.lastIndex)
211+ }
212+ return body.joinToString("\n").trimEnd()
213+}
214+
215+/** Copy [text] to the system clipboard and show a confirmation toast. */
216+internal fun copyToClipboard(context: Context, label: String, text: String = label) {
217+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
218+ clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
219+ showToast(context, context.getString(R.string.copied))
220+}
221+
222+private var lastToast: Toast? = null
223+
224+internal fun showToast(context: Context, message: String) {
225+ lastToast?.cancel()
226+ lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }
227+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktdeleted−479
@@ -1,479 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.content.Intent
7−import android.net.Uri
8−import android.widget.Toast
9−import androidx.compose.foundation.ExperimentalFoundationApi
10−import androidx.compose.foundation.background
11−import androidx.compose.foundation.clickable
12−import androidx.compose.foundation.combinedClickable
13−import androidx.compose.foundation.layout.Arrangement
14−import androidx.compose.foundation.layout.Column
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.fillMaxWidth
17−import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
19−import androidx.compose.foundation.layout.widthIn
20−import androidx.compose.foundation.shape.RoundedCornerShape
21−import androidx.compose.foundation.text.selection.SelectionContainer
22−import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Build
24−import androidx.compose.material.icons.filled.Check
25−import androidx.compose.material.icons.filled.ErrorOutline
26−import androidx.compose.material.icons.filled.ExpandLess
27−import androidx.compose.material.icons.filled.ExpandMore
28−import androidx.compose.material.icons.filled.Schedule
29−import androidx.compose.material.icons.filled.SmartToy
30−import androidx.compose.material3.DropdownMenu
31−import androidx.compose.material3.DropdownMenuItem
32−import androidx.compose.material3.Icon
33−import androidx.compose.material3.MaterialTheme
34−import androidx.compose.material3.Text
35−import androidx.compose.runtime.Composable
36−import androidx.compose.runtime.getValue
37−import androidx.compose.runtime.mutableStateOf
38−import androidx.compose.runtime.remember
39−import androidx.compose.runtime.setValue
40−import androidx.compose.ui.Alignment
41−import androidx.compose.ui.Modifier
42−import androidx.compose.ui.draw.clip
43−import androidx.compose.ui.graphics.Color
44−import androidx.compose.ui.platform.LocalContext
45−import androidx.compose.ui.text.AnnotatedString
46−import androidx.compose.ui.text.LinkAnnotation
47−import androidx.compose.ui.text.SpanStyle
48−import androidx.compose.ui.text.TextLinkStyles
49−import androidx.compose.ui.text.buildAnnotatedString
50−import androidx.compose.ui.text.font.FontFamily
51−import androidx.compose.ui.text.font.FontStyle
52−import androidx.compose.ui.text.font.FontWeight
53−import androidx.compose.ui.text.withLink
54−import androidx.compose.ui.text.withStyle
55−import androidx.compose.ui.text.style.TextDecoration
56−import androidx.compose.ui.text.style.TextOverflow
57−import androidx.compose.ui.unit.dp
58−import androidx.compose.ui.unit.sp
59−import soy.iko.opencode.core.MessageStatus
60−import soy.iko.opencode.core.MessageView
61−import soy.iko.opencode.core.ToolView
62−import soy.iko.opencode.ui.theme.MonoStyle
63−
64−private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65−
66−@OptIn(ExperimentalFoundationApi::class)
67−@Composable
68−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69− val isUser = message.isUser
70− val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71− val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72− val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73− val shape = RoundedCornerShape(
74− topStart = 16.dp,
75− topEnd = 16.dp,
76− bottomStart = if (isUser) 16.dp else 4.dp,
77− bottomEnd = if (isUser) 4.dp else 16.dp,
78− )
79−
80− val context = LocalContext.current
81− var showMenu by remember { mutableStateOf(false) }
82−
83− Row(
84− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86− ) {
87− if (!isUser) {
88− Icon(
89− Icons.Filled.SmartToy,
90− contentDescription = null,
91− tint = MaterialTheme.colorScheme.primary,
92− modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93− )
94− }
95− Column(
96− modifier = Modifier
97− .fillMaxWidth(0.85f)
98− .widthIn(max = 320.dp)
99− .clip(shape)
100− .background(bubbleColor)
101− .combinedClickable(
102− onClick = {},
103− onLongClick = {
104− if (message.text.isNotEmpty()) {
105− showMenu = true
106− }
107− },
108− )
109− .padding(horizontal = 14.dp, vertical = 10.dp),
110− ) {
111− DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112− DropdownMenuItem(
113− text = { Text("Copy") },
114− onClick = {
115− copyToClipboard(context, message.text)
116− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117− showMenu = false
118− },
119− )
120− DropdownMenuItem(
121− text = { Text("Share") },
122− onClick = {
123− shareText(context, message.text)
124− showMenu = false
125− },
126− )
127− }
128−
129− message.reasoning?.let { ReasoningBlock(it, context) }
130−
131− // Give a quick overview of how many tools ran before listing them.
132− if (message.tools.size > 1) {
133− Text(
134− text = "${message.tools.size} tool calls",
135− style = MaterialTheme.typography.labelSmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− modifier = Modifier.padding(bottom = 4.dp),
138− )
139− }
140−
141− message.tools.forEach { ToolRow(it) }
142−
143− SelectionContainer {
144− if (message.text.isNotEmpty()) {
145− val linkColor = MaterialTheme.colorScheme.primary
146− // Tint the code background off the bubble's own foreground colour so it
147− // contrasts on both bubble types (user bubbles use primaryContainer).
148− val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149− val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150− buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151− }
152− Text(
153− text = annotatedText,
154− color = textColor,
155− // Give assistant replies (often long, code-heavy) extra line
156− // spacing for readability; user bubbles keep the default.
157− style = if (isUser) {
158− MaterialTheme.typography.bodyLarge
159− } else {
160− MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161− },
162− )
163− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165− }
166− }
167−
168− if (message.time > 0) {
169− Text(
170− text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171− style = MaterialTheme.typography.labelSmall,
172− color = mutedColor,
173− modifier = Modifier.padding(top = 4.dp),
174− )
175− }
176−
177− if (isUser && message.status != MessageStatus.Sent) {
178− StatusLine(message.status, onRetry = onRetry)
179− }
180− }
181− }
182−}
183−
184−private fun copyToClipboard(context: Context, text: String) {
185− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186− clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187−}
188−
189−private val urlRegex = Regex("""https?://[^\s]+""")
190−
191−/**
192− * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193− * prose and code segments: code segments are rendered monospace on a tinted
194− * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195− * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196− */
197−private fun buildLinkedText(
198− text: String,
199− streaming: Boolean,
200− linkColor: Color,
201− codeBackground: Color,
202− context: Context,
203−): AnnotatedString = buildAnnotatedString {
204− // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205− // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206− val segments = text.split("```")
207− segments.forEachIndexed { index, segment ->
208− if (index % 2 == 1) {
209− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210− append(segment)
211− }
212− } else {
213− appendLinkedText(segment, linkColor, codeBackground, context)
214− }
215− }
216− if (streaming) append(" ▌")
217−}
218−
219−/**
220− * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221− * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222− * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223− * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224− * inline spans reach here.
225− */
226−private fun AnnotatedString.Builder.appendLinkedText(
227− text: String,
228− linkColor: Color,
229− codeBackground: Color,
230− context: Context,
231−) {
232− // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233− // inline code and links inside it. Odd-indexed segments sit between a pair of **
234− // markers, so they're bold (markers dropped); even segments are ordinary prose.
235− // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236− // segment on "*" would misfire on the leftover asterisks from the ** markers.
237− val segments = text.split("**")
238− // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239− // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240− val unclosed = segments.size % 2 == 0
241− segments.forEachIndexed { index, segment ->
242− val isUnclosedTail = unclosed && index == segments.size - 1
243− if (index % 2 == 1 && !isUnclosedTail) {
244− withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245− appendInlineCode(segment, linkColor, codeBackground, context)
246− }
247− } else {
248− if (isUnclosedTail) append("**")
249− appendItalicText(segment, linkColor, codeBackground, context)
250− }
251− }
252−}
253−
254−/**
255− * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256− * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257− * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258− */
259−private fun AnnotatedString.Builder.appendItalicText(
260− text: String,
261− linkColor: Color,
262− codeBackground: Color,
263− context: Context,
264−) {
265− // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266− // (markers dropped); even segments are ordinary prose.
267− val segments = text.split("*")
268− // An even segment count means an odd number of * markers, so the final * is unclosed:
269− // its trailing segment isn't italic. Restore the literal * and render it as prose.
270− val unclosed = segments.size % 2 == 0
271− segments.forEachIndexed { index, segment ->
272− val isUnclosedTail = unclosed && index == segments.size - 1
273− if (index % 2 == 1 && !isUnclosedTail) {
274− withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275− appendInlineCode(segment, linkColor, codeBackground, context)
276− }
277− } else {
278− if (isUnclosedTail) append("*")
279− appendInlineCode(segment, linkColor, codeBackground, context)
280− }
281− }
282−}
283−
284−/**
285− * Append [text] to the builder, rendering single-backtick pairs as inline code
286− * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287− * into a clickable link.
288− */
289−private fun AnnotatedString.Builder.appendInlineCode(
290− text: String,
291− linkColor: Color,
292− codeBackground: Color,
293− context: Context,
294−) {
295− // Odd-indexed segments sit between a pair of single backticks, so they're inline
296− // code. Even segments are ordinary prose and get URL linking applied.
297− val segments = text.split("`")
298− // An even segment count means an odd number of backtick markers, so the final backtick
299− // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300− val unclosed = segments.size % 2 == 0
301− segments.forEachIndexed { index, segment ->
302− val isUnclosedTail = unclosed && index == segments.size - 1
303− if (index % 2 == 1 && !isUnclosedTail) {
304− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305− append(segment)
306− }
307− } else {
308− if (isUnclosedTail) append("`")
309− appendUrls(segment, linkColor, context)
310− }
311− }
312−}
313−
314−/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315−private fun AnnotatedString.Builder.appendUrls(
316− text: String,
317− linkColor: Color,
318− context: Context,
319−) {
320− val matches = urlRegex.findAll(text).toList()
321− var cursor = 0
322− for (match in matches) {
323− append(text.substring(cursor, match.range.first))
324− val url = match.value
325− withLink(
326− LinkAnnotation.Clickable(
327− tag = url,
328− styles = TextLinkStyles(
329− SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330− ),
331− linkInteractionListener = {
332− runCatching {
333− context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334− }
335− },
336− ),
337− ) {
338− append(url)
339− }
340− cursor = match.range.last + 1
341− }
342− append(text.substring(cursor))
343−}
344−
345−private fun shareText(context: Context, text: String) {
346− val intent = Intent(Intent.ACTION_SEND).apply {
347− type = "text/plain"
348− putExtra(Intent.EXTRA_TEXT, text)
349− }
350− context.startActivity(Intent.createChooser(intent, null))
351−}
352−
353−@Composable
354−private fun ReasoningBlock(text: String, context: Context) {
355− var expanded by remember { mutableStateOf(false) }
356− Column(modifier = Modifier.padding(bottom = 6.dp)) {
357− Row(
358− verticalAlignment = Alignment.CenterVertically,
359− modifier = Modifier.clickable { expanded = !expanded },
360− ) {
361− Text(
362− text = if (expanded) "Show less" else "Reasoning",
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
365− )
366− Icon(
367− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368− contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369− tint = MaterialTheme.colorScheme.onSurfaceVariant,
370− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371− )
372− }
373− if (expanded) {
374− Text(
375− text = text,
376− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377− color = MaterialTheme.colorScheme.onSurfaceVariant,
378− modifier = Modifier.padding(top = 4.dp),
379− )
380− Text(
381− text = "Copy",
382− style = MaterialTheme.typography.labelSmall,
383− color = MaterialTheme.colorScheme.primary,
384− modifier = Modifier
385− .padding(top = 4.dp)
386− .clickable {
387− copyToClipboard(context, text)
388− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389− },
390− )
391− }
392− }
393−}
394−
395−@Composable
396−private fun ToolRow(tool: ToolView) {
397− var expanded by remember { mutableStateOf(false) }
398− val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399− val statusColor = when (tool.status) {
400− "completed", "success" -> MaterialTheme.colorScheme.primary
401− "error", "failed" -> MaterialTheme.colorScheme.error
402− "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403− else -> MaterialTheme.colorScheme.onSurfaceVariant
404− }
405− Row(
406− verticalAlignment = Alignment.CenterVertically,
407− modifier = Modifier
408− .fillMaxWidth()
409− .clickable { expanded = !expanded }
410− .padding(bottom = 6.dp),
411− ) {
412− Icon(
413− Icons.Filled.Build,
414− contentDescription = null,
415− tint = MaterialTheme.colorScheme.primary,
416− modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417− )
418− Text(
419− text = buildAnnotatedString {
420− withStyle(SpanStyle(color = baseColor)) {
421− append(tool.name)
422− append(" · ")
423− }
424− withStyle(SpanStyle(color = statusColor)) {
425− append(tool.status)
426− }
427− if (expanded) tool.title?.let {
428− withStyle(SpanStyle(color = baseColor)) {
429− append(" — ")
430− append(it)
431− }
432− }
433− },
434− style = MonoStyle,
435− color = baseColor,
436− maxLines = if (expanded) Int.MAX_VALUE else 1,
437− overflow = TextOverflow.Ellipsis,
438− modifier = Modifier.weight(1f),
439− )
440− Icon(
441− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442− contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443− tint = MaterialTheme.colorScheme.onSurfaceVariant,
444− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445− )
446− }
447−}
448−
449−@Composable
450−private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451− val (icon, label, tint) = when (status) {
452− MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453− MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454− MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455− }
456− Row(
457− verticalAlignment = Alignment.CenterVertically,
458− horizontalArrangement = Arrangement.End,
459− modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460− ) {
461− Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462− Text(
463− text = label,
464− style = MaterialTheme.typography.labelSmall,
465− color = tint,
466− modifier = Modifier.padding(start = 4.dp),
467− )
468− if (status == MessageStatus.Failed) {
469− Text(
470− text = "Retry",
471− style = MaterialTheme.typography.labelSmall,
472− color = MaterialTheme.colorScheme.primary,
473− modifier = Modifier
474− .padding(start = 12.dp)
475− .clickable { onRetry() },
476− )
477− }
478− }
479−}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktnew file+140
@@ -0,0 +1,140 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.util.Base64
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.material3.MaterialTheme
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.Immutable
10+import androidx.compose.runtime.remember
11+import androidx.compose.ui.Alignment
12+import androidx.compose.ui.Modifier
13+import androidx.compose.ui.draw.clip
14+import androidx.compose.ui.layout.ContentScale
15+import androidx.compose.ui.platform.LocalContext
16+import androidx.compose.ui.res.stringResource
17+import androidx.compose.ui.semantics.contentDescription
18+import androidx.compose.ui.semantics.semantics
19+import androidx.compose.ui.unit.dp
20+import coil.compose.SubcomposeAsyncImage
21+import coil.request.ImageRequest
22+import androidx.compose.material3.CircularProgressIndicator
23+import androidx.compose.material3.Icon
24+import androidx.compose.material.icons.Icons
25+import androidx.compose.material.icons.filled.BrokenImage
26+import soy.iko.opencode.R
27+import soy.iko.opencode.data.model.FilePart
28+import soy.iko.opencode.data.model.ServerProfile
29+import soy.iko.opencode.data.network.HttpClientFactory
30+
31+/**
32+ * Carries the bits needed to load an image off the opencode server: the base URL (for
33+ * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
34+ */
35+@Immutable
36+data class ImageLoadContext(
37+ val baseUrl: String,
38+ val basicAuthHeader: String?,
39+)
40+
41+/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
42+fun ServerProfile.toImageContext(): ImageLoadContext {
43+ val auth = if (hasAuth && !password.isNullOrEmpty()) {
44+ val raw = "$username:$password".toByteArray()
45+ "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
46+ } else {
47+ null
48+ }
49+ return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
50+}
51+
52+/** True if the part references a raster/vector image we can render. */
53+val FilePart.isImage: Boolean
54+ get() = mime?.startsWith("image/") == true
55+
56+/**
57+ * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
58+ * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
59+ */
60+private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
61+ val src = source
62+ if (!src.isNullOrBlank() && src.startsWith("data:")) {
63+ val comma = src.indexOf(',')
64+ if (comma < 0) return null
65+ val payload = src.substring(comma + 1)
66+ return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
67+ }
68+ val url = url ?: return null
69+ // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
70+ // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71+ // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
72+ // otherwise get the server's Basic auth attached and leak the credentials to that host.
73+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
74+ val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
75+ val resolved = runCatching {
76+ java.net.URI(base).resolve(url).normalize()
77+ }.getOrElse { return null }
78+ // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
79+ // sending the request (with its Basic auth) to another host. A server-absolute path
80+ // resolves to a different base *path* but the same origin, so it must still be allowed.
81+ val sameOrigin = resolved.host != null &&
82+ resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
83+ resolved.host.equals(baseUri.host, ignoreCase = true) &&
84+ resolved.port == baseUri.port
85+ return if (sameOrigin) resolved.toString() else null
86+}
87+
88+/**
89+ * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
90+ * and server-relative URLs (fetched with Basic auth when the server requires it).
91+ * Falls back to nothing when the part has no loadable source.
92+ */
93+@Composable
94+fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
95+ val context = LocalContext.current
96+ val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
97+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
98+ ImageRequest.Builder(context)
99+ .data(model)
100+ .apply {
101+ // Only send Basic auth over HTTPS; sending credentials over
102+ // cleartext HTTP would expose them on the network.
103+ val isHttps = (model as? String)?.startsWith("https://") == true
104+ if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
105+ }
106+ .crossfade(true)
107+ .build()
108+ }
109+ SubcomposeAsyncImage(
110+ model = request,
111+ contentDescription = part.filename ?: stringResource(R.string.image),
112+ contentScale = ContentScale.FillWidth,
113+ modifier = modifier
114+ .heightIn(max = 320.dp)
115+ .clip(MaterialTheme.shapes.small),
116+ loading = {
117+ Box(
118+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
119+ contentAlignment = Alignment.Center,
120+ ) {
121+ val loadingLabel = stringResource(R.string.loading)
122+ CircularProgressIndicator(
123+ modifier = Modifier.semantics { contentDescription = loadingLabel },
124+ )
125+ }
126+ },
127+ error = {
128+ Box(
129+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
130+ contentAlignment = Alignment.Center,
131+ ) {
132+ Icon(
133+ Icons.Filled.BrokenImage,
134+ contentDescription = stringResource(R.string.image_failed),
135+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
136+ )
137+ }
138+ },
139+ )
140+}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+216
@@ -0,0 +1,216 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.Context
4+import android.text.format.DateUtils
5+import androidx.compose.material3.ExperimentalMaterial3Api
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.PlainTooltip
8+import androidx.compose.material3.Text
9+import androidx.compose.material3.TooltipBox
10+import androidx.compose.material3.TooltipDefaults
11+import androidx.compose.material3.rememberTooltipState
12+import androidx.compose.runtime.Composable
13+import androidx.compose.runtime.compositionLocalOf
14+import androidx.compose.runtime.derivedStateOf
15+import androidx.compose.runtime.getValue
16+import androidx.compose.runtime.mutableLongStateOf
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
19+import androidx.compose.ui.Modifier
20+import androidx.compose.ui.graphics.Color
21+import androidx.compose.ui.platform.LocalContext
22+import androidx.compose.ui.text.TextStyle
23+import androidx.lifecycle.Lifecycle
24+import androidx.lifecycle.compose.LocalLifecycleOwner
25+import androidx.lifecycle.repeatOnLifecycle
26+import kotlinx.coroutines.delay
27+import java.util.concurrent.TimeUnit
28+
29+/**
30+ * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
31+ *
32+ * For coarse units (minutes/hours/days) prefers Android's locale-aware
33+ * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34+ * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35+ * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36+ * `isReturnDefaultValues = true` stubs the framework call to null). For
37+ * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
38+ * uses the compact numeric form with a locale-stable unit suffix. Always returns a
39+ * non-empty string for a valid timestamp.
40+ */
41+fun relativeTime(epochMillis: Long?): String {
42+ if (epochMillis == null || epochMillis <= 0) return ""
43+ val now = System.currentTimeMillis()
44+ val diff = now - epochMillis
45+ if (diff < 0) return "now"
46+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
47+ if (minutes < 1) return "now"
48+ if (minutes < 60) {
49+ val locale = DateUtils.getRelativeTimeSpanString(
50+ epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
51+ )?.toString()
52+ return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
53+ }
54+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
55+ if (hours < 24) {
56+ val locale = DateUtils.getRelativeTimeSpanString(
57+ epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
58+ )?.toString()
59+ return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
60+ }
61+ val days = TimeUnit.MILLISECONDS.toDays(diff)
62+ if (days < 7) {
63+ val locale = DateUtils.getRelativeTimeSpanString(
64+ epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
65+ )?.toString()
66+ return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
67+ }
68+ // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
69+ // numeric form. The suffix is locale-stable (English letter) but these are
70+ // rare timestamps (older than a week) and the numeric value is the primary signal.
71+ val weeks = days / 7
72+ if (weeks < 5) return "${weeks}w"
73+ // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
74+ // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
75+ // through to years, rendering "1y" for a span that's under a year (~11.9 months).
76+ if (days < 365) return "${days / 30}mo"
77+ val years = days / 365
78+ return "${years}y"
79+}
80+
81+/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
82+ * enough for the smallest unit we show (minutes). */
83+private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
84+
85+/**
86+ * A shared, screen-level "tick" that advances periodically so every relative-time label
87+ * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
88+ *
89+ * val tick = rememberRelativeTimeTick()
90+ * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
91+ *
92+ * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
93+ * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
94+ * timer churn as items enter and leave the viewport.
95+ *
96+ * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
97+ * then, so it keeps auto-refreshing even without a provider.
98+ */
99+val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
100+
101+/**
102+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
103+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
104+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
105+ */
106+@Composable
107+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
108+ var tick by remember { mutableLongStateOf(0L) }
109+ val lifecycleOwner = LocalLifecycleOwner.current
110+
111+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
112+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
113+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
114+ // then periodically so labels stay current.
115+ tick++
116+ while (true) {
117+ delay(intervalMs)
118+ tick++
119+ }
120+ }
121+ }
122+ return tick
123+}
124+
125+/**
126+ * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
127+ * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
128+ * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
129+ * need a provider to get auto-refreshing labels.
130+ */
131+@Composable
132+fun rememberRelativeTime(epochMillis: Long?): String {
133+ val sharedTick = LocalRelativeTimeTick.current
134+ return if (sharedTick != 0L) {
135+ // Reading sharedTick above registers a recomposition dependency on the shared
136+ // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
137+ relativeTime(epochMillis)
138+ } else {
139+ rememberRelativeTimeStandalone(epochMillis)
140+ }
141+}
142+
143+/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
144+@Composable
145+private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
146+ var tick by remember { mutableLongStateOf(0L) }
147+ val lifecycleOwner = LocalLifecycleOwner.current
148+
149+ androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
150+ if (epochMillis == null) return@LaunchedEffect
151+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
152+ // Tick immediately on resume so a stale label refreshes without waiting for
153+ // the first interval, then periodically.
154+ tick++
155+ while (true) {
156+ delay(RELATIVE_TIME_INTERVAL_MS)
157+ tick++
158+ }
159+ }
160+ }
161+
162+ // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
163+ // the relative-time string from the current wall clock on each tick. Keying remember
164+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
165+ val formatted by remember(epochMillis) {
166+ derivedStateOf {
167+ // Read tick to create a recomposition dependency on the timer.
168+ tick
169+ relativeTime(epochMillis)
170+ }
171+ }
172+ return formatted
173+}
174+
175+/**
176+ * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
177+ * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
178+ * and bubbles — the relative form is scannable, the absolute form is precise. Returns an
179+ * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
180+ */
181+fun absoluteTime(context: Context, epochMillis: Long?): String {
182+ if (epochMillis == null || epochMillis <= 0) return ""
183+ return DateUtils.formatDateTime(
184+ context,
185+ epochMillis,
186+ DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
187+ )
188+}
189+
190+/**
191+ * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
192+ * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
193+ * the default while the exact date/time is one long-press away — without spending screen
194+ * space on it. Renders nothing when the timestamp is absent so callers can drop it in
195+ * unconditionally.
196+ */
197+@OptIn(ExperimentalMaterial3Api::class)
198+@Composable
199+fun RelativeTimeText(
200+ epochMillis: Long?,
201+ modifier: Modifier = Modifier,
202+ style: TextStyle = MaterialTheme.typography.labelSmall,
203+ color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
204+) {
205+ val relative = rememberRelativeTime(epochMillis)
206+ if (relative.isEmpty()) return
207+ val context = LocalContext.current
208+ val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
209+ TooltipBox(
210+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
211+ tooltip = { PlainTooltip { Text(absolute) } },
212+ state = rememberTooltipState(),
213+ ) {
214+ Text(relative, style = style, color = color, modifier = modifier)
215+ }
216+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+375
@@ -0,0 +1,375 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Box
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.KeyboardActions
18+import androidx.compose.foundation.text.KeyboardOptions
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.ChevronRight
22+import androidx.compose.material.icons.filled.Close
23+import androidx.compose.material.icons.filled.Description
24+import androidx.compose.material.icons.filled.Folder
25+import androidx.compose.material.icons.filled.Home
26+import androidx.compose.material.icons.filled.Search
27+import androidx.compose.material3.CircularProgressIndicator
28+import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.HorizontalDivider
30+import androidx.compose.material3.Icon
31+import androidx.compose.material3.IconButton
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.OutlinedTextField
34+import androidx.compose.material3.Scaffold
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.Text
38+import androidx.compose.material3.TopAppBar
39+import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.LaunchedEffect
41+import androidx.compose.runtime.getValue
42+import androidx.compose.runtime.remember
43+import androidx.compose.ui.Alignment
44+import androidx.compose.ui.Modifier
45+import androidx.compose.ui.graphics.Color
46+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
47+import androidx.compose.ui.platform.testTag
48+import androidx.compose.ui.semantics.Role
49+import androidx.compose.ui.semantics.contentDescription
50+import androidx.compose.ui.semantics.role
51+import androidx.compose.ui.semantics.semantics
52+import androidx.compose.ui.text.font.FontFamily
53+import androidx.compose.ui.text.input.ImeAction
54+import androidx.compose.ui.text.style.TextOverflow
55+import androidx.compose.ui.unit.dp
56+import androidx.compose.ui.res.stringResource
57+import androidx.lifecycle.compose.collectAsStateWithLifecycle
58+import androidx.lifecycle.viewmodel.compose.viewModel
59+import soy.iko.opencode.data.model.FileStatusEntry
60+import soy.iko.opencode.di.AppContainer
61+import soy.iko.opencode.R
62+import soy.iko.opencode.ui.vmFactory
63+
64+@OptIn(ExperimentalMaterial3Api::class)
65+@Composable
66+fun FileBrowserScreen(
67+ container: AppContainer,
68+ onOpenFile: (String) -> Unit,
69+ onBack: () -> Unit,
70+) {
71+ val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
72+ val state by vm.state.collectAsStateWithLifecycle()
73+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
74+ val snackbar = remember { SnackbarHostState() }
75+
76+ LaunchedEffect(Unit) {
77+ vm.transientErrors.collect { msg ->
78+ snackbar.showSnackbar(msg)
79+ }
80+ }
81+
82+ Scaffold(
83+ topBar = {
84+ TopAppBar(
85+ title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
86+ navigationIcon = {
87+ IconButton(onClick = onBack) {
88+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
89+ }
90+ },
91+ )
92+ },
93+ snackbarHost = { SnackbarHost(snackbar) },
94+ ) { padding ->
95+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
96+ // Tappable breadcrumb trail so deep paths are navigable.
97+ Breadcrumbs(
98+ path = state.path,
99+ onNavigate = vm::open,
100+ modifier = Modifier.fillMaxWidth(),
101+ )
102+ val keyboardController = LocalSoftwareKeyboardController.current
103+ OutlinedTextField(
104+ value = state.query,
105+ onValueChange = vm::setQuery,
106+ modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
107+ label = { Text(stringResource(R.string.search_files)) },
108+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
109+ trailingIcon = if (state.query.isNotEmpty()) {
110+ {
111+ IconButton(onClick = { vm.setQuery("") }) {
112+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
113+ }
114+ }
115+ } else null,
116+ singleLine = true,
117+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
118+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
119+ )
120+
121+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
122+ isRefreshing = refreshing,
123+ onRefresh = { vm.refresh() },
124+ modifier = Modifier.fillMaxSize(),
125+ ) {
126+ Box(modifier = Modifier.fillMaxSize()) {
127+ when {
128+ state.loading || state.searching -> {
129+ val loadingLabel = stringResource(R.string.loading)
130+ CircularProgressIndicator(
131+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
132+ )
133+ }
134+ state.error != null -> Column(
135+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
136+ horizontalAlignment = Alignment.CenterHorizontally,
137+ ) {
138+ Text(
139+ state.error ?: "",
140+ color = MaterialTheme.colorScheme.error,
141+ )
142+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
143+ // Retry the operation that actually failed: re-run the search
144+ // when one was active (open() would reset the query and drop the
145+ // user back into the directory listing), otherwise reload the dir.
146+ androidx.compose.material3.TextButton(
147+ onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
148+ ) {
149+ Text(stringResource(R.string.retry))
150+ }
151+ }
152+ state.isSearching -> SearchResults(state.results, onOpenFile)
153+ else -> DirectoryListing(
154+ state = state,
155+ onOpenDir = vm::open,
156+ onUp = vm::up,
157+ onOpenFile = onOpenFile,
158+ )
159+ }
160+ }
161+ }
162+ }
163+ }
164+}
165+
166+@Composable
167+private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
168+ val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
169+ Row(
170+ modifier = modifier
171+ .horizontalScroll(rememberScrollState())
172+ .padding(horizontal = 16.dp, vertical = 8.dp),
173+ verticalAlignment = Alignment.CenterVertically,
174+ ) {
175+ IconButton(onClick = { onNavigate("") }) {
176+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
177+ }
178+ var acc = ""
179+ segments.forEachIndexed { index, segment ->
180+ acc = if (acc.isEmpty()) segment else "$acc/$segment"
181+ Icon(
182+ Icons.Filled.ChevronRight,
183+ contentDescription = null,
184+ modifier = Modifier.size(16.dp),
185+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
186+ )
187+ val target = acc
188+ Text(
189+ segment,
190+ style = MaterialTheme.typography.bodyMedium,
191+ color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
192+ modifier = Modifier
193+ .clickable(role = Role.Button) { onNavigate(target) }
194+ .defaultMinSize(minHeight = 48.dp)
195+ .padding(horizontal = 6.dp, vertical = 14.dp),
196+ )
197+ }
198+ }
199+}
200+
201+@Composable
202+private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
203+ if (results.isEmpty()) {
204+ EmptyFileState(
205+ icon = Icons.Filled.Search,
206+ message = stringResource(R.string.no_matches),
207+ modifier = Modifier.fillMaxWidth().padding(24.dp),
208+ )
209+ return
210+ }
211+ LazyColumn(modifier = Modifier.fillMaxSize()) {
212+ items(results, key = { it }) { path ->
213+ // Split the path into directory (muted) + filename (emphasized) so results
214+ // are scannable instead of a wall of identical-looking full paths.
215+ val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
216+ val name = path.substringAfterLast('/')
217+ FileRow(
218+ icon = false,
219+ label = name,
220+ sublabel = dir.takeIf { it.isNotEmpty() },
221+ onClick = { onOpenFile(path) },
222+ modifier = Modifier.animateItem(),
223+ )
224+ HorizontalDivider()
225+ }
226+ }
227+}
228+
229+@Composable
230+private fun DirectoryListing(
231+ state: FileBrowserState,
232+ onOpenDir: (String) -> Unit,
233+ onUp: () -> Unit,
234+ onOpenFile: (String) -> Unit,
235+) {
236+ if (state.entries.isEmpty()) {
237+ EmptyFileState(
238+ icon = Icons.Filled.Folder,
239+ message = stringResource(R.string.empty_folder),
240+ modifier = Modifier.fillMaxWidth().padding(24.dp),
241+ )
242+ return
243+ }
244+ LazyColumn(modifier = Modifier.fillMaxSize()) {
245+ if (state.path.isNotBlank()) {
246+ item(key = "__up") {
247+ FileRow(icon = true, label = "..", onClick = onUp)
248+ HorizontalDivider()
249+ }
250+ }
251+ items(state.entries, key = { it.path + "_" + it.name }) { node ->
252+ FileRow(
253+ icon = node.isDirectory,
254+ label = node.name,
255+ onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
256+ status = state.statusMap[node.path],
257+ modifier = Modifier.animateItem(),
258+ )
259+ HorizontalDivider()
260+ }
261+ }
262+}
263+
264+/** Shared empty-state for the file browser: icon + message, matching the icon+text
265+ * pattern used by the session and server lists (the prior versions were bare Text
266+ * lines that read as status messages rather than intentional empty states). */
267+@Composable
268+private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
269+ Column(
270+ modifier = modifier,
271+ horizontalAlignment = Alignment.CenterHorizontally,
272+ ) {
273+ Icon(
274+ icon,
275+ contentDescription = null,
276+ modifier = Modifier.size(48.dp),
277+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
278+ )
279+ Spacer(Modifier.size(12.dp))
280+ Text(
281+ message,
282+ style = MaterialTheme.typography.bodyMedium,
283+ color = MaterialTheme.colorScheme.onSurfaceVariant,
284+ )
285+ }
286+}
287+
288+@Composable
289+private fun FileRow(
290+ icon: Boolean,
291+ label: String,
292+ onClick: () -> Unit,
293+ status: FileStatusEntry? = null,
294+ sublabel: String? = null,
295+ modifier: Modifier = Modifier,
296+) {
297+ val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
298+ else if (icon) stringResource(R.string.folder, label)
299+ else stringResource(R.string.file_label, label)
300+ // Build a combined description so TalkBack announces both the file name and its git
301+ // status. The parent sets an explicit contentDescription (mergeDescendants), which
302+ // would otherwise suppress the StatusBadge's own description.
303+ val statusDesc = status?.let {
304+ when (it.status) {
305+ "added" -> stringResource(R.string.git_added)
306+ "modified" -> stringResource(R.string.git_modified)
307+ "deleted" -> stringResource(R.string.git_deleted)
308+ else -> ""
309+ }
310+ }.orEmpty()
311+ val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
312+ Row(
313+ modifier = modifier
314+ .fillMaxWidth()
315+ .defaultMinSize(minHeight = 48.dp)
316+ .clickable(role = Role.Button, onClick = onClick)
317+ .semantics(mergeDescendants = true) { contentDescription = fullDesc }
318+ .padding(horizontal = 16.dp, vertical = 14.dp),
319+ verticalAlignment = Alignment.CenterVertically,
320+ ) {
321+ Icon(
322+ if (icon) Icons.Filled.Folder else Icons.Filled.Description,
323+ contentDescription = null,
324+ modifier = Modifier.size(20.dp),
325+ tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
326+ )
327+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
328+ Text(
329+ label,
330+ style = MaterialTheme.typography.bodyLarge,
331+ maxLines = 1,
332+ overflow = TextOverflow.Ellipsis,
333+ )
334+ if (sublabel != null) {
335+ Text(
336+ sublabel,
337+ style = MaterialTheme.typography.labelSmall,
338+ color = MaterialTheme.colorScheme.onSurfaceVariant,
339+ maxLines = 1,
340+ overflow = TextOverflow.Ellipsis,
341+ )
342+ }
343+ }
344+ if (status != null) StatusBadge(status)
345+ }
346+}
347+
348+@Composable
349+private fun StatusBadge(status: FileStatusEntry) {
350+ val (letter, color) = when (status.status) {
351+ "added" -> "A" to MaterialTheme.colorScheme.primary
352+ "modified" -> "M" to MaterialTheme.colorScheme.tertiary
353+ "deleted" -> "D" to MaterialTheme.colorScheme.error
354+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
355+ }
356+ Row(
357+ verticalAlignment = Alignment.CenterVertically,
358+ ) {
359+ Text(
360+ letter,
361+ style = MaterialTheme.typography.labelMedium,
362+ fontFamily = FontFamily.Monospace,
363+ color = color,
364+ )
365+ if (status.added > 0 || status.removed > 0) {
366+ Text(
367+ "+${status.added} −${status.removed}",
368+ style = MaterialTheme.typography.labelSmall,
369+ fontFamily = FontFamily.Monospace,
370+ color = MaterialTheme.colorScheme.onSurfaceVariant,
371+ modifier = Modifier.padding(start = 4.dp),
372+ )
373+ }
374+ }
375+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+191
@@ -0,0 +1,191 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.FileNode
7+import soy.iko.opencode.data.model.FileStatusEntry
8+import soy.iko.opencode.data.network.NetworkConfig
9+import soy.iko.opencode.di.AppContainer
10+import soy.iko.opencode.R
11+import soy.iko.opencode.util.runCatchingCancellable
12+import kotlinx.coroutines.ExperimentalCoroutinesApi
13+import kotlinx.coroutines.Job
14+import kotlinx.coroutines.delay
15+import kotlinx.coroutines.channels.BufferOverflow
16+import kotlinx.coroutines.flow.MutableSharedFlow
17+import kotlinx.coroutines.flow.MutableStateFlow
18+import kotlinx.coroutines.flow.SharedFlow
19+import kotlinx.coroutines.flow.StateFlow
20+import kotlinx.coroutines.flow.asSharedFlow
21+import kotlinx.coroutines.flow.asStateFlow
22+import kotlinx.coroutines.flow.collectLatest
23+import kotlinx.coroutines.flow.update
24+import kotlinx.coroutines.launch
25+
26+@Immutable
27+data class FileBrowserState(
28+ val path: String = "",
29+ val entries: List<FileNode> = emptyList(),
30+ val statusMap: Map<String, FileStatusEntry> = emptyMap(),
31+ val query: String = "",
32+ val results: List<String> = emptyList(),
33+ val searching: Boolean = false,
34+ val loading: Boolean = true,
35+ val error: String? = null,
36+) {
37+ val isSearching: Boolean get() = query.isNotBlank()
38+}
39+
40+@OptIn(ExperimentalCoroutinesApi::class)
41+class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
42+
43+ private val api get() = container.activeConnection.value?.api
44+
45+ private val _state = MutableStateFlow(FileBrowserState())
46+ val state: StateFlow<FileBrowserState> = _state.asStateFlow()
47+
48+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
49+ private val _refreshing = MutableStateFlow(false)
50+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
51+
52+ /** One-shot transient errors (failed VCS status fetch, etc.) surfaced as snackbars without
53+ * hiding the file list. A SharedFlow (not StateFlow) so each emission is delivered independently. */
54+ private val _transientErrors = MutableSharedFlow<String>(
55+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
56+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
57+ )
58+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
59+
60+ private var searchJob: Job? = null
61+ private var openJob: Job? = null
62+
63+ init {
64+ open("")
65+ // Observe the active connection so VCS status loads (or reloads) when a connection
66+ // becomes available — including when the view opens during a reconnect window where
67+ // activeConnection.value was momentarily null. Without this, loadStatus() would be
68+ // called once from init with a null api and never retried.
69+ viewModelScope.launch {
70+ container.activeConnection.collectLatest { conn ->
71+ if (conn == null) return@collectLatest
72+ loadStatus()
73+ // Re-open the current directory when a connection becomes available
74+ // so the initial open("") (which may have run before auto-reconnect
75+ // finished and found no connection) doesn't leave a stale error. But if
76+ // the user is mid-search, re-run the search instead — open() resets
77+ // query/results and would silently discard their in-progress search.
78+ if (_state.value.isSearching) setQuery(_state.value.query)
79+ else open(_state.value.path)
80+ }
81+ }
82+ }
83+
84+ /** Tracks the in-flight VCS status load so a server switch can cancel it. */
85+ private var statusJob: Job? = null
86+
87+ /** Fetch the repo-wide VCS status once so file rows can show git badges. */
88+ private fun loadStatus() {
89+ val client = api ?: return
90+ statusJob?.cancel()
91+ statusJob = viewModelScope.launch {
92+ runCatchingCancellable { client.fileStatus() }
93+ .onSuccess { entries ->
94+ _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
95+ }
96+ .onFailure {
97+ // VCS status is best-effort: don't block the file browser, but surface
98+ // a transient error so the user knows why badges are missing.
99+ _transientErrors.tryEmit(container.friendlyError(it))
100+ }
101+ }
102+ }
103+
104+ /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
105+ fun refresh() {
106+ _refreshing.value = true
107+ val searching = _state.value.isSearching
108+ if (searching) {
109+ // Re-run the active search rather than open(), which would reset query/results
110+ // and drop the user back into the directory listing — discarding their search.
111+ setQuery(_state.value.query)
112+ } else {
113+ open(_state.value.path)
114+ }
115+ viewModelScope.launch {
116+ // Clear the spinner once the in-flight load settles by joining whichever job
117+ // the refresh kicked off (search or directory open).
118+ (if (searching) searchJob else openJob)?.join()
119+ _refreshing.value = false
120+ }
121+ }
122+
123+ fun open(path: String) {
124+ val client = api ?: run {
125+ _state.update { it.copy(
126+ path = path,
127+ loading = false,
128+ error = container.string(R.string.not_connected),
129+ ) }
130+ return
131+ }
132+ _state.update { it.copy(
133+ path = path,
134+ query = "",
135+ results = emptyList(),
136+ searching = false,
137+ loading = true,
138+ error = null,
139+ ) }
140+ openJob?.cancel()
141+ searchJob?.cancel()
142+ openJob = viewModelScope.launch {
143+ runCatchingCancellable { client.listDirectory(path) }
144+ .onSuccess { entries ->
145+ _state.update { it.copy(
146+ entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
147+ loading = false,
148+ ) }
149+ }
150+ .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
151+ }
152+ }
153+
154+ /** Navigate up one path segment. */
155+ fun up() {
156+ val current = _state.value.path
157+ if (current.isBlank()) return
158+ val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
159+ open(parent)
160+ }
161+
162+ fun setQuery(query: String) {
163+ _state.update { it.copy(query = query, error = null) }
164+ searchJob?.cancel()
165+ openJob?.cancel()
166+ if (query.isBlank()) {
167+ _state.update { it.copy(results = emptyList(), searching = false) }
168+ // Cancelling openJob above may have interrupted an in-flight directory load,
169+ // leaving loading=true stuck. Re-open the current directory so the listing
170+ // re-populates instead of showing a perpetual spinner after clearing search.
171+ open(_state.value.path)
172+ return
173+ }
174+ // The search view replaces the directory listing; clear loading so the spinner
175+ // from a cancelled open() doesn't persist into the search results view.
176+ // Set searching=true immediately (not after the debounce) so the spinner shows
177+ // during the debounce delay — otherwise the UI briefly renders SearchResults
178+ // with an empty list, flashing "no matches" before the search even starts.
179+ _state.update { it.copy(loading = false, searching = true) }
180+ val client = api ?: run {
181+ _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
182+ return
183+ }
184+ searchJob = viewModelScope.launch {
185+ delay(NetworkConfig.fileSearchDebounceMs) // debounce
186+ runCatchingCancellable { client.findFiles(query) }
187+ .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
188+ .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
189+ }
190+ }
191+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+59
@@ -0,0 +1,59 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.FileContent
7+import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
9+import kotlinx.coroutines.ExperimentalCoroutinesApi
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.collectLatest
14+import kotlinx.coroutines.flow.merge
15+import kotlinx.coroutines.launch
16+import soy.iko.opencode.util.runCatchingCancellable
17+
18+@Immutable
19+data class FileViewState(
20+ val loading: Boolean = true,
21+ val content: FileContent? = null,
22+ val error: String? = null,
23+)
24+
25+@OptIn(ExperimentalCoroutinesApi::class)
26+class FileViewModel(
27+ private val container: AppContainer,
28+ private val path: String,
29+) : ViewModel() {
30+
31+ private val _state = MutableStateFlow(FileViewState())
32+ val state: StateFlow<FileViewState> = _state.asStateFlow()
33+
34+ /** Incremented by [reload] to trigger a fresh fetch without waiting for a connection change. */
35+ private val _reload = MutableStateFlow(0)
36+
37+ fun reload() { _reload.value++ }
38+
39+ init {
40+ // Observe the active connection so the file loads (or reloads) when a connection
41+ // becomes available — including when the view opens during a reconnect window
42+ // where activeConnection.value was momentarily null. collectLatest cancels the
43+ // in-flight load if the connection is replaced mid-read. The reload trigger is
44+ // merged in so a manual retry (e.g. after a transient error) re-fetches.
45+ viewModelScope.launch {
46+ merge(container.activeConnection, _reload).collectLatest {
47+ val conn = container.activeConnection.value
48+ if (conn == null) {
49+ _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
50+ return@collectLatest
51+ }
52+ _state.value = FileViewState(loading = true)
53+ runCatchingCancellable { conn.api.readFile(path) }
54+ .onSuccess { _state.value = FileViewState(loading = false, content = it) }
55+ .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
56+ }
57+ }
58+ }
59+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+539
@@ -0,0 +1,539 @@
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.remember
58+import androidx.compose.runtime.saveable.rememberSaveable
59+import androidx.compose.runtime.setValue
60+import androidx.compose.ui.Alignment
61+import androidx.compose.ui.Modifier
62+import androidx.compose.ui.layout.onSizeChanged
63+import androidx.compose.ui.platform.LocalContext
64+import androidx.compose.ui.platform.LocalDensity
65+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
66+import androidx.compose.ui.semantics.contentDescription
67+import androidx.compose.ui.semantics.semantics
68+import androidx.compose.ui.text.font.FontFamily
69+import androidx.compose.ui.text.input.ImeAction
70+import androidx.compose.ui.text.style.TextOverflow
71+import androidx.compose.ui.unit.dp
72+import androidx.compose.ui.res.stringResource
73+import androidx.lifecycle.compose.collectAsStateWithLifecycle
74+import androidx.lifecycle.viewmodel.compose.viewModel
75+import soy.iko.opencode.di.AppContainer
76+import soy.iko.opencode.R
77+import soy.iko.opencode.ui.components.DiffView
78+import soy.iko.opencode.ui.components.copyToClipboard
79+import soy.iko.opencode.ui.components.highlightLine
80+import soy.iko.opencode.ui.components.rememberHighlightPalette
81+import soy.iko.opencode.ui.vmFactory
82+import soy.iko.opencode.util.runCatchingCancellable
83+
84+@OptIn(ExperimentalMaterial3Api::class)
85+@Composable
86+fun FileViewScreen(
87+ container: AppContainer,
88+ path: String,
89+ onBack: () -> Unit,
90+) {
91+ val vm: FileViewModel = viewModel(factory = vmFactory { FileViewModel(container, path) })
92+ val state by vm.state.collectAsStateWithLifecycle()
93+ val context = LocalContext.current
94+ val filename = remember(path) {
95+ val trimmed = path.trimEnd('/')
96+ trimmed.substringAfterLast('/').ifBlank { path.ifBlank { "/" } }
97+ }
98+ val shareLabel = stringResource(R.string.share)
99+ // Diff vs raw view toggle. Defaults to showing the diff when one exists; the user can
100+ // switch to the raw new content. Persisted via rememberSaveable across rotation.
101+ val hasDiff = state.content?.diff != null && state.content?.diff?.isNotBlank() == true
102+ var showDiff by rememberSaveable(path) { mutableStateOf(true) }
103+ val showToggle = hasDiff && state.content?.content.orEmpty().isNotEmpty()
104+ // Find-in-file and line-wrapping state, persisted so a rotation or reload keeps the
105+ // user's query/mode.
106+ var findActive by rememberSaveable(path) { mutableStateOf(false) }
107+ var findQuery by rememberSaveable(path) { mutableStateOf("") }
108+ var matchPos by rememberSaveable(path) { mutableIntStateOf(0) }
109+ var wrap by rememberSaveable(path) { mutableStateOf(false) }
110+ val listState = rememberLazyListState()
111+ val rawText = state.content?.content.orEmpty()
112+ val lines = remember(rawText) { rawText.split("\n") }
113+ val matchIndices = remember(lines, findQuery) {
114+ val q = findQuery.trim()
115+ if (q.isEmpty()) emptyList()
116+ else lines.mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
117+ .filter { it >= 0 }
118+ }
119+ // Keep the current match index in range when the match set shrinks (e.g. the file
120+ // reloaded with fewer matches), so the counter never shows a stale "10 / 3".
121+ LaunchedEffect(matchIndices.size) {
122+ matchPos = matchPos.coerceIn(0, (matchIndices.size - 1).coerceAtLeast(0))
123+ }
124+ // Scroll to the current match whenever it (or the match set) changes.
125+ LaunchedEffect(matchPos, matchIndices) {
126+ matchIndices.getOrNull(matchPos)?.let { idx -> runCatchingCancellable { listState.animateScrollToItem(idx) } }
127+ }
128+
129+ Scaffold(
130+ topBar = {
131+ FileViewTopBar(
132+ filename = filename,
133+ loading = state.loading,
134+ content = rawText,
135+ wrap = wrap,
136+ inDiffMode = showToggle && showDiff,
137+ onBack = onBack,
138+ onReload = { vm.reload() },
139+ onToggleFind = { findActive = !findActive; if (!findActive) findQuery = "" },
140+ onToggleWrap = { wrap = !wrap },
141+ onCopy = { copyToClipboard(context, filename, rawText) },
142+ onShare = {
143+ val send = Intent(Intent.ACTION_SEND).apply {
144+ type = "text/plain"
145+ putExtra(Intent.EXTRA_SUBJECT, filename)
146+ putExtra(Intent.EXTRA_TEXT, rawText)
147+ }
148+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
149+ .onFailure { showToast(context, context.getString(R.string.no_share_app)) }
150+ },
151+ )
152+ },
153+ ) { padding ->
154+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
155+ // AnimatedContent cross-fades between loading / error / content states so the
156+ // viewer doesn't snap abruptly when a reload finishes or fails.
157+ AnimatedContent(
158+ targetState = state,
159+ transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(180)) },
160+ label = "fileViewState",
161+ ) { s ->
162+ // Derive match indices from THIS layer's content (s), not the outer/newest
163+ // state. During the crossfade the exiting layer still renders the old
164+ // content; passing the outer matchIndices (computed from the new content)
165+ // would highlight and scroll the wrong lines until the animation settles.
166+ val sMatchIndices = remember(s.content?.content, findQuery) {
167+ val q = findQuery.trim()
168+ if (q.isEmpty()) emptyList()
169+ else (s.content?.content.orEmpty()).split("\n")
170+ .mapIndexed { index, line -> if (line.contains(q, ignoreCase = true)) index else -1 }
171+ .filter { it >= 0 }
172+ }
173+ FileViewStateContent(
174+ state = s,
175+ filename = filename,
176+ showToggle = showToggle,
177+ showDiff = showDiff,
178+ onSetShowDiff = {
179+ showDiff = it
180+ // Close any open find bar when entering diff mode: find searches
181+ // the raw content and scrolls the line LazyColumn, neither of
182+ // which is visible in diff mode, so leaving it open would be a
183+ // confusing no-op.
184+ if (it) { findActive = false; findQuery = "" }
185+ },
186+ findActive = findActive,
187+ findQuery = findQuery,
188+ onQueryChange = { findQuery = it; matchPos = 0 },
189+ matchIndices = sMatchIndices,
190+ matchPos = matchPos,
191+ onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
192+ onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
193+ onCloseFind = { findActive = false; findQuery = "" },
194+ wrap = wrap,
195+ listState = listState,
196+ onRetry = { vm.reload() },
197+ )
198+ }
199+ }
200+ }
201+}
202+
203+@OptIn(ExperimentalMaterial3Api::class)
204+@Composable
205+private fun FileViewTopBar(
206+ filename: String,
207+ loading: Boolean,
208+ content: String,
209+ wrap: Boolean,
210+ inDiffMode: Boolean,
211+ onBack: () -> Unit,
212+ onReload: () -> Unit,
213+ onToggleFind: () -> Unit,
214+ onToggleWrap: () -> Unit,
215+ onCopy: () -> Unit,
216+ onShare: () -> Unit,
217+) {
218+ TopAppBar(
219+ title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
220+ navigationIcon = {
221+ IconButton(onClick = onBack) {
222+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
223+ }
224+ },
225+ actions = {
226+ // In-place refresh: a file's content can change on the server (the agent may
227+ // be editing it), so reload without requiring a back-out.
228+ IconButton(onClick = onReload, enabled = !loading) {
229+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
230+ }
231+ if (content.isNotEmpty()) {
232+ // Find-in-file: toggles a search bar over the raw content. Disabled only when
233+ // the DiffView is actually on screen ([inDiffMode]) — the find bar searches the
234+ // raw text and scrolls the line LazyColumn, neither of which is visible under a
235+ // diff, so find would silently no-op there. A plain file with no diff (the common
236+ // case) shows raw text, so find must stay enabled. The user can switch to Raw to find.
237+ IconButton(onClick = onToggleFind, enabled = !inDiffMode) {
238+ Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
239+ }
240+ // Wrap long lines instead of horizontal-scrolling, useful for prose.
241+ IconButton(onClick = onToggleWrap) {
242+ Icon(
243+ Icons.AutoMirrored.Filled.WrapText,
244+ contentDescription = stringResource(R.string.wrap_lines),
245+ tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
246+ )
247+ }
248+ IconButton(onClick = onCopy) {
249+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
250+ }
251+ IconButton(onClick = onShare) {
252+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
253+ }
254+ }
255+ },
256+ )
257+}
258+
259+@Composable
260+private fun BoxScope.FileViewStateContent(
261+ state: FileViewState,
262+ filename: String,
263+ showToggle: Boolean,
264+ showDiff: Boolean,
265+ onSetShowDiff: (Boolean) -> Unit,
266+ findActive: Boolean,
267+ findQuery: String,
268+ onQueryChange: (String) -> Unit,
269+ matchIndices: List<Int>,
270+ matchPos: Int,
271+ onPrev: () -> Unit,
272+ onNext: () -> Unit,
273+ onCloseFind: () -> Unit,
274+ wrap: Boolean,
275+ listState: LazyListState,
276+ onRetry: () -> Unit,
277+) {
278+ when {
279+ state.loading -> {
280+ val loadingLabel = stringResource(R.string.loading)
281+ CircularProgressIndicator(
282+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
283+ )
284+ }
285+ state.error != null -> Column(
286+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
287+ horizontalAlignment = Alignment.CenterHorizontally,
288+ ) {
289+ Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
290+ Spacer(Modifier.size(12.dp))
291+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
292+ }
293+ state.content?.isBinary == true -> Text(
294+ stringResource(R.string.binary_file),
295+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
296+ color = MaterialTheme.colorScheme.onSurfaceVariant,
297+ )
298+ else -> FileViewContentBody(
299+ content = state.content,
300+ filename = filename,
301+ showToggle = showToggle,
302+ showDiff = showDiff,
303+ onSetShowDiff = onSetShowDiff,
304+ findActive = findActive,
305+ findQuery = findQuery,
306+ onQueryChange = onQueryChange,
307+ matchIndices = matchIndices,
308+ matchPos = matchPos,
309+ onPrev = onPrev,
310+ onNext = onNext,
311+ onCloseFind = onCloseFind,
312+ wrap = wrap,
313+ listState = listState,
314+ )
315+ }
316+}
317+
318+@Composable
319+private fun BoxScope.FileViewContentBody(
320+ content: soy.iko.opencode.data.model.FileContent?,
321+ filename: String,
322+ showToggle: Boolean,
323+ showDiff: Boolean,
324+ onSetShowDiff: (Boolean) -> Unit,
325+ findActive: Boolean,
326+ findQuery: String,
327+ onQueryChange: (String) -> Unit,
328+ matchIndices: List<Int>,
329+ matchPos: Int,
330+ onPrev: () -> Unit,
331+ onNext: () -> Unit,
332+ onCloseFind: () -> Unit,
333+ wrap: Boolean,
334+ listState: LazyListState,
335+) {
336+ // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
337+ // reachable while scrolling. Each contributes a top inset so the content below
338+ // isn't hidden behind the overlay. The inset is measured from the actual overlay
339+ // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
340+ // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
341+ // cause the first lines of content to hide behind the overlay.
342+ // The raw text is on screen whenever we're NOT rendering the diff — i.e. either the
343+ // file has no diff toggle at all (plain file: showToggle == false) or the toggle is
344+ // set to Raw. Gate the find bar on this rather than on !showDiff: for a plain file
345+ // showDiff stays at its `true` default forever (nothing ever flips it), so !showDiff
346+ // would keep the find bar — and therefore find-in-file — permanently hidden.
347+ val rawOnScreen = !(showToggle && showDiff)
348+ val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && rawOnScreen) 1 else 0)
349+ var overlayHeightPx by remember { mutableIntStateOf(0) }
350+ // Only apply the measured inset while an overlay is actually composed. When overlayRows
351+ // drops to 0 the overlay Column is removed and onSizeChanged never fires to zero the
352+ // height, so keying the inset off overlayRows (rather than the stale measurement) avoids
353+ // a phantom top gap on the content below — e.g. after closing find on a plain file.
354+ val topInset = if (overlayRows > 0) {
355+ with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
356+ } else 0.dp
357+ if (overlayRows > 0) {
358+ Column(
359+ modifier = Modifier
360+ .align(Alignment.TopCenter)
361+ .fillMaxWidth()
362+ .onSizeChanged { overlayHeightPx = it.height }
363+ .padding(top = 4.dp),
364+ verticalArrangement = Arrangement.spacedBy(4.dp),
365+ ) {
366+ if (showToggle) {
367+ Row(
368+ modifier = Modifier.fillMaxWidth(),
369+ horizontalArrangement = Arrangement.Center,
370+ ) {
371+ FilterChip(
372+ selected = showDiff,
373+ onClick = { onSetShowDiff(true) },
374+ label = { Text(stringResource(R.string.show_diff)) },
375+ )
376+ Spacer(Modifier.size(8.dp))
377+ FilterChip(
378+ selected = !showDiff,
379+ onClick = { onSetShowDiff(false) },
380+ label = { Text(stringResource(R.string.show_raw)) },
381+ )
382+ }
383+ }
384+ if (findActive && rawOnScreen) {
385+ FindBar(
386+ query = findQuery,
387+ onQueryChange = onQueryChange,
388+ matchCount = matchIndices.size,
389+ matchPos = matchPos,
390+ onPrev = onPrev,
391+ onNext = onNext,
392+ onClose = onCloseFind,
393+ )
394+ }
395+ }
396+ }
397+ if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
398+ DiffView(
399+ diff = content.diff,
400+ modifier = Modifier
401+ .fillMaxSize()
402+ .padding(top = topInset)
403+ .verticalScroll(rememberScrollState())
404+ .padding(8.dp),
405+ )
406+ } else {
407+ val text = content?.content.orEmpty()
408+ if (text.isEmpty()) {
409+ Text(
410+ stringResource(R.string.empty_file),
411+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
412+ color = MaterialTheme.colorScheme.onSurfaceVariant,
413+ )
414+ } else {
415+ FileTextContent(
416+ text = text,
417+ filename = filename,
418+ topInset = topInset,
419+ wrap = wrap,
420+ findQuery = findQuery,
421+ matchIndices = matchIndices,
422+ listState = listState,
423+ )
424+ }
425+ }
426+}
427+
428+@Composable
429+private fun FindBar(
430+ query: String,
431+ onQueryChange: (String) -> Unit,
432+ matchCount: Int,
433+ matchPos: Int,
434+ onPrev: () -> Unit,
435+ onNext: () -> Unit,
436+ onClose: () -> Unit,
437+) {
438+ val keyboardController = LocalSoftwareKeyboardController.current
439+ Row(
440+ modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
441+ verticalAlignment = Alignment.CenterVertically,
442+ ) {
443+ OutlinedTextField(
444+ value = query,
445+ onValueChange = onQueryChange,
446+ modifier = Modifier.weight(1f),
447+ placeholder = { Text(stringResource(R.string.find_hint)) },
448+ singleLine = true,
449+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
450+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
451+ trailingIcon = {
452+ val countText = if (query.isBlank()) ""
453+ else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
454+ else stringResource(R.string.match_count, matchPos + 1, matchCount)
455+ if (countText.isNotEmpty()) {
456+ // Use error color for "no matches" so the zero-result state is
457+ // distinguishable from a positive match count at a glance.
458+ val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
459+ else MaterialTheme.colorScheme.onSurfaceVariant
460+ Text(
461+ countText,
462+ style = MaterialTheme.typography.labelSmall,
463+ color = color,
464+ )
465+ }
466+ },
467+ )
468+ IconButton(onClick = onPrev, enabled = matchCount > 0) {
469+ Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
470+ }
471+ IconButton(onClick = onNext, enabled = matchCount > 0) {
472+ Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
473+ }
474+ IconButton(onClick = onClose) {
475+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
476+ }
477+ }
478+}
479+
480+@Composable
481+private fun FileTextContent(
482+ text: String,
483+ filename: String,
484+ topInset: androidx.compose.ui.unit.Dp,
485+ wrap: Boolean,
486+ findQuery: String,
487+ matchIndices: List<Int>,
488+ listState: LazyListState,
489+) {
490+ val lines = remember(text) { text.split("\n") }
491+ // Gutter width scales with the digit count and the font scale so accessibility
492+ // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
493+ // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
494+ // proportionally for larger font scales.
495+ val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
496+ val gutterWidth = remember(lines.size, fontScale) {
497+ ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
498+ }
499+ val hScrollState = rememberScrollState()
500+ val palette = rememberHighlightPalette()
501+ val q = findQuery.trim()
502+ val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
503+ LazyColumn(
504+ state = listState,
505+ modifier = Modifier
506+ .fillMaxSize()
507+ .padding(top = topInset)
508+ .padding(8.dp),
509+ ) {
510+ itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
511+ val isMatch = q.isNotEmpty() && index in matchSet
512+ Row(
513+ modifier = Modifier
514+ .fillMaxWidth()
515+ .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
516+ .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
517+ ) {
518+ Text(
519+ "${index + 1}",
520+ modifier = Modifier.width(gutterWidth),
521+ style = MaterialTheme.typography.bodySmall,
522+ fontFamily = FontFamily.Monospace,
523+ color = MaterialTheme.colorScheme.onSurfaceVariant,
524+ )
525+ // Heuristic syntax highlighting per line. Falls back to plain text for
526+ // unknown extensions, so non-code files render unchanged. Memoized so the
527+ // O(n) tokenizer + AnnotatedString allocation runs only when the line text,
528+ // file, or palette actually changes — not on every recomposition (e.g. every
529+ // keystroke into find-in-file, which would otherwise re-highlight all visible lines).
530+ val highlighted = remember(line, filename, palette) { highlightLine(line, filename, palette) }
531+ Text(
532+ highlighted,
533+ modifier = Modifier.padding(start = 8.dp),
534+ style = MaterialTheme.typography.bodySmall,
535+ )
536+ }
537+ }
538+ }
539+}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktdeleted−590
@@ -1,590 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.animation.AnimatedVisibility
8−import androidx.compose.animation.core.RepeatMode
9−import androidx.compose.animation.core.animateFloat
10−import androidx.compose.animation.core.infiniteRepeatable
11−import androidx.compose.animation.core.rememberInfiniteTransition
12−import androidx.compose.animation.core.tween
13−import androidx.compose.animation.fadeIn
14−import androidx.compose.animation.fadeOut
15−import androidx.compose.animation.scaleIn
16−import androidx.compose.animation.scaleOut
17−import androidx.compose.foundation.ExperimentalFoundationApi
18−import androidx.compose.foundation.background
19−import androidx.compose.foundation.clickable
20−import androidx.compose.foundation.combinedClickable
21−import androidx.compose.foundation.layout.Arrangement
22−import androidx.compose.foundation.layout.Box
23−import androidx.compose.foundation.layout.BoxScope
24−import androidx.compose.foundation.layout.Column
25−import androidx.compose.foundation.layout.Row
26−import androidx.compose.foundation.layout.Spacer
27−import androidx.compose.foundation.layout.fillMaxSize
28−import androidx.compose.foundation.layout.fillMaxWidth
29−import androidx.compose.foundation.layout.heightIn
30−import androidx.compose.foundation.layout.PaddingValues
31−import androidx.compose.foundation.layout.WindowInsets
32−import androidx.compose.foundation.layout.ime
33−import androidx.compose.foundation.layout.imePadding
34−import androidx.compose.foundation.layout.padding
35−import androidx.compose.foundation.layout.size
36−import androidx.compose.foundation.lazy.LazyColumn
37−import androidx.compose.foundation.lazy.itemsIndexed
38−import androidx.compose.foundation.lazy.rememberLazyListState
39−import androidx.compose.foundation.shape.CircleShape
40−import androidx.compose.foundation.shape.RoundedCornerShape
41−import androidx.compose.foundation.text.KeyboardActions
42−import androidx.compose.foundation.text.KeyboardOptions
43−import androidx.compose.material.icons.Icons
44−import androidx.compose.material.icons.automirrored.filled.ArrowBack
45−import androidx.compose.material.icons.automirrored.filled.Send
46−import androidx.compose.material.icons.filled.ChatBubbleOutline
47−import androidx.compose.material.icons.filled.Close
48−import androidx.compose.material.icons.filled.KeyboardArrowDown
49−import androidx.compose.material.icons.filled.KeyboardArrowUp
50−import androidx.compose.material.icons.filled.Stop
51−import androidx.compose.material3.CircularProgressIndicator
52−import androidx.compose.material3.ExperimentalMaterial3Api
53−import androidx.compose.material3.FilledIconButton
54−import androidx.compose.material3.FilledTonalButton
55−import androidx.compose.material3.FloatingActionButton
56−import androidx.compose.material3.Icon
57−import androidx.compose.material3.IconButton
58−import androidx.compose.material3.LinearProgressIndicator
59−import androidx.compose.material3.MaterialTheme
60−import androidx.compose.material3.OutlinedTextField
61−import androidx.compose.material3.Scaffold
62−import androidx.compose.material3.Surface
63−import androidx.compose.material3.Text
64−import androidx.compose.material3.TextButton
65−import androidx.compose.material3.TopAppBar
66−import androidx.compose.runtime.Composable
67−import androidx.compose.runtime.LaunchedEffect
68−import androidx.compose.runtime.derivedStateOf
69−import androidx.compose.runtime.getValue
70−import androidx.compose.runtime.mutableStateOf
71−import androidx.compose.runtime.remember
72−import androidx.compose.runtime.rememberCoroutineScope
73−import androidx.compose.runtime.setValue
74−import androidx.compose.ui.Alignment
75−import androidx.compose.ui.Modifier
76−import androidx.compose.ui.draw.clip
77−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
78−import androidx.compose.ui.platform.LocalClipboardManager
79−import androidx.compose.ui.platform.LocalContext
80−import androidx.compose.ui.platform.LocalDensity
81−import androidx.compose.ui.platform.LocalHapticFeedback
82−import androidx.compose.ui.text.font.FontStyle
83−import androidx.compose.ui.text.input.ImeAction
84−import androidx.compose.ui.text.style.TextAlign
85−import androidx.compose.ui.text.style.TextOverflow
86−import androidx.compose.ui.unit.dp
87−import kotlinx.coroutines.launch
88−import soy.iko.opencode.core.Event
89−import soy.iko.opencode.core.UiState
90−import soy.iko.opencode.ui.components.MessageBubble
91−
92−@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
93−@Composable
94−fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
95− val listState = rememberLazyListState()
96− val coroutineScope = rememberCoroutineScope()
97− val context = LocalContext.current
98−
99− // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
100− // sit over the top of the IME. Reading the ime inset bottom during composition
101− // observes snapshot state, so this flips as the keyboard opens/closes.
102− val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
103−
104− // Show a jump-to-bottom button once the user scrolls up away from the latest
105− // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
106− // it only flips when the near-bottom threshold is actually crossed.
107− val showScrollButton by remember {
108− derivedStateOf {
109− val layoutInfo = listState.layoutInfo
110− val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111− val totalItems = layoutInfo.totalItemsCount
112− totalItems > 0 && lastVisibleIndex < totalItems - 3
113− }
114− }
115−
116− // Offer a jump-to-top shortcut once the conversation is long and the user has
117− // scrolled a few messages down — saves flick-scrolling back through history.
118− val showScrollTop by remember {
119− derivedStateOf { state.messages.size > 10 && listState.firstVisibleItemIndex > 3 }
120− }
121−
122− // Track how many messages the user has "seen" (i.e. was at the bottom for). While
123− // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
124− var lastSeenCount by remember { mutableStateOf(0) }
125− LaunchedEffect(showScrollButton, state.messages.size) {
126− if (!showScrollButton) {
127− lastSeenCount = state.messages.size
128− }
129− }
130− val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131−
132− // Keep the newest content in view as it streams in, but only when the user is
133− // already near the bottom. If they've scrolled up to read history, leave it alone.
134− val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
135− LaunchedEffect(state.messages.size, lastLen, state.generating) {
136− if (state.messages.isNotEmpty()) {
137− val layoutInfo = listState.layoutInfo
138− val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
139− val totalItems = layoutInfo.totalItemsCount
140− val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
141− if (nearBottom) {
142− listState.animateScrollToItem(state.messages.size - 1)
143− }
144− }
145− }
146−
147− Scaffold(
148− topBar = {
149− TopAppBar(
150− title = {
151− val title = state.currentSessionTitle.ifEmpty { "Chat" }
152− Text(
153− text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
154− maxLines = 1,
155− overflow = TextOverflow.Ellipsis,
156− modifier = Modifier.combinedClickable(
157− onClick = { dispatch(Event.NavigateToSessions) },
158− onLongClick = {
159− state.currentSessionId?.let { id ->
160− copyToClipboard(context, id)
161− Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
162− }
163− },
164− ),
165− )
166− },
167− navigationIcon = {
168− IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
169− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
170− }
171− },
172− actions = {
173− if (state.generating) {
174− CircularProgressIndicator(
175− modifier = Modifier.padding(end = 16.dp).size(22.dp),
176− strokeWidth = 2.dp,
177− )
178− }
179− },
180− )
181− },
182− bottomBar = {
183− ChatInputBar(
184− draft = state.draft,
185− generating = state.generating,
186− loading = state.loading && state.messages.isEmpty(),
187− onDraftChange = { dispatch(Event.DraftChanged(it)) },
188− onSend = { dispatch(Event.SendMessage(state.draft)) },
189− onStop = { dispatch(Event.CancelGeneration) },
190− )
191− },
192− ) { padding ->
193− Column(Modifier.fillMaxSize().padding(padding)) {
194− if (!state.sseConnected && !state.generating) {
195− ReconnectingBanner()
196− }
197− if (state.loading && state.messages.isEmpty()) {
198− LinearProgressIndicator(Modifier.fillMaxWidth())
199− }
200− if (state.error != null && state.messages.isNotEmpty()) {
201− ErrorBanner(
202− message = state.error,
203− onDismiss = { dispatch(Event.DismissError) },
204− )
205− }
206− Box(Modifier.fillMaxWidth().weight(1f)) {
207− if (state.messages.isEmpty() && !state.loading) {
208− Column(
209− modifier = Modifier.align(Alignment.Center).padding(32.dp),
210− horizontalAlignment = Alignment.CenterHorizontally,
211− ) {
212− Icon(
213− Icons.Filled.ChatBubbleOutline,
214− contentDescription = null,
215− modifier = Modifier.size(48.dp),
216− tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
217− )
218− Spacer(Modifier.size(16.dp))
219− Text(
220− text = "Send a message to start the conversation.",
221− style = MaterialTheme.typography.bodyMedium,
222− color = MaterialTheme.colorScheme.onSurfaceVariant,
223− textAlign = TextAlign.Center,
224− )
225− }
226− }
227− LazyColumn(
228− state = listState,
229− modifier = Modifier.fillMaxSize(),
230− contentPadding = PaddingValues(vertical = 8.dp),
231− ) {
232− itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
233− // Insert a date separator whenever a message falls on a different
234− // calendar day than the one before it (and above the first message).
235− val previous = state.messages.getOrNull(index - 1)
236− if (shouldShowDateSeparator(previous, message)) {
237− DateSeparator(message.time)
238− }
239− val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
240− MessageBubble(
241− message,
242− onRetry = { dispatch(Event.SendMessage(message.text)) },
243− compactSpacing = sameSenderAsPrevious,
244− )
245− }
246− // While the assistant is spinning up but hasn't emitted a streaming
247− // message yet (only the user's message is present), show a typing
248− // indicator so the wait doesn't look like a stall.
249− if (state.generating && state.messages.none { it.streaming }) {
250− item(key = "typing-indicator") {
251− TypingIndicator()
252− }
253− }
254− }
255− ScrollToBottomButton(
256− visible = showScrollButton && !isImeVisible,
257− showBadge = hasUnseenMessages,
258− onClick = {
259− coroutineScope.launch {
260− if (state.messages.isNotEmpty()) {
261− // When the typing indicator is showing it occupies the item
262− // just past the last message, so scroll one further to reach it.
263− val target = if (state.generating && state.messages.none { it.streaming })
264− state.messages.size
265− else
266− state.messages.size - 1
267− listState.animateScrollToItem(target)
268− }
269− }
270− },
271− )
272− ScrollToTopButton(
273− visible = showScrollTop,
274− onClick = {
275− coroutineScope.launch {
276− listState.animateScrollToItem(0)
277− }
278− },
279− )
280− }
281− }
282− }
283−}
284−
285−// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
286−// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
287−// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
288−@Composable
289−private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
290− AnimatedVisibility(
291− visible = visible,
292− modifier = Modifier.align(Alignment.BottomEnd),
293− enter = scaleIn() + fadeIn(),
294− exit = scaleOut() + fadeOut(),
295− ) {
296− Box(Modifier.padding(16.dp)) {
297− FloatingActionButton(
298− onClick = onClick,
299− modifier = Modifier.size(44.dp),
300− ) {
301− Icon(
302− Icons.Filled.KeyboardArrowDown,
303− contentDescription = "Scroll to latest message",
304− )
305− }
306− if (showBadge) {
307− Box(
308− modifier = Modifier
309− .align(Alignment.TopEnd)
310− .size(8.dp)
311− .clip(CircleShape)
312− .background(MaterialTheme.colorScheme.error),
313− )
314− }
315− }
316− }
317−}
318−
319−// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320−// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
321−@Composable
322−private fun BoxScope.ScrollToTopButton(visible: Boolean, onClick: () -> Unit) {
323− AnimatedVisibility(
324− visible = visible,
325− modifier = Modifier.align(Alignment.TopStart),
326− enter = scaleIn() + fadeIn(),
327− exit = scaleOut() + fadeOut(),
328− ) {
329− Box(Modifier.padding(16.dp)) {
330− FloatingActionButton(
331− onClick = onClick,
332− modifier = Modifier.size(44.dp),
333− ) {
334− Icon(
335− Icons.Filled.KeyboardArrowUp,
336− contentDescription = "Scroll to top",
337− )
338− }
339− }
340− }
341−}
342−
343−private fun copyToClipboard(context: Context, text: String) {
344− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
345− clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
346−}
347−
348−private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
349−
350−/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
351−private fun localDayOf(epochMillis: Long): java.time.LocalDate =
352− java.time.Instant.ofEpochMilli(epochMillis)
353− .atZone(java.time.ZoneId.systemDefault())
354− .toLocalDate()
355−
356−/**
357− * A separator is shown above a message when it starts a new calendar day — i.e. the
358− * first timestamped message, or one whose day differs from the previous message's.
359− * Messages without a valid time (time <= 0) never get one.
360− */
361−private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
362− if (current.time <= 0) return false
363− if (previous == null || previous.time <= 0) return true
364− return localDayOf(previous.time) != localDayOf(current.time)
365−}
366−
367−/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
368−private fun dateSeparatorLabel(day: java.time.LocalDate): String {
369− val today = java.time.LocalDate.now()
370− return when (day) {
371− today -> "Today"
372− today.minusDays(1) -> "Yesterday"
373− else -> day.format(dateSeparatorFormat)
374− }
375−}
376−
377−@Composable
378−private fun DateSeparator(epochMillis: Long) {
379− Text(
380− text = dateSeparatorLabel(localDayOf(epochMillis)),
381− style = MaterialTheme.typography.labelSmall,
382− color = MaterialTheme.colorScheme.onSurfaceVariant,
383− textAlign = TextAlign.Center,
384− modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
385− )
386−}
387−
388−// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
389−// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
390−// surfaceVariant look so it reads as an incoming message.
391−@Composable
392−private fun TypingIndicator() {
393− val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
394− val transition = rememberInfiniteTransition(label = "typing")
395− Row(
396− modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
397− horizontalArrangement = Arrangement.Start,
398− ) {
399− Row(
400− modifier = Modifier
401− .clip(shape)
402− .background(MaterialTheme.colorScheme.surfaceVariant)
403− .padding(horizontal = 14.dp, vertical = 12.dp),
404− verticalAlignment = Alignment.CenterVertically,
405− horizontalArrangement = Arrangement.spacedBy(6.dp),
406− ) {
407− Text(
408− text = "Thinking",
409− style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
410− color = MaterialTheme.colorScheme.onSurfaceVariant,
411− )
412− Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
413− // Stagger the three dots so they fade in and out in sequence.
414− listOf(0, 150, 300).forEach { staggerMs ->
415− val alpha by transition.animateFloat(
416− initialValue = 0.2f,
417− targetValue = 1f,
418− animationSpec = infiniteRepeatable(
419− animation = tween(durationMillis = 600, delayMillis = staggerMs),
420− repeatMode = RepeatMode.Reverse,
421− ),
422− label = "dot",
423− )
424− Box(
425− modifier = Modifier
426− .size(6.dp)
427− .clip(CircleShape)
428− .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
429− )
430− }
431− }
432− }
433− }
434−}
435−
436−@Composable
437−private fun ReconnectingBanner() {
438− Surface(
439− modifier = Modifier.fillMaxWidth(),
440− color = MaterialTheme.colorScheme.tertiaryContainer,
441− ) {
442− Row(
443− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
444− verticalAlignment = Alignment.CenterVertically,
445− horizontalArrangement = Arrangement.spacedBy(8.dp),
446− ) {
447− CircularProgressIndicator(
448− modifier = Modifier.size(14.dp),
449− strokeWidth = 2.dp,
450− color = MaterialTheme.colorScheme.onTertiaryContainer,
451− )
452− Text(
453− text = "Reconnecting…",
454− style = MaterialTheme.typography.bodySmall,
455− color = MaterialTheme.colorScheme.onTertiaryContainer,
456− )
457− }
458− }
459−}
460−
461−@Composable
462−private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
463− Surface(
464− modifier = Modifier.fillMaxWidth(),
465− color = MaterialTheme.colorScheme.errorContainer,
466− ) {
467− Row(
468− modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
469− verticalAlignment = Alignment.CenterVertically,
470− horizontalArrangement = Arrangement.spacedBy(8.dp),
471− ) {
472− Text(
473− text = message,
474− style = MaterialTheme.typography.bodySmall,
475− color = MaterialTheme.colorScheme.onErrorContainer,
476− modifier = Modifier.weight(1f),
477− )
478− IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
479− Icon(
480− Icons.Filled.Close,
481− contentDescription = "Dismiss error",
482− tint = MaterialTheme.colorScheme.onErrorContainer,
483− modifier = Modifier.size(18.dp),
484− )
485− }
486− }
487− }
488−}
489−
490−@Composable
491−private fun ChatInputBar(
492− draft: String,
493− generating: Boolean,
494− loading: Boolean,
495− onDraftChange: (String) -> Unit,
496− onSend: () -> Unit,
497− onStop: () -> Unit,
498−) {
499− val haptic = LocalHapticFeedback.current
500− val clipboardManager = LocalClipboardManager.current
501− Surface(tonalElevation = 3.dp) {
502− Column(
503− modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
504− ) {
505− Row(
506− modifier = Modifier.fillMaxWidth(),
507− verticalAlignment = Alignment.Bottom,
508− horizontalArrangement = Arrangement.spacedBy(8.dp),
509− ) {
510− OutlinedTextField(
511− value = draft,
512− onValueChange = onDraftChange,
513− modifier = Modifier.weight(1f).heightIn(min = 48.dp),
514− enabled = !loading,
515− placeholder = { Text("Message opencode…") },
516− maxLines = 5,
517− // Offer a quick way to wipe the draft once there's something to clear
518− // and we're not mid-generation (where the field is a poor place to fuss).
519− trailingIcon = if (draft.isNotEmpty() && !generating) {
520− {
521− IconButton(
522− onClick = { onDraftChange("") },
523− modifier = Modifier.size(20.dp),
524− ) {
525− Icon(
526− Icons.Filled.Close,
527− contentDescription = "Clear draft",
528− modifier = Modifier.size(16.dp),
529− )
530− }
531− }
532− } else {
533− null
534− },
535− // Surface a running count once the draft gets long, so the user has a
536− // sense of scale before sending a wall of text.
537− supportingText = if (draft.length > 500) {
538− {
539− Text(
540− text = "${draft.length} characters",
541− style = MaterialTheme.typography.labelSmall,
542− color = MaterialTheme.colorScheme.onSurfaceVariant,
543− textAlign = TextAlign.End,
544− modifier = Modifier.fillMaxWidth(),
545− )
546− }
547− } else {
548− null
549− },
550− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551− keyboardActions = KeyboardActions(onSend = {
552− if (!generating && !loading && draft.isNotBlank()) onSend()
553− }),
554− )
555− if (generating) {
556− FilledTonalButton(onClick = onStop) {
557− Icon(
558− Icons.Filled.Stop,
559− contentDescription = null,
560− modifier = Modifier.size(18.dp),
561− )
562− Spacer(Modifier.size(6.dp))
563− Text("Stop")
564− }
565− } else {
566− FilledIconButton(
567− onClick = {
568− haptic.performHapticFeedback(HapticFeedbackType.LongPress)
569− onSend()
570− },
571− enabled = draft.isNotBlank() && !loading,
572− ) {
573− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
574− }
575− }
576− }
577− // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
578− if (draft.isEmpty() && !generating && !loading) {
579− TextButton(
580− onClick = {
581− clipboardManager.getText()?.text?.let { onDraftChange(it) }
582− },
583− contentPadding = PaddingValues(0.dp),
584− ) {
585− Text("Paste")
586− }
587− }
588− }
589− }
590−}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktdeleted−222
@@ -1,222 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Spacer
6−import androidx.compose.foundation.layout.fillMaxSize
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.height
9−import androidx.compose.foundation.layout.imePadding
10−import androidx.compose.foundation.layout.padding
11−import androidx.compose.foundation.layout.size
12−import androidx.compose.foundation.rememberScrollState
13−import androidx.compose.foundation.text.KeyboardActions
14−import androidx.compose.foundation.text.KeyboardOptions
15−import androidx.compose.foundation.verticalScroll
16−import androidx.compose.material.icons.Icons
17−import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material.icons.filled.Person
19−import androidx.compose.material.icons.filled.Terminal
20−import androidx.compose.material.icons.filled.Visibility
21−import androidx.compose.material.icons.filled.VisibilityOff
22−import androidx.compose.material3.Button
23−import androidx.compose.material3.CircularProgressIndicator
24−import androidx.compose.material3.Icon
25−import androidx.compose.material3.IconButton
26−import androidx.compose.material3.MaterialTheme
27−import androidx.compose.material3.OutlinedTextField
28−import androidx.compose.material3.Text
29−import androidx.compose.runtime.Composable
30−import androidx.compose.runtime.LaunchedEffect
31−import androidx.compose.runtime.getValue
32−import androidx.compose.runtime.mutableStateOf
33−import androidx.compose.runtime.remember
34−import androidx.compose.runtime.setValue
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.focus.FocusRequester
38−import androidx.compose.ui.focus.focusRequester
39−import androidx.compose.ui.text.input.ImeAction
40−import androidx.compose.ui.text.input.KeyboardType
41−import androidx.compose.ui.text.input.PasswordVisualTransformation
42−import androidx.compose.ui.text.input.VisualTransformation
43−import androidx.compose.ui.text.style.TextAlign
44−import androidx.compose.ui.unit.dp
45−import soy.iko.opencode.core.Event
46−import soy.iko.opencode.core.UiState
47−
48−@Composable
49−fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50− val focusRequester = remember { FocusRequester() }
51− var passwordVisible by remember { mutableStateOf(false) }
52−
53− // Focus the server URL field when the screen opens, but only when it's empty
54− // so we don't steal focus (and pop the keyboard) on a return visit.
55− LaunchedEffect(Unit) {
56− if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
57− }
58−
59− Column(
60− modifier = Modifier
61− .fillMaxSize()
62− .verticalScroll(rememberScrollState())
63− .imePadding()
64− .padding(horizontal = 28.dp),
65− verticalArrangement = Arrangement.Center,
66− horizontalAlignment = Alignment.CenterHorizontally,
67− ) {
68− Icon(
69− Icons.Filled.Terminal,
70− contentDescription = null,
71− tint = MaterialTheme.colorScheme.primary,
72− modifier = Modifier.size(72.dp),
73− )
74− Spacer(Modifier.height(16.dp))
75− Text(
76− text = "opencode",
77− style = MaterialTheme.typography.displaySmall,
78− color = MaterialTheme.colorScheme.primary,
79− )
80− Text(
81− text = "Connect to your opencode server",
82− style = MaterialTheme.typography.bodyMedium,
83− color = MaterialTheme.colorScheme.onSurfaceVariant,
84− textAlign = TextAlign.Center,
85− )
86−
87− Spacer(Modifier.height(32.dp))
88−
89− OutlinedTextField(
90− value = state.serverUrl,
91− onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
92− label = { Text("Server URL") },
93− placeholder = { Text("http://192.168.1.10:4096") },
94− singleLine = true,
95− modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
96− keyboardOptions = KeyboardOptions(
97− keyboardType = KeyboardType.Uri,
98− imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
99− ),
100− keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
101− enabled = !state.loading,
102− )
103−
104− // Point first-time users at the expected address format. Hidden once they
105− // start typing so it doesn't compete with the field's own contents.
106− if (state.serverUrl.isEmpty()) {
107− Spacer(Modifier.height(6.dp))
108− Text(
109− text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110− style = MaterialTheme.typography.bodySmall,
111− color = MaterialTheme.colorScheme.onSurfaceVariant,
112− textAlign = TextAlign.Center,
113− )
114− }
115−
116− // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117− // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118− if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119− Spacer(Modifier.height(6.dp))
120− Text(
121− text = "Tip: Add http:// if connecting to a local server",
122− style = MaterialTheme.typography.bodySmall,
123− color = MaterialTheme.colorScheme.tertiary,
124− textAlign = TextAlign.Center,
125− )
126− }
127−
128− if (state.authRequired) {
129− Spacer(Modifier.height(12.dp))
130− OutlinedTextField(
131− value = state.username,
132− onValueChange = { dispatch(Event.UsernameChanged(it)) },
133− label = { Text("Username") },
134− singleLine = true,
135− leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136− modifier = Modifier.fillMaxWidth(),
137− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138− enabled = !state.loading,
139− )
140− Spacer(Modifier.height(12.dp))
141− OutlinedTextField(
142− value = state.password,
143− onValueChange = { dispatch(Event.PasswordChanged(it)) },
144− label = { Text("Password") },
145− singleLine = true,
146− visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
147− leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
148− trailingIcon = {
149− IconButton(onClick = { passwordVisible = !passwordVisible }) {
150− Icon(
151− imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
152− contentDescription = if (passwordVisible) "Hide password" else "Show password",
153− )
154− }
155− },
156− modifier = Modifier.fillMaxWidth(),
157− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158− keyboardActions = KeyboardActions(onGo = {
159− // Mirror the connect button's enabled condition so the keyboard's
160− // Go action can't fire a request the form isn't ready for.
161− if (!state.loading && state.serverUrl.isNotBlank() &&
162− (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163− ) {
164− dispatch(Event.Connect)
165− }
166− }),
167− enabled = !state.loading,
168− )
169− Spacer(Modifier.height(4.dp))
170− Text(
171− text = "This server requires authentication.",
172− style = MaterialTheme.typography.bodySmall,
173− color = MaterialTheme.colorScheme.onSurfaceVariant,
174− )
175− }
176−
177− Spacer(Modifier.height(24.dp))
178−
179− Button(
180− onClick = { dispatch(Event.Connect) },
181− // When auth is required, keep the button disabled until at least one
182− // credential is entered — otherwise it just triggers a validation error.
183− enabled = !state.loading && state.serverUrl.isNotBlank() &&
184− (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
185− modifier = Modifier.fillMaxWidth(),
186− ) {
187− if (state.loading) {
188− CircularProgressIndicator(
189− modifier = Modifier.height(20.dp),
190− strokeWidth = 2.dp,
191− color = MaterialTheme.colorScheme.onPrimary,
192− )
193− } else {
194− Text(if (state.authRequired) "Sign in" else "Connect")
195− }
196− }
197−
198− // While connecting (including auto-connect on launch), echo the target URL
199− // so the user knows what the spinner is waiting on.
200− if (state.loading) {
201− Spacer(Modifier.height(8.dp))
202− Text(
203− text = "Connecting to ${state.serverUrl}...",
204− style = MaterialTheme.typography.bodySmall,
205− color = MaterialTheme.colorScheme.onSurfaceVariant,
206− textAlign = TextAlign.Center,
207− )
208− }
209−
210− // Surface the error inline as well, so it stays visible while the user
211− // corrects the form (the snackbar is transient).
212− if (state.error != null && !state.loading) {
213− Spacer(Modifier.height(8.dp))
214− Text(
215− text = state.error,
216− style = MaterialTheme.typography.bodySmall,
217− color = MaterialTheme.colorScheme.error,
218− textAlign = TextAlign.Center,
219− )
220− }
221− }
222−}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktdeleted−402
@@ -1,402 +0,0 @@
1−package soy.iko.opencode.ui.screens
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.foundation.ExperimentalFoundationApi
8−import androidx.compose.foundation.background
9−import androidx.compose.foundation.clickable
10−import androidx.compose.foundation.combinedClickable
11−import androidx.compose.foundation.layout.Arrangement
12−import androidx.compose.foundation.layout.Box
13−import androidx.compose.foundation.layout.Column
14−import androidx.compose.foundation.layout.PaddingValues
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.Spacer
17−import androidx.compose.foundation.layout.fillMaxSize
18−import androidx.compose.foundation.layout.fillMaxWidth
19−import androidx.compose.foundation.layout.height
20−import androidx.compose.foundation.layout.padding
21−import androidx.compose.foundation.layout.size
22−import androidx.compose.foundation.lazy.LazyColumn
23−import androidx.compose.foundation.lazy.items
24−import androidx.compose.foundation.lazy.rememberLazyListState
25−import androidx.compose.foundation.shape.CircleShape
26−import androidx.compose.material.icons.Icons
27−import androidx.compose.material.icons.automirrored.filled.ArrowBack
28−import androidx.compose.material.icons.filled.Add
29−import androidx.compose.material.icons.filled.ChatBubbleOutline
30−import androidx.compose.material.icons.filled.ChevronRight
31−import androidx.compose.material.icons.filled.DeleteOutline
32−import androidx.compose.material.icons.filled.Refresh
33−import androidx.compose.material3.AlertDialog
34−import androidx.compose.material3.CircularProgressIndicator
35−import androidx.compose.material3.ExperimentalMaterial3Api
36−import androidx.compose.material3.ExtendedFloatingActionButton
37−import androidx.compose.material3.HorizontalDivider
38−import androidx.compose.material3.Icon
39−import androidx.compose.material3.IconButton
40−import androidx.compose.material3.LinearProgressIndicator
41−import androidx.compose.material3.MaterialTheme
42−import androidx.compose.material3.OutlinedTextField
43−import androidx.compose.material3.Scaffold
44−import androidx.compose.material3.Surface
45−import androidx.compose.material3.SwipeToDismissBox
46−import androidx.compose.material3.SwipeToDismissBoxValue
47−import androidx.compose.material3.Text
48−import androidx.compose.material3.TextButton
49−import androidx.compose.material3.TopAppBar
50−import androidx.compose.material3.rememberSwipeToDismissBoxState
51−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52−import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
53−import androidx.compose.runtime.Composable
54−import androidx.compose.runtime.LaunchedEffect
55−import androidx.compose.runtime.getValue
56−import androidx.compose.runtime.mutableStateOf
57−import androidx.compose.runtime.remember
58−import androidx.compose.runtime.setValue
59−import androidx.compose.ui.Alignment
60−import androidx.compose.ui.Modifier
61−import androidx.compose.ui.draw.clip
62−import androidx.compose.ui.graphics.Color
63−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
64−import androidx.compose.ui.platform.LocalContext
65−import androidx.compose.ui.platform.LocalHapticFeedback
66−import androidx.compose.ui.text.style.TextAlign
67−import androidx.compose.ui.text.style.TextOverflow
68−import androidx.compose.ui.unit.dp
69−import soy.iko.opencode.core.Event
70−import soy.iko.opencode.core.SessionView
71−import soy.iko.opencode.core.UiState
72−
73−@OptIn(ExperimentalMaterial3Api::class)
74−@Composable
75−fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
76− val haptic = LocalHapticFeedback.current
77− var pendingDeleteId by remember { mutableStateOf<String?>(null) }
78− var isPullRefreshing by remember { mutableStateOf(false) }
79− var searchQuery by remember { mutableStateOf("") }
80− val sessionsListState = rememberLazyListState()
81−
82− // Bring a newly created session (which lands at the top of the list) into view.
83− // Only scroll when the list actually grows, not on every reload of the same sessions.
84− var prevSize by remember { mutableStateOf(state.sessions.size) }
85− LaunchedEffect(state.sessions.size) {
86− if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
87− prevSize = state.sessions.size
88− }
89−
90− // The pull-to-refresh spinner should only reflect a user-initiated pull, not
91− // every loading operation. Clear it once the underlying load completes.
92− LaunchedEffect(state.loading) {
93− if (!state.loading) isPullRefreshing = false
94− }
95−
96− pendingDeleteId?.let { deleteId ->
97− val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
98− AlertDialog(
99− onDismissRequest = { pendingDeleteId = null },
100− title = { Text("Delete session?") },
101− text = { Text(pendingSession?.title ?: deleteId) },
102− confirmButton = {
103− TextButton(onClick = {
104− haptic.performHapticFeedback(HapticFeedbackType.LongPress)
105− dispatch(Event.DeleteSession(deleteId))
106− pendingDeleteId = null
107− }) {
108− Text("Delete")
109− }
110− },
111− dismissButton = {
112− TextButton(onClick = { pendingDeleteId = null }) {
113− Text("Cancel")
114− }
115− },
116− )
117− }
118−
119− Scaffold(
120− topBar = {
121− TopAppBar(
122− title = {
123− Row(verticalAlignment = Alignment.CenterVertically) {
124− // A quick at-a-glance health dot for the SSE stream: green when
125− // connected, error-tinted when the stream is down (updates stall).
126− Box(
127− modifier = Modifier
128− .size(8.dp)
129− .clip(CircleShape)
130− .background(
131− if (state.sseConnected) Color(0xFF4CAF50)
132− else MaterialTheme.colorScheme.error,
133− ),
134− )
135− Spacer(Modifier.size(8.dp))
136− Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions")
137− }
138− },
139− navigationIcon = {
140− IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
142− }
143− },
144− actions = {
145− IconButton(onClick = { dispatch(Event.LoadSessions) }) {
146− Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
147− }
148− },
149− )
150− },
151− floatingActionButton = {
152− // Give the search results the full width while searching — the FAB would
153− // otherwise cover the bottom rows and isn't relevant mid-search anyway.
154− if (searchQuery.isEmpty()) {
155− ExtendedFloatingActionButton(
156− onClick = { dispatch(Event.CreateSession) },
157− icon = { Icon(Icons.Filled.Add, contentDescription = null) },
158− text = { Text("New session") },
159− )
160− }
161− },
162− ) { padding ->
163− Column(Modifier.fillMaxSize().padding(padding)) {
164− if (!state.sseConnected) {
165− ReconnectingBanner()
166− }
167− if (state.loading && !isPullRefreshing) {
168− LinearProgressIndicator(Modifier.fillMaxWidth())
169− }
170− if (state.sessions.isNotEmpty()) {
171− OutlinedTextField(
172− value = searchQuery,
173− onValueChange = { searchQuery = it },
174− placeholder = { Text("Search sessions") },
175− singleLine = true,
176− modifier = Modifier
177− .fillMaxWidth()
178− .padding(horizontal = 16.dp, vertical = 8.dp),
179− )
180− }
181− val filteredSessions = if (searchQuery.isEmpty()) {
182− state.sessions
183− } else {
184− state.sessions.filter {
185− it.title.contains(searchQuery, ignoreCase = true) ||
186− it.id.contains(searchQuery, ignoreCase = true)
187− }
188− }
189− when {
190− state.sessions.isEmpty() && !state.loading -> EmptySessions()
191− filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
192− else -> PullToRefreshBox(
193− isRefreshing = isPullRefreshing,
194− onRefresh = {
195− isPullRefreshing = true
196− dispatch(Event.LoadSessions)
197− },
198− modifier = Modifier.fillMaxSize(),
199− state = rememberPullToRefreshState(),
200− ) {
201− LazyColumn(
202− state = sessionsListState,
203− modifier = Modifier.fillMaxSize(),
204− contentPadding = PaddingValues(bottom = 88.dp),
205− ) {
206− items(filteredSessions, key = { it.id }) { session ->
207− // animateItem() slides the remaining rows into place when one is
208− // deleted (and fades new rows in) instead of snapping instantly.
209− Column(modifier = Modifier.animateItem()) {
210− val dismissState = rememberSwipeToDismissBoxState(
211− // Only a swipe from the end (right-to-left) triggers deletion;
212− // return false so the row snaps back and the confirm dialog decides.
213− confirmValueChange = { value ->
214− if (value == SwipeToDismissBoxValue.EndToStart) {
215− pendingDeleteId = session.id
216− }
217− false
218− },
219− )
220− SwipeToDismissBox(
221− state = dismissState,
222− enableDismissFromStartToEnd = false,
223− backgroundContent = { SwipeDeleteBackground() },
224− ) {
225− SessionRow(
226− session = session,
227− onOpen = { dispatch(Event.SelectSession(session.id)) },
228− onDelete = { pendingDeleteId = session.id },
229− )
230− }
231− HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
232− }
233− }
234− }
235− }
236− }
237− }
238− }
239−}
240−
241−@OptIn(ExperimentalFoundationApi::class)
242−@Composable
243−private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
244− val context = LocalContext.current
245− Row(
246− modifier = Modifier
247− .fillMaxWidth()
248− .background(MaterialTheme.colorScheme.surface)
249− .combinedClickable(
250− onClick = onOpen,
251− onLongClick = {
252− copyToClipboard(context, session.id)
253− Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
254− },
255− )
256− .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
257− verticalAlignment = Alignment.CenterVertically,
258− ) {
259− Icon(
260− Icons.Filled.ChatBubbleOutline,
261− contentDescription = null,
262− tint = MaterialTheme.colorScheme.primary,
263− modifier = Modifier.padding(end = 16.dp),
264− )
265− Column(Modifier.weight(1f)) {
266− Text(
267− text = session.title,
268− style = MaterialTheme.typography.bodyLarge,
269− maxLines = 1,
270− overflow = TextOverflow.Ellipsis,
271− )
272− Text(
273− text = shortSessionId(session.id),
274− style = MaterialTheme.typography.bodySmall,
275− color = MaterialTheme.colorScheme.onSurfaceVariant,
276− maxLines = 1,
277− overflow = TextOverflow.Ellipsis,
278− )
279− }
280− Icon(
281− Icons.Filled.ChevronRight,
282− contentDescription = null,
283− tint = MaterialTheme.colorScheme.onSurfaceVariant,
284− modifier = Modifier.padding(horizontal = 4.dp),
285− )
286− IconButton(onClick = onDelete) {
287− Icon(
288− Icons.Filled.DeleteOutline,
289− contentDescription = "Delete session",
290− tint = MaterialTheme.colorScheme.error,
291− )
292− }
293− }
294−}
295−
296−/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
297−@Composable
298−private fun SwipeDeleteBackground() {
299− Box(
300− modifier = Modifier
301− .fillMaxSize()
302− .background(MaterialTheme.colorScheme.error)
303− .padding(horizontal = 24.dp),
304− contentAlignment = Alignment.CenterEnd,
305− ) {
306− Row(verticalAlignment = Alignment.CenterVertically) {
307− Icon(
308− Icons.Filled.DeleteOutline,
309− contentDescription = null,
310− tint = MaterialTheme.colorScheme.onError,
311− )
312− Text(
313− text = "Delete",
314− style = MaterialTheme.typography.labelLarge,
315− color = MaterialTheme.colorScheme.onError,
316− modifier = Modifier.padding(start = 4.dp),
317− )
318− }
319− }
320−}
321−
322−/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
323−@Composable
324−private fun ReconnectingBanner() {
325− Surface(
326− modifier = Modifier.fillMaxWidth(),
327− color = MaterialTheme.colorScheme.tertiaryContainer,
328− ) {
329− Row(
330− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
331− verticalAlignment = Alignment.CenterVertically,
332− horizontalArrangement = Arrangement.spacedBy(8.dp),
333− ) {
334− CircularProgressIndicator(
335− modifier = Modifier.size(14.dp),
336− strokeWidth = 2.dp,
337− color = MaterialTheme.colorScheme.onTertiaryContainer,
338− )
339− Text(
340− text = "Reconnecting…",
341− style = MaterialTheme.typography.bodySmall,
342− color = MaterialTheme.colorScheme.onTertiaryContainer,
343− )
344− }
345− }
346−}
347−
348−private fun copyToClipboard(context: Context, text: String) {
349− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
350− clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
351−}
352−
353−/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
354−private fun shortSessionId(id: String): String =
355− if (id.length > 12) id.take(8) + "…" else id
356−
357−@Composable
358−private fun NoSearchResults(query: String) {
359− Box(
360− modifier = Modifier.fillMaxSize().padding(32.dp),
361− contentAlignment = Alignment.Center,
362− ) {
363− Text(
364− text = "No results for “$query”",
365− style = MaterialTheme.typography.bodyMedium,
366− color = MaterialTheme.colorScheme.onSurfaceVariant,
367− )
368− }
369−}
370−
371−@Composable
372−private fun EmptySessions() {
373− Column(
374− modifier = Modifier.fillMaxSize().padding(32.dp),
375− verticalArrangement = Arrangement.Center,
376− horizontalAlignment = Alignment.CenterHorizontally,
377− ) {
378− Icon(
379− Icons.Filled.ChatBubbleOutline,
380− contentDescription = null,
381− modifier = Modifier.size(64.dp),
382− tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
383− )
384− Spacer(Modifier.height(16.dp))
385− Text(
386− text = "No sessions yet",
387− style = MaterialTheme.typography.titleMedium,
388− )
389− Text(
390− text = "Tap “New session” to start chatting.",
391− style = MaterialTheme.typography.bodyMedium,
392− color = MaterialTheme.colorScheme.onSurfaceVariant,
393− )
394− Spacer(Modifier.height(8.dp))
395− Text(
396− text = "Sessions you create on this server will appear here.",
397− style = MaterialTheme.typography.bodySmall,
398− color = MaterialTheme.colorScheme.onSurfaceVariant,
399− textAlign = TextAlign.Center,
400− )
401− }
402−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+356
@@ -0,0 +1,356 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedVisibility
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.fillMaxSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.imePadding
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.layout.widthIn
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.text.KeyboardActions
17+import androidx.compose.foundation.text.KeyboardOptions
18+import androidx.compose.foundation.verticalScroll
19+import androidx.compose.material.icons.Icons
20+import androidx.compose.material.icons.automirrored.filled.ArrowBack
21+import androidx.compose.material.icons.filled.Visibility
22+import androidx.compose.material.icons.filled.VisibilityOff
23+import androidx.compose.material3.AlertDialog
24+import androidx.compose.material3.Button
25+import androidx.compose.material3.CircularProgressIndicator
26+import androidx.compose.material3.ExperimentalMaterial3Api
27+import androidx.compose.material3.Icon
28+import androidx.compose.material3.IconButton
29+import androidx.compose.material3.MaterialTheme
30+import androidx.compose.material3.OutlinedButton
31+import androidx.compose.material3.OutlinedTextField
32+import androidx.compose.material3.Scaffold
33+import androidx.compose.material3.Text
34+import androidx.compose.material3.TextButton
35+import androidx.compose.material3.TopAppBar
36+import androidx.compose.runtime.Composable
37+import androidx.compose.runtime.getValue
38+import androidx.compose.runtime.mutableStateOf
39+import androidx.compose.runtime.remember
40+import androidx.compose.runtime.saveable.rememberSaveable
41+import androidx.compose.runtime.setValue
42+import androidx.compose.ui.Alignment
43+import androidx.compose.ui.Modifier
44+import androidx.compose.ui.platform.testTag
45+import androidx.compose.ui.semantics.contentDescription
46+import androidx.compose.ui.semantics.semantics
47+import androidx.compose.ui.text.input.ImeAction
48+import androidx.compose.ui.text.input.KeyboardType
49+import androidx.compose.ui.text.input.PasswordVisualTransformation
50+import androidx.compose.ui.text.input.VisualTransformation
51+import androidx.compose.ui.res.stringResource
52+import androidx.compose.ui.unit.dp
53+import androidx.lifecycle.compose.collectAsStateWithLifecycle
54+import androidx.lifecycle.viewmodel.compose.viewModel
55+import soy.iko.opencode.di.AppContainer
56+import soy.iko.opencode.R
57+import soy.iko.opencode.ui.vmFactory
58+
59+@OptIn(ExperimentalMaterial3Api::class)
60+@Composable
61+fun ServerEditScreen(
62+ container: AppContainer,
63+ profileId: String?,
64+ onDone: () -> Unit,
65+ sourceId: String? = null,
66+) {
67+ val vm: ServerEditViewModel =
68+ viewModel(factory = vmFactory { ServerEditViewModel(container, profileId, sourceId) })
69+ val state by vm.state.collectAsStateWithLifecycle()
70+ var showDiscardConfirm by rememberSaveable { mutableStateOf(false) }
71+ var passwordVisible by rememberSaveable { mutableStateOf(false) }
72+
73+ fun safeExit() {
74+ when {
75+ // A save is in flight: ignore the back gesture (matching the BackHandler guard
76+ // below). Popping here would cancel the save's viewModelScope coroutine and
77+ // silently abandon the write; the VM calls onDone itself once the save lands.
78+ state.saving -> Unit
79+ state.isDirty -> showDiscardConfirm = true
80+ else -> onDone()
81+ }
82+ }
83+
84+ // Intercept the system back gesture when the form has unsaved changes so the user
85+ // gets a chance to keep editing instead of losing their work silently.
86+ BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
87+ showDiscardConfirm = true
88+ }
89+
90+ Scaffold(
91+ topBar = {
92+ TopAppBar(
93+ title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
94+ navigationIcon = {
95+ IconButton(onClick = ::safeExit) {
96+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
97+ }
98+ },
99+ )
100+ },
101+ ) { padding ->
102+ if (!state.loaded) {
103+ val loadingLabel = stringResource(R.string.loading)
104+ Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
105+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
106+ }
107+ } else {
108+ ServerEditForm(
109+ state = state,
110+ passwordVisible = passwordVisible,
111+ onTogglePassword = { passwordVisible = !passwordVisible },
112+ padding = padding,
113+ vm = vm,
114+ onDone = onDone,
115+ )
116+ }
117+ }
118+
119+ if (showDiscardConfirm) {
120+ AlertDialog(
121+ onDismissRequest = { showDiscardConfirm = false },
122+ title = { Text(stringResource(R.string.discard_title)) },
123+ text = { Text(stringResource(R.string.discard_text)) },
124+ confirmButton = {
125+ TextButton(onClick = {
126+ showDiscardConfirm = false
127+ onDone()
128+ }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
129+ },
130+ dismissButton = {
131+ TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
132+ },
133+ )
134+ }
135+}
136+
137+@Composable
138+private fun ServerEditForm(
139+ state: ServerEditState,
140+ passwordVisible: Boolean,
141+ onTogglePassword: () -> Unit,
142+ padding: androidx.compose.foundation.layout.PaddingValues,
143+ vm: ServerEditViewModel,
144+ onDone: () -> Unit,
145+) {
146+ val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
147+ // canSave parses the base URL with java.net.URI (which throws — and fills a stack trace —
148+ // on the common half-typed URL). Memoize on baseUrl so it runs once per edit instead of
149+ // being re-parsed at each of the enabled= call sites on every keystroke's recomposition.
150+ val canSave = remember(state.baseUrl) { state.canSave }
151+ Column(
152+ modifier = Modifier
153+ .padding(padding)
154+ .imePadding()
155+ .widthIn(max = 600.dp)
156+ .padding(16.dp)
157+ .verticalScroll(rememberScrollState()),
158+ verticalArrangement = Arrangement.spacedBy(12.dp),
159+ ) {
160+ OutlinedTextField(
161+ value = state.label,
162+ onValueChange = { v -> vm.update { it.copy(label = v) } },
163+ label = { Text(stringResource(R.string.label_optional)) },
164+ singleLine = true,
165+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
166+ modifier = Modifier.fillMaxWidth().testTag("server_label"),
167+ )
168+ OutlinedTextField(
169+ value = state.baseUrl,
170+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
171+ label = { Text(stringResource(R.string.base_url)) },
172+ placeholder = { Text(stringResource(R.string.base_url_hint)) },
173+ singleLine = true,
174+ isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
175+ supportingText = {
176+ if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
177+ // Offer a one-tap "http://" fix for bare host:port input instead of a
178+ // generic error, so the user doesn't have to know the URL needs a scheme.
179+ val suggestion = suggestUrlScheme(state.baseUrl)
180+ if (suggestion != null) {
181+ TextButton(
182+ onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
183+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
184+ ) {
185+ Text(stringResource(R.string.suggest_scheme))
186+ }
187+ } else {
188+ Text(stringResource(R.string.invalid_url))
189+ }
190+ }
191+ },
192+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
193+ modifier = Modifier.fillMaxWidth().testTag("server_url"),
194+ )
195+ OutlinedButton(
196+ onClick = { vm.probe() },
197+ enabled = canSave && !state.probing && !state.saving,
198+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
199+ ) {
200+ if (state.probing) {
201+ CircularProgressIndicator(
202+ modifier = Modifier.size(20.dp),
203+ strokeWidth = 2.dp,
204+ )
205+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
206+ } else {
207+ Text(stringResource(R.string.check_connectivity))
208+ }
209+ }
210+ // Positive feedback when the probe found the server reachable without auth, so
211+ // the user knows the connection works before saving (closes the loop that the
212+ // prior "silently does nothing" Reachable path left open).
213+ if (state.probeReachable == true && !state.authFieldsVisible) {
214+ Text(
215+ stringResource(R.string.server_reachable),
216+ style = MaterialTheme.typography.bodySmall,
217+ color = MaterialTheme.colorScheme.primary,
218+ modifier = Modifier.fillMaxWidth(),
219+ )
220+ }
221+ if (!state.authFieldsVisible) {
222+ Text(
223+ stringResource(R.string.auth_help),
224+ style = MaterialTheme.typography.bodySmall,
225+ color = MaterialTheme.colorScheme.onSurfaceVariant,
226+ modifier = Modifier.fillMaxWidth(),
227+ )
228+ }
229+ AnimatedVisibility(visible = state.authFieldsVisible) {
230+ AuthFields(
231+ state = state,
232+ passwordVisible = passwordVisible,
233+ onTogglePassword = onTogglePassword,
234+ onUpdate = vm::update,
235+ onTestCredentials = vm::testCredentials,
236+ onImeDone = { if (canSave && !state.saving) vm.saveAndConnect(onDone) },
237+ )
238+ }
239+ Row(
240+ modifier = Modifier.fillMaxWidth(),
241+ horizontalArrangement = Arrangement.spacedBy(12.dp),
242+ ) {
243+ OutlinedButton(
244+ onClick = {
245+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
246+ vm.save(onDone)
247+ },
248+ enabled = canSave && !state.saving,
249+ modifier = Modifier.weight(1f),
250+ ) {
251+ if (state.saving) {
252+ CircularProgressIndicator(
253+ modifier = Modifier.size(20.dp),
254+ color = MaterialTheme.colorScheme.primary,
255+ strokeWidth = 2.dp,
256+ )
257+ Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
258+ } else {
259+ Text(stringResource(R.string.save))
260+ }
261+ }
262+ Button(
263+ onClick = {
264+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
265+ vm.saveAndConnect(onDone)
266+ },
267+ enabled = canSave && !state.saving,
268+ modifier = Modifier.weight(1f),
269+ ) {
270+ Text(stringResource(R.string.save_and_connect))
271+ }
272+ }
273+ state.error?.let { message ->
274+ Text(
275+ message,
276+ color = MaterialTheme.colorScheme.error,
277+ style = MaterialTheme.typography.bodySmall,
278+ modifier = Modifier.fillMaxWidth(),
279+ )
280+ }
281+ }
282+}
283+
284+@Composable
285+private fun AuthFields(
286+ state: ServerEditState,
287+ passwordVisible: Boolean,
288+ onTogglePassword: () -> Unit,
289+ onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
290+ onTestCredentials: () -> Unit,
291+ onImeDone: () -> Unit,
292+) {
293+ val showPasswordLabel = stringResource(R.string.show_password)
294+ val hidePasswordLabel = stringResource(R.string.hide_password)
295+ // Memoize the URI parse on baseUrl so the test-credentials button doesn't re-parse it on
296+ // every keystroke's recomposition (see ServerEditForm).
297+ val canSave = remember(state.baseUrl) { state.canSave }
298+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
299+ Text(
300+ stringResource(R.string.auth_required),
301+ style = MaterialTheme.typography.bodySmall,
302+ color = MaterialTheme.colorScheme.error,
303+ )
304+ OutlinedTextField(
305+ value = state.username,
306+ onValueChange = { v -> onUpdate { it.copy(username = v) } },
307+ label = { Text(stringResource(R.string.username_optional)) },
308+ singleLine = true,
309+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
310+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
311+ )
312+ OutlinedTextField(
313+ value = state.password,
314+ onValueChange = { v -> onUpdate { it.copy(password = v) } },
315+ label = { Text(stringResource(R.string.password_optional)) },
316+ singleLine = true,
317+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
318+ trailingIcon = {
319+ IconButton(onClick = onTogglePassword) {
320+ Icon(
321+ if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
322+ contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
323+ )
324+ }
325+ },
326+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
327+ keyboardActions = KeyboardActions(onDone = { onImeDone() }),
328+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
329+ )
330+ OutlinedButton(
331+ onClick = onTestCredentials,
332+ // Require at least one credential field: testCredentials() no-ops when both are
333+ // blank, so without this the button is tappable but silently does nothing.
334+ enabled = canSave && !state.testingCredentials && !state.saving &&
335+ (state.username.isNotBlank() || state.password.isNotBlank()),
336+ modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
337+ ) {
338+ if (state.testingCredentials) {
339+ CircularProgressIndicator(
340+ modifier = Modifier.size(20.dp),
341+ strokeWidth = 2.dp,
342+ )
343+ Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
344+ } else {
345+ Text(stringResource(R.string.test_credentials))
346+ }
347+ }
348+ state.credentialsResult?.let { ok ->
349+ Text(
350+ stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
351+ style = MaterialTheme.typography.bodySmall,
352+ color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
353+ )
354+ }
355+ }
356+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+286
@@ -0,0 +1,286 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.network.NetworkConfig
8+import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.di.ProbeResult
10+import kotlinx.coroutines.flow.MutableStateFlow
11+import kotlinx.coroutines.flow.StateFlow
12+import kotlinx.coroutines.flow.asStateFlow
13+import kotlinx.coroutines.flow.first
14+import kotlinx.coroutines.flow.update
15+import kotlinx.coroutines.launch
16+import kotlinx.coroutines.withTimeoutOrNull
17+import soy.iko.opencode.R
18+import soy.iko.opencode.util.runCatchingCancellable
19+import java.util.UUID
20+
21+@Immutable
22+data class ServerEditState(
23+ val id: String? = null,
24+ val label: String = "",
25+ val baseUrl: String = "",
26+ val username: String = "",
27+ val password: String = "",
28+ val loaded: Boolean = false,
29+ val saving: Boolean = false,
30+ val error: String? = null,
31+ /** Whether the auth (username/password) fields are shown. */
32+ val authFieldsVisible: Boolean = false,
33+ /** Whether a connectivity/auth probe is in progress. */
34+ val probing: Boolean = false,
35+ /** Snapshot of the profile as loaded (for dirty detection). Null until loaded. */
36+ val initial: InitialProfile? = null,
37+ /** Whether a credential test is in progress. */
38+ val testingCredentials: Boolean = false,
39+ /** Non-null when the last credential test succeeded (true) or failed (false). */
40+ val credentialsResult: Boolean? = null,
41+ /** True when the last connectivity probe found the server reachable without auth.
42+ * Null until a probe runs, and cleared when the base URL is edited. */
43+ val probeReachable: Boolean? = null,
44+) {
45+ val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
46+ val isNew: Boolean get() = id == null
47+ /** True if any field differs from its loaded value. */
48+ val isDirty: Boolean
49+ get() {
50+ val init = initial ?: return false
51+ return label.trim() != init.label ||
52+ baseUrl.trim() != init.baseUrl ||
53+ username.trim() != init.username ||
54+ password.trim() != init.password
55+ }
56+}
57+
58+/** Snapshot of the profile loaded into the editor, normalized the same way save() stores it. */
59+data class InitialProfile(
60+ val label: String,
61+ val baseUrl: String,
62+ val username: String,
63+ val password: String,
64+)
65+
66+fun isValidUrl(url: String): Boolean = try {
67+ val u = java.net.URI(url.trim())
68+ u.scheme != null && u.host != null && (u.scheme == "http" || u.scheme == "https")
69+} catch (_: Exception) {
70+ false
71+}
72+
73+/**
74+ * If [input] looks like a bare host (optionally with a port/path) and lacks a scheme,
75+ * return the suggested `http://`-prefixed form (only when it parses to a valid URL).
76+ * Lets the UI offer a one-tap fix instead of a generic "invalid URL" error.
77+ */
78+fun suggestUrlScheme(input: String): String? {
79+ val trimmed = input.trim()
80+ if (trimmed.isEmpty() || "://" in trimmed) return null
81+ val candidate = "http://$trimmed"
82+ return if (isValidUrl(candidate)) candidate else null
83+}
84+
85+class ServerEditViewModel(
86+ private val container: AppContainer,
87+ private val profileId: String?,
88+ private val sourceId: String? = null,
89+) : ViewModel() {
90+
91+ private val _state = MutableStateFlow(ServerEditState(id = profileId))
92+ val state: StateFlow<ServerEditState> = _state.asStateFlow()
93+
94+ init {
95+ viewModelScope.launch {
96+ if (profileId != null) {
97+ val existing = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
98+ container.profileStore.profiles
99+ .first { list -> list.any { p -> p.id == profileId } }
100+ .firstOrNull { it.id == profileId }
101+ }
102+ if (existing != null) {
103+ val init = InitialProfile(
104+ label = existing.label,
105+ baseUrl = existing.baseUrl,
106+ username = existing.username.orEmpty(),
107+ password = existing.password.orEmpty(),
108+ )
109+ _state.value = ServerEditState(
110+ id = existing.id,
111+ label = existing.label,
112+ baseUrl = existing.baseUrl,
113+ username = existing.username.orEmpty(),
114+ password = existing.password.orEmpty(),
115+ loaded = true,
116+ authFieldsVisible = existing.hasAuth,
117+ initial = init,
118+ )
119+ return@launch
120+ }
121+ _state.value = ServerEditState(
122+ id = profileId,
123+ loaded = true,
124+ error = container.string(R.string.error_load_timeout),
125+ )
126+ return@launch
127+ }
128+ // Duplicate: seed the form from an existing profile but as a NEW profile
129+ // (id stays null so save() generates a fresh one). Appends " (copy)" to the
130+ // label so the user can tell the duplicate from the original at a glance.
131+ if (sourceId != null) {
132+ val source = withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
133+ container.profileStore.profiles
134+ .first { list -> list.any { p -> p.id == sourceId } }
135+ .firstOrNull { it.id == sourceId }
136+ }
137+ if (source != null) {
138+ val dupLabel = if (source.label.isBlank()) source.baseUrl + " (copy)" else "${source.label} (copy)"
139+ _state.value = ServerEditState(
140+ id = null,
141+ label = dupLabel,
142+ baseUrl = source.baseUrl,
143+ username = source.username.orEmpty(),
144+ password = source.password.orEmpty(),
145+ loaded = true,
146+ authFieldsVisible = source.hasAuth,
147+ // Snapshot the seeded (copy) values so an untouched duplicate isn't
148+ // immediately dirty — an empty initial would make backing out of a
149+ // freshly opened duplicate always trigger the discard-changes dialog.
150+ initial = InitialProfile(
151+ label = dupLabel.trim(),
152+ baseUrl = source.baseUrl.trim(),
153+ username = source.username.orEmpty().trim(),
154+ password = source.password.orEmpty().trim(),
155+ ),
156+ )
157+ return@launch
158+ }
159+ // Source not found — fall through to a blank new-profile form.
160+ }
161+ // New-profile form: seed an initial snapshot of empty values so isDirty
162+ // becomes true the moment the user types anything.
163+ _state.update {
164+ it.copy(
165+ loaded = true,
166+ initial = InitialProfile("", "", "", ""),
167+ )
168+ }
169+ }
170+ }
171+
172+ fun update(transform: (ServerEditState) -> ServerEditState) {
173+ // Clear both the inline credential note and the bottom-of-form error when any field
174+ // changes: once the user starts editing (e.g. correcting a rejected password), a stale
175+ // "Credentials rejected" / test-failure message no longer describes the current input.
176+ _state.update { transform(it).copy(credentialsResult = null, error = null) }
177+ }
178+
179+ fun probe() {
180+ val s = _state.value
181+ if (!s.canSave || s.probing) return
182+ _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
183+ viewModelScope.launch {
184+ val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
185+ result.onSuccess { pr ->
186+ _state.update {
187+ when (pr) {
188+ is ProbeResult.Reachable -> it.copy(
189+ probing = false,
190+ authFieldsVisible = false,
191+ error = null,
192+ probeReachable = true,
193+ )
194+ is ProbeResult.NeedsAuth -> it.copy(
195+ probing = false,
196+ authFieldsVisible = true,
197+ error = null,
198+ probeReachable = false,
199+ )
200+ is ProbeResult.Unreachable -> it.copy(
201+ probing = false,
202+ authFieldsVisible = false,
203+ error = pr.error,
204+ probeReachable = false,
205+ )
206+ }
207+ }
208+ }.onFailure { e ->
209+ _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
210+ }
211+ }
212+ }
213+
214+ /**
215+ * Probe the server *with* the entered credentials to validate them before saving.
216+ * Only meaningful once auth fields are visible (the server requires auth); calling
217+ * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
218+ * so the UI can show success/failure without dismissing the form.
219+ */
220+ fun testCredentials() {
221+ val s = _state.value
222+ if (!s.canSave || s.testingCredentials) return
223+ if (s.username.isBlank() && s.password.isBlank()) return
224+ _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
225+ viewModelScope.launch {
226+ val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
227+ result.onSuccess { ok ->
228+ _state.update {
229+ it.copy(
230+ testingCredentials = false,
231+ credentialsResult = ok,
232+ error = if (ok) null else container.string(R.string.credentials_rejected),
233+ )
234+ }
235+ }.onFailure { e ->
236+ _state.update {
237+ it.copy(
238+ testingCredentials = false,
239+ credentialsResult = false,
240+ error = container.friendlyError(e),
241+ )
242+ }
243+ }
244+ }
245+ }
246+
247+ fun save(onDone: () -> Unit) {
248+ saveInternal(connectAfter = false, onDone = onDone)
249+ }
250+
251+ /** Save and then connect to the saved profile in one step. */
252+ fun saveAndConnect(onDone: () -> Unit) {
253+ saveInternal(connectAfter = true, onDone = onDone)
254+ }
255+
256+ private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
257+ val s = _state.value
258+ if (!s.canSave || s.saving) return
259+ _state.update { it.copy(saving = true) }
260+ viewModelScope.launch {
261+ val result = runCatchingCancellable {
262+ val existingLastUsed = if (s.id != null) {
263+ withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
264+ container.profileStore.profiles.first()
265+ .firstOrNull { it.id == s.id }?.lastUsed
266+ } ?: 0L
267+ } else 0L
268+ val saved = ServerProfile(
269+ id = s.id ?: UUID.randomUUID().toString(),
270+ label = s.label.trim(),
271+ baseUrl = s.baseUrl.trim(),
272+ username = s.username.trim().takeIf { it.isNotBlank() },
273+ password = s.password.trim().takeIf { it.isNotEmpty() },
274+ lastUsed = existingLastUsed,
275+ )
276+ container.profileStore.save(saved)
277+ if (connectAfter) {
278+ container.connect(saved)
279+ container.activeConnection.value?.api?.ping()
280+ }
281+ }
282+ result.onSuccess { onDone() }
283+ .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
284+ }
285+ }
286+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+455
@@ -0,0 +1,455 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.fillMaxWidth
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.lazy.LazyColumn
15+import androidx.compose.foundation.lazy.items
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.CheckCircle
19+import androidx.compose.material.icons.filled.Delete
20+import androidx.compose.material.icons.filled.Dns
21+import androidx.compose.material.icons.filled.MoreVert
22+import androidx.compose.material3.AlertDialog
23+import androidx.compose.material3.Card
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
27+import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.FloatingActionButton
29+import androidx.compose.material3.Icon
30+import androidx.compose.material3.IconButton
31+import androidx.compose.material3.LinearProgressIndicator
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarDuration
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.SnackbarResult
38+import androidx.compose.material3.SwipeToDismissBox
39+import androidx.compose.material3.SwipeToDismissBoxValue
40+import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
42+import androidx.compose.material3.TopAppBar
43+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
44+import androidx.compose.material3.rememberSwipeToDismissBoxState
45+import androidx.compose.runtime.Composable
46+import androidx.compose.runtime.LaunchedEffect
47+import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableStateOf
49+import androidx.compose.runtime.remember
50+import androidx.compose.runtime.saveable.rememberSaveable
51+import androidx.compose.runtime.setValue
52+import androidx.compose.ui.Alignment
53+import androidx.compose.ui.Modifier
54+import androidx.compose.ui.draw.clip
55+import androidx.compose.ui.platform.LocalHapticFeedback
56+import androidx.compose.ui.platform.testTag
57+import androidx.compose.ui.semantics.Role
58+import androidx.compose.ui.semantics.contentDescription
59+import androidx.compose.ui.semantics.semantics
60+import androidx.compose.ui.text.style.TextOverflow
61+import androidx.compose.ui.unit.dp
62+import androidx.compose.ui.res.stringResource
63+import androidx.lifecycle.compose.collectAsStateWithLifecycle
64+import androidx.lifecycle.viewmodel.compose.viewModel
65+import kotlinx.coroutines.coroutineScope
66+import kotlinx.coroutines.flow.collectLatest
67+import kotlinx.coroutines.delay
68+import kotlinx.coroutines.launch
69+import soy.iko.opencode.data.model.ServerProfile
70+import soy.iko.opencode.data.network.NetworkConfig
71+import soy.iko.opencode.di.AppContainer
72+import soy.iko.opencode.R
73+import soy.iko.opencode.ui.components.rememberRelativeTime
74+import soy.iko.opencode.ui.vmFactory
75+
76+@OptIn(ExperimentalMaterial3Api::class)
77+@Composable
78+fun ServerListScreen(
79+ container: AppContainer,
80+ onConnected: () -> Unit,
81+ onAddProfile: () -> Unit,
82+ onEditProfile: (String) -> Unit,
83+ onDuplicateProfile: (String) -> Unit,
84+) {
85+ val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
86+ val profiles by vm.profiles.collectAsStateWithLifecycle()
87+ val connectingId by vm.connectingId.collectAsStateWithLifecycle()
88+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
89+ val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
90+ val haptics = LocalHapticFeedback.current
91+ val snackbar = remember { SnackbarHostState() }
92+ val retryLabel = stringResource(R.string.retry)
93+ val undoLabel = stringResource(R.string.undo)
94+ val serverRemovedLabel = stringResource(R.string.server_removed)
95+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
96+ val connectedId = activeConnection?.profile?.id
97+
98+ LaunchedEffect(Unit) {
99+ vm.errorEvents.collect { event ->
100+ val result = if (event.profile != null) {
101+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
102+ } else {
103+ snackbar.showSnackbar(event.message)
104+ }
105+ if (result == SnackbarResult.ActionPerformed && event.profile != null) {
106+ vm.connect(event.profile, onConnected)
107+ }
108+ }
109+ }
110+
111+ // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
112+ // action is taken before the delay expires, the profile is kept and the delete
113+ // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
114+ // matching timed dismiss keeps the Undo button visible for exactly the
115+ // undoServerDeleteDelayMs window (5s) and no longer: a fixed SnackbarDuration.Long
116+ // (~10s) outlasted the window, leaving the button on screen but dead for its
117+ // second half. Mirrors DiagnosticsScreen's undo pattern.
118+ LaunchedEffect(Unit) {
119+ // collectLatest (not collect): a serialized collect queues each snackbar behind
120+ // the previous one's full window, so under rapid deletes a later profile's VM
121+ // delete timer (started at emit time) fires before its snackbar is ever shown —
122+ // a dead Undo button. collectLatest instead cancels the current snackbar and
123+ // shows the newest immediately, keeping its Undo window aligned with its timer.
124+ vm.undoEvents.collectLatest { profileId ->
125+ coroutineScope {
126+ val dismisser = launch {
127+ delay(NetworkConfig.undoServerDeleteDelayMs)
128+ snackbar.currentSnackbarData?.dismiss()
129+ }
130+ val result = snackbar.showSnackbar(
131+ message = serverRemovedLabel,
132+ actionLabel = undoLabel,
133+ duration = SnackbarDuration.Indefinite,
134+ )
135+ dismisser.cancel()
136+ if (result == SnackbarResult.ActionPerformed) {
137+ vm.undoDelete(profileId)
138+ }
139+ }
140+ }
141+ }
142+
143+ // On cold start the container auto-reconnects to the last server; once it lands,
144+ // skip this screen and go straight to the session list. Consumed once so a later
145+ // manual visit to this screen doesn't bounce the user back out.
146+ LaunchedEffect(Unit) {
147+ container.autoConnectDone.collect { succeeded ->
148+ if (succeeded && container.consumeAutoConnect()) onConnected()
149+ }
150+ }
151+
152+ Scaffold(
153+ topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
154+ snackbarHost = { SnackbarHost(snackbar) },
155+ floatingActionButton = {
156+ FloatingActionButton(onClick = onAddProfile) {
157+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
158+ }
159+ },
160+ ) { padding ->
161+ Column(modifier = Modifier.fillMaxSize()) {
162+ if (reconnecting && profiles.isNotEmpty()) {
163+ LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
164+ }
165+ if (profiles.isEmpty()) {
166+ EmptyServers(
167+ onAdd = onAddProfile,
168+ modifier = Modifier.fillMaxSize().padding(padding),
169+ )
170+ } else {
171+ PullToRefreshBox(
172+ isRefreshing = connectingId != null,
173+ onRefresh = { vm.refresh(onConnected) },
174+ modifier = Modifier.fillMaxSize(),
175+ ) {
176+ LazyColumn(
177+ modifier = Modifier.fillMaxSize().padding(padding),
178+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
179+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
180+ ),
181+ verticalArrangement = Arrangement.spacedBy(12.dp),
182+ ) {
183+ items(profiles, key = { it.id }) { profile ->
184+ val isActive = profile.id == connectedId
185+ if (isActive) {
186+ // The active server can't be swipe-deleted (deleting it also
187+ // disconnects, which deserves an explicit tap, not an
188+ // accidental swipe). Render a plain card without the swipe
189+ // affordance for the active row.
190+ Card(
191+ modifier = Modifier
192+ .fillMaxWidth()
193+ .animateItem()
194+ .testTag("server_card")
195+ .clickable(enabled = connectingId == null, role = Role.Button) {
196+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
197+ vm.connect(profile, onConnected)
198+ },
199+ ) {
200+ Row(
201+ modifier = Modifier.fillMaxWidth().padding(16.dp),
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Column(modifier = Modifier.weight(1f)) {
205+ Text(
206+ profile.displayLabel,
207+ style = MaterialTheme.typography.titleMedium,
208+ maxLines = 1,
209+ overflow = TextOverflow.Ellipsis,
210+ )
211+ Text(
212+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
213+ style = MaterialTheme.typography.bodySmall,
214+ color = MaterialTheme.colorScheme.onSurfaceVariant,
215+ maxLines = 1,
216+ overflow = TextOverflow.Ellipsis,
217+ )
218+ Text(
219+ stringResource(R.string.connected),
220+ style = MaterialTheme.typography.labelSmall,
221+ color = MaterialTheme.colorScheme.primary,
222+ )
223+ LastUsedText(profile.lastUsed)
224+ }
225+ Icon(
226+ Icons.Filled.CheckCircle,
227+ contentDescription = stringResource(R.string.connected),
228+ tint = MaterialTheme.colorScheme.primary,
229+ modifier = Modifier.size(22.dp),
230+ )
231+ // Overflow menu consolidates edit/duplicate/delete so the
232+ // active row shows a single chevron instead of three icons.
233+ // Keep edit available on the active server so credentials can
234+ // be fixed without disconnecting first. Delete is also offered:
235+ // the confirmation dialog warns that removing the active server
236+ // also disconnects (remove_server_active_text), and the
237+ // ViewModel disconnects immediately on confirm.
238+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
239+ val editLabel = stringResource(R.string.edit)
240+ val duplicateLabel = stringResource(R.string.duplicate_server)
241+ val removeLabel = stringResource(R.string.remove)
242+ val moreLabel = stringResource(R.string.more)
243+ Box {
244+ IconButton(onClick = { showRowMenu = true }) {
245+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
246+ }
247+ DropdownMenu(
248+ expanded = showRowMenu,
249+ onDismissRequest = { showRowMenu = false },
250+ ) {
251+ DropdownMenuItem(
252+ text = { Text(editLabel) },
253+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
254+ )
255+ DropdownMenuItem(
256+ text = { Text(duplicateLabel) },
257+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
258+ )
259+ DropdownMenuItem(
260+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
261+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
262+ )
263+ }
264+ }
265+ }
266+ }
267+ } else {
268+ // Swipe end-to-start reveals a delete affordance and opens the
269+ // same confirmation dialog as the trash icon (matching the
270+ // session list). confirmValueChange always returns false (snap
271+ // back) so the dialog guards the actual deletion.
272+ val swipeState = rememberSwipeToDismissBoxState(
273+ confirmValueChange = {
274+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
275+ pendingDeleteId = profile.id
276+ false
277+ },
278+ )
279+ SwipeToDismissBox(
280+ state = swipeState,
281+ enableDismissFromStartToEnd = false,
282+ modifier = Modifier.animateItem(),
283+ backgroundContent = {
284+ Box(
285+ modifier = Modifier
286+ .fillMaxSize()
287+ .clip(MaterialTheme.shapes.medium)
288+ .background(MaterialTheme.colorScheme.errorContainer)
289+ .padding(horizontal = 20.dp),
290+ contentAlignment = Alignment.CenterEnd,
291+ ) {
292+ Icon(
293+ Icons.Filled.Delete,
294+ contentDescription = stringResource(R.string.delete),
295+ tint = MaterialTheme.colorScheme.onErrorContainer,
296+ )
297+ }
298+ },
299+ ) {
300+ Card(
301+ modifier = Modifier
302+ .fillMaxWidth()
303+ .testTag("server_card")
304+ .clickable(enabled = connectingId == null, role = Role.Button) {
305+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
306+ vm.connect(profile, onConnected)
307+ },
308+ ) {
309+ Row(
310+ modifier = Modifier.fillMaxWidth().padding(16.dp),
311+ verticalAlignment = Alignment.CenterVertically,
312+ ) {
313+ Column(modifier = Modifier.weight(1f)) {
314+ Text(
315+ profile.displayLabel,
316+ style = MaterialTheme.typography.titleMedium,
317+ maxLines = 1,
318+ overflow = TextOverflow.Ellipsis,
319+ )
320+ Text(
321+ profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
322+ style = MaterialTheme.typography.bodySmall,
323+ color = MaterialTheme.colorScheme.onSurfaceVariant,
324+ maxLines = 1,
325+ overflow = TextOverflow.Ellipsis,
326+ )
327+ LastUsedText(profile.lastUsed)
328+ }
329+ if (connectingId == profile.id) {
330+ val connectingLabel = stringResource(R.string.connecting)
331+ CircularProgressIndicator(
332+ modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
333+ )
334+ } else {
335+ // Overflow menu replaces the three inline edit/duplicate/
336+ // delete icons so a long server list looks less busy.
337+ // Swipe-to-delete remains the gesture path; the overflow
338+ // is the discovery path (matching the session cards).
339+ var showRowMenu by rememberSaveable(profile.id) { mutableStateOf(false) }
340+ val editLabel = stringResource(R.string.edit)
341+ val duplicateLabel = stringResource(R.string.duplicate_server)
342+ val removeLabel = stringResource(R.string.remove)
343+ val moreLabel = stringResource(R.string.more)
344+ Box {
345+ IconButton(onClick = { showRowMenu = true }) {
346+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
347+ }
348+ DropdownMenu(
349+ expanded = showRowMenu,
350+ onDismissRequest = { showRowMenu = false },
351+ ) {
352+ DropdownMenuItem(
353+ text = { Text(editLabel) },
354+ onClick = { showRowMenu = false; onEditProfile(profile.id) },
355+ )
356+ DropdownMenuItem(
357+ text = { Text(duplicateLabel) },
358+ onClick = { showRowMenu = false; onDuplicateProfile(profile.id) },
359+ )
360+ DropdownMenuItem(
361+ text = { Text(removeLabel, color = MaterialTheme.colorScheme.error) },
362+ onClick = { showRowMenu = false; pendingDeleteId = profile.id },
363+ )
364+ }
365+ }
366+ }
367+ }
368+ }
369+ }
370+ }
371+ }
372+ }
373+ }
374+ }
375+ }
376+ }
377+
378+ profiles.find { it.id == pendingDeleteId }?.let { profile ->
379+ val isActiveProfile = profile.id == connectedId
380+ AlertDialog(
381+ onDismissRequest = { pendingDeleteId = null },
382+ title = { Text(stringResource(R.string.remove_server_title)) },
383+ text = {
384+ Text(
385+ if (isActiveProfile) stringResource(R.string.remove_server_active_text, profile.displayLabel)
386+ else stringResource(R.string.remove_server_text, profile.displayLabel),
387+ )
388+ },
389+ confirmButton = {
390+ TextButton(onClick = {
391+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
392+ pendingDeleteId = null
393+ // delete() defers the actual removal so the undo snackbar can cancel
394+ // it; the row disappears on the next profiles emission.
395+ vm.delete(profile)
396+ }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
397+ },
398+ dismissButton = {
399+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
400+ },
401+ )
402+ }
403+}
404+
405+/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
406+ * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
407+@Composable
408+private fun LastUsedText(lastUsed: Long) {
409+ if (lastUsed <= 0) {
410+ Text(
411+ stringResource(R.string.last_used_never),
412+ style = MaterialTheme.typography.labelSmall,
413+ color = MaterialTheme.colorScheme.onSurfaceVariant,
414+ )
415+ } else {
416+ val relative = rememberRelativeTime(lastUsed)
417+ if (relative.isNotEmpty()) {
418+ Text(
419+ stringResource(R.string.last_used, relative),
420+ style = MaterialTheme.typography.labelSmall,
421+ color = MaterialTheme.colorScheme.onSurfaceVariant,
422+ )
423+ }
424+ }
425+}
426+
427+@Composable
428+private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
429+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
430+ Column(
431+ modifier = Modifier.padding(32.dp),
432+ horizontalAlignment = Alignment.CenterHorizontally,
433+ ) {
434+ Icon(
435+ Icons.Filled.Dns,
436+ contentDescription = null,
437+ modifier = Modifier.size(56.dp),
438+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
439+ )
440+ Spacer(Modifier.size(16.dp))
441+ Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
442+ Spacer(Modifier.size(8.dp))
443+ Text(
444+ stringResource(R.string.no_servers_hint),
445+ style = MaterialTheme.typography.bodyMedium,
446+ color = MaterialTheme.colorScheme.onSurfaceVariant,
447+ )
448+ Spacer(Modifier.size(20.dp))
449+ TextButton(onClick = onAdd) {
450+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
451+ Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
452+ }
453+ }
454+ }
455+}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+133
@@ -0,0 +1,133 @@
1+package soy.iko.opencode.ui.server
2+
3+import androidx.lifecycle.ViewModel
4+import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
6+import soy.iko.opencode.data.network.NetworkConfig
7+import soy.iko.opencode.di.AppContainer
8+import soy.iko.opencode.R
9+import soy.iko.opencode.util.runCatchingCancellable
10+import kotlinx.coroutines.channels.BufferOverflow
11+import kotlinx.coroutines.flow.MutableSharedFlow
12+import kotlinx.coroutines.flow.MutableStateFlow
13+import kotlinx.coroutines.flow.SharedFlow
14+import kotlinx.coroutines.flow.SharingStarted
15+import kotlinx.coroutines.flow.StateFlow
16+import kotlinx.coroutines.flow.asSharedFlow
17+import kotlinx.coroutines.flow.asStateFlow
18+import kotlinx.coroutines.flow.combine
19+import kotlinx.coroutines.flow.stateIn
20+import kotlinx.coroutines.flow.update
21+import kotlinx.coroutines.launch
22+
23+/** A transient error surfaced as a snackbar, optionally paired with the profile that
24+ * failed to connect so the snackbar can offer a Retry action. */
25+data class ConnectError(val message: String, val profile: ServerProfile?)
26+
27+class ServerListViewModel(private val container: AppContainer) : ViewModel() {
28+
29+ /** Ids optimistically hidden while their deferred delete's undo window is open, so a
30+ * "deleted" row disappears immediately instead of lingering (tappable) for the whole
31+ * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
32+ private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
33+
34+ val profiles: StateFlow<List<ServerProfile>> =
35+ combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
36+ profiles.filterNot { it.id in hidden }
37+ }.stateIn(
38+ scope = viewModelScope,
39+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
40+ initialValue = emptyList(),
41+ )
42+
43+ private val _connecting = MutableStateFlow<String?>(null)
44+ val connectingId: StateFlow<String?> = _connecting.asStateFlow()
45+
46+ /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
47+ * emission is delivered independently. */
48+ private val _errorEvents = MutableSharedFlow<ConnectError>(
49+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
50+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
51+ )
52+ val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
53+
54+ /** One-shot events carrying the id of a server profile marked for deferred deletion,
55+ * so the UI can show an Undo snackbar. Mirrors the session list's undo pattern. */
56+ private val _undoEvents = MutableSharedFlow<String>(
57+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
58+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
59+ )
60+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
61+
62+ fun connect(profile: ServerProfile, onConnected: () -> Unit) {
63+ if (_connecting.value != null) return
64+ _connecting.value = profile.id
65+ viewModelScope.launch {
66+ try {
67+ val result = runCatchingCancellable {
68+ val conn = container.connect(profile)
69+ conn.api.ping()
70+ }
71+ result.onSuccess { onConnected() }
72+ .onFailure {
73+ container.disconnect()
74+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
75+ }
76+ } finally {
77+ _connecting.value = null
78+ }
79+ }
80+ }
81+
82+ /** Retry connecting to the most recently used profile — the pull-to-refresh action on
83+ * the server list. Gives the user a familiar gesture to retry a failed auto-connect
84+ * instead of having to tap a server card. */
85+ fun refresh(onConnected: () -> Unit) {
86+ if (_connecting.value != null || container.reconnecting.value) return
87+ val target = profiles.value.firstOrNull() ?: return
88+ connect(target, onConnected)
89+ }
90+
91+ /**
92+ * Mark [profile] for deferred deletion and emit an Undo event. The actual
93+ * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
94+ * [undoDelete] is called before it fires, the profile is restored. If the profile
95+ * is the active one, disconnect immediately (the user has already confirmed via the
96+ * dialog) so the UI reflects the disconnection right away — the profile row is
97+ * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
98+ */
99+ fun delete(profile: ServerProfile) {
100+ // Optimistically hide the row so the UI feels instant; undo re-shows it.
101+ _hiddenIds.update { it + profile.id }
102+ if (container.activeConnection.value?.profile?.id == profile.id) {
103+ // disconnect() is suspend; run it on the VM scope so the optimistic removal
104+ // and undo emission aren't blocked on the connection close completing.
105+ viewModelScope.launch { container.disconnect() }
106+ }
107+ _undoEvents.tryEmit(profile.id)
108+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
109+ // if the user navigates away from the server list within the undo window, a
110+ // viewModelScope job would be cancelled and the profile would never be deleted
111+ // (reappearing on return). The container owns the timer and the undo cancellation.
112+ container.scheduleProfileDelete(
113+ id = profile.id,
114+ delayMs = NetworkConfig.undoServerDeleteDelayMs,
115+ onDeleted = {
116+ // Drop the optimistic hide only after the store has emitted the
117+ // removal, so the row doesn't flash back before the list updates.
118+ _hiddenIds.update { it - profile.id }
119+ },
120+ onError = {
121+ // Delete failed: re-show the row so it isn't hidden forever.
122+ _hiddenIds.update { it - profile.id }
123+ _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
124+ },
125+ )
126+ }
127+
128+ /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
129+ fun undoDelete(profileId: String) {
130+ container.cancelProfileDelete(profileId)
131+ _hiddenIds.update { it - profileId }
132+ }
133+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+751
@@ -0,0 +1,751 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.background
4+import androidx.activity.compose.BackHandler
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.Box
8+import androidx.compose.foundation.layout.Column
9+import androidx.compose.foundation.layout.PaddingValues
10+import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.fillMaxSize
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.lazy.LazyColumn
17+import androidx.compose.foundation.lazy.items
18+import androidx.compose.foundation.text.KeyboardActions
19+import androidx.compose.foundation.text.KeyboardOptions
20+import androidx.compose.material.icons.Icons
21+import androidx.compose.material.icons.automirrored.filled.Logout
22+import androidx.compose.material.icons.automirrored.filled.Sort
23+import androidx.compose.material.icons.filled.Add
24+import androidx.compose.material.icons.filled.ArrowDropDown
25+import androidx.compose.material.icons.filled.ChatBubbleOutline
26+import androidx.compose.material.icons.filled.CheckCircle
27+import androidx.compose.material.icons.filled.Close
28+import androidx.compose.material.icons.filled.Delete
29+import androidx.compose.material.icons.filled.Folder
30+import androidx.compose.material.icons.filled.MoreVert
31+import androidx.compose.material.icons.filled.Refresh
32+import androidx.compose.material.icons.filled.Search
33+import androidx.compose.material.icons.filled.Settings
34+import androidx.compose.material3.AlertDialog
35+import androidx.compose.material3.Button
36+import androidx.compose.material3.Card
37+import androidx.compose.material3.CircularProgressIndicator
38+import androidx.compose.material3.DropdownMenu
39+import androidx.compose.material3.DropdownMenuItem
40+import androidx.compose.material3.ExperimentalMaterial3Api
41+import androidx.compose.material3.ExtendedFloatingActionButton
42+import androidx.compose.material3.Icon
43+import androidx.compose.material3.IconButton
44+import androidx.compose.material3.MaterialTheme
45+import androidx.compose.material3.OutlinedTextField
46+import androidx.compose.material3.Scaffold
47+import androidx.compose.material3.SnackbarHost
48+import androidx.compose.material3.SnackbarHostState
49+import androidx.compose.material3.SwipeToDismissBox
50+import androidx.compose.material3.SwipeToDismissBoxValue
51+import androidx.compose.material3.Text
52+import androidx.compose.material3.TextButton
53+import androidx.compose.material3.TopAppBar
54+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
55+import androidx.compose.material3.rememberSwipeToDismissBoxState
56+import androidx.compose.runtime.Composable
57+import androidx.compose.runtime.CompositionLocalProvider
58+import androidx.compose.runtime.LaunchedEffect
59+import androidx.compose.runtime.getValue
60+import androidx.compose.runtime.mutableStateOf
61+import androidx.compose.runtime.remember
62+import androidx.compose.runtime.saveable.rememberSaveable
63+import androidx.compose.runtime.setValue
64+import androidx.compose.ui.Alignment
65+import androidx.compose.ui.Modifier
66+import androidx.compose.ui.draw.clip
67+import androidx.compose.ui.platform.LocalHapticFeedback
68+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
69+import androidx.compose.ui.platform.testTag
70+import androidx.compose.ui.semantics.Role
71+import androidx.compose.ui.semantics.contentDescription
72+import androidx.compose.ui.semantics.semantics
73+import androidx.compose.ui.text.input.ImeAction
74+import androidx.compose.ui.text.style.TextOverflow
75+import androidx.compose.ui.unit.dp
76+import androidx.compose.ui.res.stringResource
77+import androidx.lifecycle.compose.collectAsStateWithLifecycle
78+import androidx.lifecycle.viewmodel.compose.viewModel
79+import kotlinx.coroutines.coroutineScope
80+import kotlinx.coroutines.flow.collectLatest
81+import kotlinx.coroutines.delay
82+import kotlinx.coroutines.launch
83+import soy.iko.opencode.data.model.ServerProfile
84+import soy.iko.opencode.data.model.Session
85+import soy.iko.opencode.data.network.NetworkConfig
86+import soy.iko.opencode.di.AppContainer
87+import soy.iko.opencode.R
88+import soy.iko.opencode.ui.components.ConnectionBanner
89+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
90+import soy.iko.opencode.ui.components.RelativeTimeText
91+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
92+import soy.iko.opencode.ui.vmFactory
93+
94+@OptIn(ExperimentalMaterial3Api::class)
95+@Composable
96+fun SessionListScreen(
97+ container: AppContainer,
98+ onOpenSession: (String) -> Unit,
99+ onDisconnect: () -> Unit,
100+ onOpenFiles: () -> Unit,
101+ onOpenSettings: () -> Unit,
102+ onAddServer: () -> Unit,
103+ selectedSessionId: String? = null,
104+) {
105+ val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
106+ val state by vm.state.collectAsStateWithLifecycle()
107+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
108+ val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
109+ val profiles by vm.profiles.collectAsStateWithLifecycle()
110+ val switchingId by vm.switchingId.collectAsStateWithLifecycle()
111+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
112+ val unread by vm.unread.collectAsStateWithLifecycle()
113+ val creating by vm.creating.collectAsStateWithLifecycle()
114+ val anyRunActive by container.anyRunActive.collectAsStateWithLifecycle()
115+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
116+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
117+ val connectedId = activeConnection?.profile?.id
118+ val haptics = LocalHapticFeedback.current
119+ val snackbar = remember { SnackbarHostState() }
120+ val undoLabel = stringResource(R.string.undo)
121+ val sessionDeletedLabel = stringResource(R.string.session_deleted)
122+ // One shared timer drives every relative-time label in the session list instead of
123+ // each card spinning up its own coroutine + lifecycle observer while scrolling.
124+ val timeTick = rememberRelativeTimeTick()
125+ var showServerMenu by rememberSaveable { mutableStateOf(false) }
126+ var showSortMenu by rememberSaveable { mutableStateOf(false) }
127+ var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
128+ var pendingRenameId by rememberSaveable { mutableStateOf<String?>(null) }
129+ var showDisconnectConfirm by rememberSaveable { mutableStateOf(false) }
130+ val pendingDelete = pendingDeleteId?.let { id -> state.sessions.firstOrNull { it.id == id } }
131+ val pendingRename = pendingRenameId?.let { id -> state.sessions.firstOrNull { it.id == id } }
132+
133+ // Close the open dropdown on back press instead of navigating away.
134+ BackHandler(enabled = showServerMenu) { showServerMenu = false }
135+ BackHandler(enabled = showSortMenu) { showSortMenu = false }
136+
137+ LaunchedEffect(Unit) {
138+ vm.transientErrors.collect { msg ->
139+ snackbar.showSnackbar(msg)
140+ }
141+ }
142+
143+ // Undo snackbar: when a session is marked for deferred deletion, offer Undo. If the
144+ // action is taken before the delay expires, the session is restored and the REST
145+ // delete never fires. Indefinite + a matching timed dismiss keeps the Undo button
146+ // visible for exactly the undoDeleteDelayMs window (5s) and no longer: a fixed
147+ // SnackbarDuration.Long (~10s) outlasted the window, leaving the button on screen
148+ // but dead for its second half. Mirrors DiagnosticsScreen's undo pattern.
149+ LaunchedEffect(Unit) {
150+ // collectLatest (not collect): a serialized collect queues each snackbar behind
151+ // the previous one's full window, so under rapid deletes a later session's VM
152+ // delete timer (started at emit time) fires before its snackbar is ever shown —
153+ // a dead Undo button. collectLatest instead cancels the current snackbar and
154+ // shows the newest immediately, keeping its Undo window aligned with its timer.
155+ vm.undoEvents.collectLatest { sessionId ->
156+ coroutineScope {
157+ val dismisser = launch {
158+ delay(NetworkConfig.undoDeleteDelayMs)
159+ snackbar.currentSnackbarData?.dismiss()
160+ }
161+ val result = snackbar.showSnackbar(
162+ message = sessionDeletedLabel,
163+ actionLabel = undoLabel,
164+ duration = androidx.compose.material3.SnackbarDuration.Indefinite,
165+ )
166+ dismisser.cancel()
167+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) {
168+ vm.undoDelete(sessionId)
169+ }
170+ }
171+ }
172+ }
173+
174+ Scaffold(
175+ topBar = {
176+ TopAppBar(
177+ title = {
178+ ServerSwitcherMenu(
179+ serverLabel = serverLabel,
180+ profiles = profiles,
181+ connectedId = connectedId,
182+ switchingId = switchingId,
183+ expanded = showServerMenu,
184+ onExpand = { showServerMenu = true },
185+ onDismiss = { showServerMenu = false },
186+ onSelect = { profile ->
187+ showServerMenu = false
188+ vm.switchServer(profile)
189+ },
190+ onAddServer = {
191+ showServerMenu = false
192+ onAddServer()
193+ },
194+ )
195+ },
196+ actions = {
197+ Box {
198+ IconButton(onClick = { showSortMenu = true }) {
199+ Icon(Icons.AutoMirrored.Filled.Sort, contentDescription = stringResource(R.string.sort))
200+ }
201+ DropdownMenu(expanded = showSortMenu, onDismissRequest = { showSortMenu = false }) {
202+ DropdownMenuItem(
203+ text = { Text(stringResource(R.string.sort_recent)) },
204+ onClick = { vm.setSortMode(SessionSortMode.RECENT); showSortMenu = false },
205+ )
206+ DropdownMenuItem(
207+ text = { Text(stringResource(R.string.sort_title)) },
208+ onClick = { vm.setSortMode(SessionSortMode.TITLE); showSortMenu = false },
209+ )
210+ }
211+ }
212+ IconButton(onClick = { vm.refresh() }) {
213+ Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
214+ }
215+ IconButton(onClick = onOpenFiles) {
216+ Icon(Icons.Filled.Folder, contentDescription = stringResource(R.string.files))
217+ }
218+ IconButton(onClick = onOpenSettings) {
219+ Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings))
220+ }
221+ IconButton(onClick = {
222+ // Confirm before disconnecting if an agent run is active in any
223+ // session — disconnecting kills the SSE stream and the run.
224+ if (anyRunActive) showDisconnectConfirm = true else onDisconnect()
225+ }) {
226+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = stringResource(R.string.disconnect))
227+ }
228+ },
229+ )
230+ },
231+ snackbarHost = { SnackbarHost(snackbar) },
232+ floatingActionButton = {
233+ ExtendedFloatingActionButton(
234+ onClick = { if (!creating) vm.createSession(onCreated = onOpenSession) },
235+ icon = {
236+ if (creating) {
237+ CircularProgressIndicator(
238+ Modifier.size(24.dp),
239+ strokeWidth = 2.dp,
240+ color = MaterialTheme.colorScheme.onPrimaryContainer,
241+ )
242+ } else {
243+ Icon(Icons.Filled.Add, contentDescription = null)
244+ }
245+ },
246+ text = { Text(stringResource(R.string.new_session)) },
247+ // Disable while a creation is in flight so a double-tap can't spawn two
248+ // sessions. The container guard in createSession is the real protection;
249+ // this is the visual signal.
250+ expanded = !creating,
251+ )
252+ },
253+ ) { padding ->
254+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
255+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
256+ ConnectionBanner(
257+ state = connectionState,
258+ modifier = Modifier.align(Alignment.TopCenter),
259+ isOnline = isOnline,
260+ onRetry = { vm.retryConnection() },
261+ )
262+ SessionListBody(
263+ state = state,
264+ unread = unread,
265+ refreshing = refreshing,
266+ haptics = haptics,
267+ selectedSessionId = selectedSessionId,
268+ onRefresh = vm::refresh,
269+ onQueryChange = vm::setQuery,
270+ onOpenSession = onOpenSession,
271+ onCreateSession = { vm.createSession(onCreated = onOpenSession) },
272+ onRename = { pendingRenameId = it },
273+ onDelete = { pendingDeleteId = it },
274+ )
275+ }
276+ }
277+ }
278+
279+ pendingDelete?.let { session ->
280+ AlertDialog(
281+ onDismissRequest = { pendingDeleteId = null },
282+ title = { Text(stringResource(R.string.delete_session_title)) },
283+ text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
284+ confirmButton = {
285+ TextButton(onClick = {
286+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
287+ pendingDeleteId = null
288+ vm.deleteSession(session)
289+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
290+ },
291+ dismissButton = {
292+ TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
293+ },
294+ )
295+ }
296+
297+ pendingRename?.let { session ->
298+ var title by rememberSaveable(session.id) { mutableStateOf(session.title ?: "") }
299+ RenameSessionDialog(
300+ title = title,
301+ onTitleChange = { title = it },
302+ onConfirm = {
303+ val newName = title.trim()
304+ pendingRenameId = null
305+ if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
306+ },
307+ onDismiss = { pendingRenameId = null },
308+ )
309+ }
310+
311+ if (showDisconnectConfirm) {
312+ AlertDialog(
313+ onDismissRequest = { showDisconnectConfirm = false },
314+ title = { Text(stringResource(R.string.disconnect_active_title)) },
315+ text = { Text(stringResource(R.string.disconnect_active_text)) },
316+ confirmButton = {
317+ TextButton(onClick = {
318+ showDisconnectConfirm = false
319+ onDisconnect()
320+ }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
321+ },
322+ dismissButton = {
323+ TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
324+ },
325+ )
326+ }
327+}
328+
329+@OptIn(ExperimentalMaterial3Api::class)
330+@Composable
331+private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
332+ state: SessionListState,
333+ unread: Map<String, Int>,
334+ refreshing: Boolean,
335+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
336+ selectedSessionId: String?,
337+ onRefresh: () -> Unit,
338+ onQueryChange: (String) -> Unit,
339+ onOpenSession: (String) -> Unit,
340+ onCreateSession: () -> Unit,
341+ onRename: (String) -> Unit,
342+ onDelete: (String) -> Unit,
343+) {
344+ when {
345+ state.loading -> {
346+ val loadingLabel = stringResource(R.string.loading)
347+ CircularProgressIndicator(
348+ Modifier
349+ .align(Alignment.Center)
350+ .semantics { contentDescription = loadingLabel },
351+ )
352+ }
353+ state.sessions.isEmpty() && state.error != null -> Column(
354+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
355+ horizontalAlignment = Alignment.CenterHorizontally,
356+ ) {
357+ Text(
358+ state.error ?: "",
359+ color = MaterialTheme.colorScheme.error,
360+ )
361+ Spacer(Modifier.size(12.dp))
362+ TextButton(onClick = onRefresh) {
363+ Text(stringResource(R.string.retry))
364+ }
365+ }
366+ state.sessions.isEmpty() -> EmptySessions(
367+ onCreate = onCreateSession,
368+ modifier = Modifier.align(Alignment.Center),
369+ )
370+ else -> Column(modifier = Modifier.fillMaxSize()) {
371+ val keyboardController = LocalSoftwareKeyboardController.current
372+ OutlinedTextField(
373+ value = state.query,
374+ onValueChange = onQueryChange,
375+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
376+ label = { Text(stringResource(R.string.search_sessions)) },
377+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
378+ trailingIcon = if (state.query.isNotEmpty()) {
379+ {
380+ IconButton(onClick = { onQueryChange("") }) {
381+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
382+ }
383+ }
384+ } else null,
385+ singleLine = true,
386+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
387+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
388+ )
389+ val sessions = remember(state.sessions, state.query, state.previews) {
390+ // Fast-path an empty query: filtered just returns sessions, so skip
391+ // the recompute (and the fresh list reference) when only previews
392+ // churned in the background. With a non-empty query the previews
393+ // map affects the match set, so recompute on any of the three.
394+ if (state.query.isBlank()) state.sessions else state.filtered
395+ }
396+ if (sessions.isEmpty()) {
397+ Box(
398+ modifier = Modifier.fillMaxSize(),
399+ contentAlignment = Alignment.Center,
400+ ) {
401+ Column(
402+ horizontalAlignment = Alignment.CenterHorizontally,
403+ modifier = Modifier.padding(32.dp),
404+ ) {
405+ Icon(
406+ Icons.Filled.Search,
407+ contentDescription = null,
408+ modifier = Modifier.size(48.dp),
409+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
410+ )
411+ Spacer(Modifier.size(12.dp))
412+ Text(
413+ stringResource(R.string.no_sessions_match, state.query),
414+ style = MaterialTheme.typography.bodyMedium,
415+ color = MaterialTheme.colorScheme.onSurfaceVariant,
416+ )
417+ }
418+ }
419+ } else {
420+ PullToRefreshBox(
421+ isRefreshing = refreshing,
422+ onRefresh = onRefresh,
423+ modifier = Modifier.fillMaxSize(),
424+ ) {
425+ LazyColumn(
426+ modifier = Modifier.fillMaxSize(),
427+ contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
428+ verticalArrangement = Arrangement.spacedBy(12.dp),
429+ ) {
430+ items(sessions, key = { it.id }) { session ->
431+ // Swipe end-to-start reveals a delete affordance and opens the
432+ // same confirmation dialog as the trash icon. We never commit
433+ // the dismissal (always reset to Settled) so the card snaps back
434+ // and the dialog guards against accidental data loss.
435+ val swipeState = rememberSwipeToDismissBoxState(
436+ confirmValueChange = {
437+ // Haptic at the trigger so the swipe path matches the icon-path
438+ // delete, which vibrates on confirm. Returns false (snap back) so
439+ // the dialog guards the actual deletion.
440+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
441+ onDelete(session.id)
442+ false
443+ },
444+ )
445+ SwipeToDismissBox(
446+ state = swipeState,
447+ enableDismissFromStartToEnd = false,
448+ modifier = Modifier.animateItem(),
449+ backgroundContent = {
450+ Box(
451+ modifier = Modifier
452+ .fillMaxSize()
453+ .clip(MaterialTheme.shapes.medium)
454+ .background(MaterialTheme.colorScheme.errorContainer)
455+ .padding(horizontal = 20.dp),
456+ contentAlignment = Alignment.CenterEnd,
457+ ) {
458+ Icon(
459+ Icons.Filled.Delete,
460+ contentDescription = stringResource(R.string.delete),
461+ tint = MaterialTheme.colorScheme.onErrorContainer,
462+ )
463+ }
464+ },
465+ ) {
466+ SessionCard(
467+ session = session,
468+ preview = state.previews[session.id],
469+ unreadCount = unread[session.id] ?: 0,
470+ isSelected = session.id == selectedSessionId,
471+ onClick = { onOpenSession(session.id) },
472+ onRename = { onRename(session.id) },
473+ onDelete = { onDelete(session.id) },
474+ modifier = Modifier.testTag("session_card"),
475+ )
476+ }
477+ }
478+ }
479+ }
480+ }
481+ }
482+ }
483+}
484+
485+@Composable
486+private fun RenameSessionDialog(
487+ title: String,
488+ onTitleChange: (String) -> Unit,
489+ onConfirm: () -> Unit,
490+ onDismiss: () -> Unit,
491+) {
492+ AlertDialog(
493+ onDismissRequest = onDismiss,
494+ title = { Text(stringResource(R.string.rename_session)) },
495+ text = {
496+ OutlinedTextField(
497+ value = title,
498+ onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
499+ modifier = Modifier.fillMaxWidth(),
500+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
501+ label = { Text(stringResource(R.string.rename_session)) },
502+ singleLine = true,
503+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
504+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
505+ )
506+ },
507+ confirmButton = {
508+ TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
509+ },
510+ dismissButton = {
511+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
512+ },
513+ )
514+}
515+
516+@OptIn(ExperimentalMaterial3Api::class)
517+@Composable
518+private fun SessionCard(
519+ session: Session,
520+ preview: String?,
521+ unreadCount: Int,
522+ onClick: () -> Unit,
523+ onRename: () -> Unit,
524+ onDelete: () -> Unit,
525+ modifier: Modifier = Modifier,
526+ isSelected: Boolean = false,
527+) {
528+ // In two-pane mode the selected row is highlighted (border + container tint) so
529+ // the user can tell which conversation is open in the detail pane at a glance —
530+ // without it every row looks identical and the open session is a guess.
531+ val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer
532+ else MaterialTheme.colorScheme.surface
533+ val borderColor = if (isSelected) MaterialTheme.colorScheme.primary
534+ else androidx.compose.ui.graphics.Color.Transparent
535+ Card(
536+ modifier = modifier
537+ .fillMaxWidth()
538+ .clickable { onClick() },
539+ colors = androidx.compose.material3.CardDefaults.cardColors(containerColor = containerColor),
540+ border = androidx.compose.foundation.BorderStroke(1.dp, borderColor),
541+ ) {
542+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
543+ Row(
544+ modifier = Modifier.fillMaxWidth(),
545+ verticalAlignment = Alignment.CenterVertically,
546+ ) {
547+ Column(modifier = Modifier.weight(1f)) {
548+ Row(verticalAlignment = Alignment.CenterVertically) {
549+ if (unreadCount > 0) {
550+ val unreadLabel = stringResource(R.string.unread_count, unreadCount)
551+ // Count badge: shows the number of unread messages so the user
552+ // can tell a single reply from a burst. Falls back to a dot for
553+ // a count of 1 (the common "one reply" case) to avoid clutter.
554+ if (unreadCount == 1) {
555+ Box(
556+ modifier = Modifier
557+ .padding(end = 8.dp)
558+ .size(10.dp)
559+ .clip(androidx.compose.foundation.shape.CircleShape)
560+ .background(MaterialTheme.colorScheme.primary)
561+ .semantics { contentDescription = unreadLabel },
562+ )
563+ } else {
564+ Box(
565+ modifier = Modifier
566+ .padding(end = 8.dp)
567+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
568+ .background(MaterialTheme.colorScheme.primary)
569+ .padding(horizontal = 6.dp, vertical = 1.dp)
570+ .semantics { contentDescription = unreadLabel },
571+ contentAlignment = Alignment.Center,
572+ ) {
573+ Text(
574+ unreadCount.toString(),
575+ style = MaterialTheme.typography.labelSmall,
576+ color = MaterialTheme.colorScheme.onPrimary,
577+ )
578+ }
579+ }
580+ }
581+ Text(
582+ session.displayTitle,
583+ style = MaterialTheme.typography.titleMedium,
584+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
585+ maxLines = 1,
586+ overflow = TextOverflow.Ellipsis,
587+ )
588+ }
589+ // Long-press the relative label to reveal the full absolute timestamp.
590+ RelativeTimeText(
591+ session.time?.updated ?: session.time?.created,
592+ modifier = Modifier.padding(top = 2.dp),
593+ )
594+ }
595+ // Overflow menu replaces the inline edit + delete IconButtons so the
596+ // card row is decluttered — two always-visible icons per row made a
597+ // long list look busy. Swipe-to-delete and the overflow cover the same
598+ // actions; swipe remains the gesture path, overflow the discovery path.
599+ var showRowMenu by rememberSaveable(session.id) { mutableStateOf(false) }
600+ val renameLabel = stringResource(R.string.rename)
601+ val deleteLabel = stringResource(R.string.delete)
602+ val moreLabel = stringResource(R.string.more)
603+ Box {
604+ IconButton(onClick = { showRowMenu = true }) {
605+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
606+ }
607+ DropdownMenu(
608+ expanded = showRowMenu,
609+ onDismissRequest = { showRowMenu = false },
610+ ) {
611+ DropdownMenuItem(
612+ text = { Text(renameLabel) },
613+ onClick = { showRowMenu = false; onRename() },
614+ )
615+ DropdownMenuItem(
616+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
617+ onClick = { showRowMenu = false; onDelete() },
618+ )
619+ }
620+ }
621+ }
622+ if (!preview.isNullOrBlank()) {
623+ Spacer(Modifier.size(4.dp))
624+ Text(
625+ preview,
626+ style = MaterialTheme.typography.bodyMedium,
627+ color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
628+ maxLines = 2,
629+ overflow = TextOverflow.Ellipsis,
630+ )
631+ }
632+ }
633+ }
634+}
635+
636+@Composable
637+private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
638+ Column(
639+ modifier = modifier.padding(32.dp),
640+ horizontalAlignment = Alignment.CenterHorizontally,
641+ ) {
642+ Icon(
643+ Icons.Filled.ChatBubbleOutline,
644+ contentDescription = null,
645+ modifier = Modifier.size(56.dp),
646+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
647+ )
648+ Spacer(Modifier.size(16.dp))
649+ Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
650+ Spacer(Modifier.size(8.dp))
651+ Text(
652+ stringResource(R.string.no_sessions_hint),
653+ style = MaterialTheme.typography.bodyMedium,
654+ color = MaterialTheme.colorScheme.onSurfaceVariant,
655+ )
656+ Spacer(Modifier.size(20.dp))
657+ Button(onClick = onCreate) {
658+ Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
659+ Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
660+ }
661+ }
662+}
663+
664+/** Title + dropdown for quick-switching between saved servers. Highlights the active
665+ * server with a check mark and primary color so the user can tell which one they're
666+ * on at a glance. Includes an "Add server" item so adding a new server doesn't require
667+ * navigating out to Settings → Manage servers — a long path for a common action. */
668+@Composable
669+private fun ServerSwitcherMenu(
670+ serverLabel: String,
671+ profiles: List<ServerProfile>,
672+ connectedId: String?,
673+ switchingId: String?,
674+ expanded: Boolean,
675+ onExpand: () -> Unit,
676+ onDismiss: () -> Unit,
677+ onSelect: (ServerProfile) -> Unit,
678+ onAddServer: () -> Unit,
679+) {
680+ Column {
681+ Box {
682+ Row(
683+ modifier = Modifier
684+ .clickable(role = Role.Button) { onExpand() },
685+ verticalAlignment = Alignment.CenterVertically,
686+ ) {
687+ Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
688+ Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
689+ }
690+ DropdownMenu(
691+ expanded = expanded,
692+ onDismissRequest = onDismiss,
693+ ) {
694+ profiles.forEach { profile ->
695+ val isActiveProfile = profile.id == connectedId
696+ DropdownMenuItem(
697+ text = {
698+ Row(verticalAlignment = Alignment.CenterVertically) {
699+ Column(modifier = Modifier.weight(1f)) {
700+ Text(
701+ profile.displayLabel,
702+ style = MaterialTheme.typography.bodyLarge,
703+ color = if (isActiveProfile) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
704+ maxLines = 1,
705+ overflow = TextOverflow.Ellipsis,
706+ )
707+ Text(
708+ profile.baseUrl,
709+ style = MaterialTheme.typography.bodySmall,
710+ color = MaterialTheme.colorScheme.onSurfaceVariant,
711+ maxLines = 1,
712+ overflow = TextOverflow.Ellipsis,
713+ )
714+ }
715+ if (isActiveProfile) {
716+ Icon(
717+ Icons.Filled.CheckCircle,
718+ contentDescription = stringResource(R.string.connected),
719+ tint = MaterialTheme.colorScheme.primary,
720+ modifier = Modifier.size(18.dp),
721+ )
722+ }
723+ if (switchingId == profile.id) {
724+ val switchingLabel = stringResource(R.string.loading)
725+ CircularProgressIndicator(
726+ Modifier
727+ .size(18.dp)
728+ .semantics { contentDescription = switchingLabel },
729+ strokeWidth = 2.dp,
730+ )
731+ }
732+ }
733+ },
734+ onClick = { onSelect(profile) },
735+ )
736+ }
737+ // Divider + Add server item so the user can add a server without leaving
738+ // the session list. The trailing icon reinforces the primary action.
739+ androidx.compose.material3.HorizontalDivider()
740+ DropdownMenuItem(
741+ text = { Text(stringResource(R.string.add_server)) },
742+ leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },
743+ onClick = {
744+ onDismiss()
745+ onAddServer()
746+ },
747+ )
748+ }
749+ }
750+ }
751+}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+601
@@ -0,0 +1,601 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.runtime.Immutable
4+import androidx.lifecycle.ViewModel
5+import androidx.lifecycle.viewModelScope
6+import soy.iko.opencode.data.model.ServerProfile
7+import soy.iko.opencode.data.model.Session
8+import soy.iko.opencode.data.model.SessionDeleted
9+import soy.iko.opencode.data.model.SessionUpdated
10+import soy.iko.opencode.data.model.TextPart
11+import soy.iko.opencode.data.model.ToolCompleted
12+import soy.iko.opencode.data.model.ToolError
13+import soy.iko.opencode.data.model.ToolPart
14+import soy.iko.opencode.data.model.ToolRunning
15+import soy.iko.opencode.data.network.EventStreamClient
16+import soy.iko.opencode.data.network.NetworkConfig
17+import soy.iko.opencode.di.AppContainer
18+import soy.iko.opencode.R
19+import soy.iko.opencode.util.runCatchingCancellable
20+import android.util.Log
21+import kotlinx.coroutines.CancellationException
22+import kotlinx.coroutines.ExperimentalCoroutinesApi
23+import kotlinx.coroutines.Job
24+import kotlinx.coroutines.channels.BufferOverflow
25+import kotlinx.coroutines.delay
26+import kotlinx.coroutines.flow.MutableSharedFlow
27+import kotlinx.coroutines.flow.MutableStateFlow
28+import kotlinx.coroutines.flow.SharedFlow
29+import kotlinx.coroutines.flow.SharingStarted
30+import kotlinx.coroutines.flow.StateFlow
31+import kotlinx.coroutines.flow.asSharedFlow
32+import kotlinx.coroutines.flow.asStateFlow
33+import kotlinx.coroutines.flow.collectLatest
34+import kotlinx.coroutines.flow.flatMapLatest
35+import kotlinx.coroutines.flow.flowOf
36+import kotlinx.coroutines.flow.stateIn
37+import kotlinx.coroutines.flow.update
38+import kotlinx.coroutines.joinAll
39+import kotlinx.coroutines.launch
40+import kotlinx.coroutines.sync.Semaphore
41+import kotlinx.coroutines.sync.withPermit
42+
43+/** Sort order for the session list. */
44+enum class SessionSortMode { RECENT, TITLE }
45+
46+@Immutable
47+data class SessionListState(
48+ val sessions: List<Session> = emptyList(),
49+ val query: String = "",
50+ val previews: Map<String, String> = emptyMap(),
51+ val loading: Boolean = true,
52+ val error: String? = null,
53+ val sortMode: SessionSortMode = SessionSortMode.RECENT,
54+) {
55+ /** Sessions filtered by the current search query (title or preview text). */
56+ val filtered: List<Session>
57+ get() {
58+ val q = query.trim()
59+ if (q.isEmpty()) return sessions
60+ return sessions.filter { session ->
61+ session.displayTitle.contains(q, ignoreCase = true) ||
62+ (previews[session.id]?.contains(q, ignoreCase = true) == true)
63+ }
64+ }
65+}
66+
67+/** Sort sessions by the given mode. RECENT sorts by last activity time desc; TITLE by
68+ * display title asc (case-insensitive), falling back to recency for ties. */
69+private fun Iterable<Session>.sortedByMode(mode: SessionSortMode): List<Session> = when (mode) {
70+ SessionSortMode.RECENT ->
71+ sortedByDescending { it.time?.updated ?: it.time?.created ?: 0L }
72+ SessionSortMode.TITLE ->
73+ sortedWith(compareBy<Session> { it.displayTitle.lowercase() }.thenByDescending { it.time?.updated ?: it.time?.created ?: 0L })
74+}
75+
76+class SessionListViewModel(private val container: AppContainer) : ViewModel() {
77+
78+ /** Live SSE connection state, surfaced so the list can show a reconnect banner. */
79+ @OptIn(ExperimentalCoroutinesApi::class)
80+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
81+ container.activeConnection
82+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
83+ .stateIn(
84+ scope = viewModelScope,
85+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
86+ initialValue = EventStreamClient.ConnectionState.Disconnected,
87+ )
88+
89+ /** Sessions with activity that arrived while not being viewed, mapped to their
90+ * unread event count so the list can show "N unread" instead of a bare dot. */
91+ val unread: StateFlow<Map<String, Int>> = container.unread
92+
93+ val profiles: StateFlow<List<ServerProfile>> =
94+ container.profileStore.profiles.stateIn(
95+ scope = viewModelScope,
96+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
97+ initialValue = emptyList(),
98+ )
99+
100+ private val _serverLabel = MutableStateFlow(
101+ container.activeConnection.value?.profile?.displayLabel ?: container.string(R.string.app_name)
102+ )
103+ val serverLabel: StateFlow<String> = _serverLabel.asStateFlow()
104+
105+ private val _switchingId = MutableStateFlow<String?>(null)
106+ val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
107+
108+ /** True while a session creation is in flight, so the FAB can disable itself and
109+ * prevent double-taps from firing two createSession calls. */
110+ private val _creating = MutableStateFlow(false)
111+ val creating: StateFlow<Boolean> = _creating.asStateFlow()
112+
113+ private val _state = MutableStateFlow(SessionListState())
114+ val state: StateFlow<SessionListState> = _state.asStateFlow()
115+
116+ /** Tracks an in-flight manual refresh so pull-to-refresh can show its indicator. */
117+ private val _refreshing = MutableStateFlow(false)
118+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
119+
120+ /** Transient errors (failed create/delete/rename) surfaced as a snackbar, not by hiding the list. */
121+ /** One-shot transient errors surfaced as snackbars. A SharedFlow (not StateFlow) so
122+ * each emission is delivered independently — two rapid failures both get a snackbar
123+ * instead of the second silently overwriting the first. */
124+ private val _transientErrors = MutableSharedFlow<String>(
125+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
126+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
127+ )
128+ val transientErrors: SharedFlow<String> = _transientErrors.asSharedFlow()
129+
130+ /** One-shot events carrying the id of a session marked for deferred deletion, so the
131+ * UI can show an Undo snackbar. */
132+ private val _undoEvents = MutableSharedFlow<String>(
133+ extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
134+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
135+ )
136+ val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
137+
138+ /** Session awaiting the undo window to expire before its REST delete fires. Holds the
139+ * Session so Undo can restore it. */
140+ // Keyed by session id, not a single field: the undo window lets several deletes be
141+ // in flight at once, and a single field would let a second delete overwrite the
142+ // first — dropping the first's server-side delete while it stays removed from the UI.
143+ // Concurrent because the delete timer's completion callbacks run on the container's
144+ // process-lived scope while the SSE collector mutates this on viewModelScope.
145+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Session>()
146+
147+ private var previewJob: Job? = null
148+ private var refreshJob: Job? = null
149+
150+ /** Per-session in-flight preview loads, so rapid SSE events for the same session
151+ * coalesce into a single request instead of firing one per event. Accessed from
152+ * multiple coroutines (the SSE event handler and [loadPreview]), so the
153+ * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
154+ private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
155+ private val previewLock = Any()
156+
157+ /** Accumulates SessionUpdated events between debounce flushes so a burst of SSE
158+ * updates during active streaming coalesces into one list rebuild + recomposition
159+ * instead of one per event. */
160+ private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
161+ private var sessionUpdateJob: Job? = null
162+
163+ private val activeProfileId: String?
164+ get() = container.activeConnection.value?.profile?.id
165+
166+ init { refresh(); observeSessionEvents() }
167+
168+ /**
169+ * Keep the session list live by reacting to SSE session events. New/renamed
170+ * sessions upsert and re-sort; deleted sessions drop immediately — no manual
171+ * refresh needed when activity happens server-side.
172+ */
173+ private fun observeSessionEvents() {
174+ viewModelScope.launch {
175+ container.activeConnection.collectLatest { conn ->
176+ if (conn == null) return@collectLatest
177+ _serverLabel.value = conn.profile.displayLabel
178+ // Cancel any in-flight preview loads from the previous connection so
179+ // they don't complete against the old server and write stale previews
180+ // for sessions that may not exist on the new one. collectLatest cancels
181+ // the SSE collector, but the jobs in livePreviewJobs are independent
182+ // launches on viewModelScope that survive the collector cancellation.
183+ synchronized(previewLock) {
184+ livePreviewJobs.values.forEach { it.cancel() }
185+ livePreviewJobs.clear()
186+ }
187+ sessionUpdateJob?.cancel()
188+ pendingSessionUpdates.clear()
189+ // Cancel any deferred deletes still pending from the previous connection.
190+ // Their captured connection is now closed, so they can only fail; letting
191+ // them run would fire onError and re-inject a session belonging to the old
192+ // server into this (new) server's list. Cancelling drops them cleanly — the
193+ // session simply survives on its original server (unavoidable once we've
194+ // switched away) rather than appearing as a foreign row here.
195+ pendingDeletes.keys.toList().forEach { container.cancelSessionDelete(it) }
196+ pendingDeletes.clear()
197+ // Reload the session list when a connection becomes available so the
198+ // initial load (which may have run before auto-reconnect finished and
199+ // found no connection) doesn't leave a stale "not connected" error.
200+ refresh()
201+ try {
202+ conn.events.events.collect { event ->
203+ when (event) {
204+ is SessionUpdated -> {
205+ val session = event.properties.info
206+ // Ignore updates for a session the user just deleted (still
207+ // within its undo window): the server hasn't deleted it yet
208+ // and can legitimately emit updates, but buffering them here
209+ // would resurrect the optimistically-hidden row mid-undo.
210+ if (pendingDeletes.containsKey(session.id)) return@collect
211+ pendingSessionUpdates[session.id] = session
212+ if (sessionUpdateJob?.isActive != true) {
213+ sessionUpdateJob = viewModelScope.launch {
214+ delay(NetworkConfig.sessionUpdateDebounceMs)
215+ flushPendingSessionUpdates()
216+ }
217+ }
218+ }
219+ is SessionDeleted -> {
220+ val id = event.properties.info?.id ?: event.properties.sessionID
221+ if (id != null) {
222+ synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
223+ // Drop any buffered update for this id so a pending flush
224+ // can't re-add the just-deleted session to the list.
225+ pendingSessionUpdates.remove(id)
226+ // The session is gone server-side; cancel any pending local
227+ // delete so its deferred REST call doesn't later 404 and
228+ // trigger the failure-restore path that resurrects the row.
229+ pendingDeletes.remove(id)
230+ container.draftStore.remove(id)
231+ _state.update { s ->
232+ s.copy(
233+ sessions = s.sessions.filterNot { it.id == id },
234+ previews = s.previews - id,
235+ )
236+ }
237+ }
238+ }
239+ else -> {}
240+ }
241+ }
242+ } catch (e: CancellationException) {
243+ throw e
244+ } catch (e: Exception) {
245+ Log.w("SessionListVM", "SSE event collector error", e)
246+ }
247+ }
248+ }
249+ }
250+
251+ /** Apply all buffered SessionUpdated events at once: upsert into the sessions list,
252+ * re-sort by recency, and kick off preview fetches. Called after the debounce delay. */
253+ private fun flushPendingSessionUpdates() {
254+ val batch = pendingSessionUpdates.toMap()
255+ if (batch.isEmpty()) return
256+ // Remove only the snapshotted key/value pairs rather than clear()-ing the whole
257+ // map: an update for a session that arrived between the toMap() snapshot and here
258+ // would otherwise be dropped. The key/value remove overload also leaves a newer
259+ // value for the same key in place to be flushed on the next pass.
260+ batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
261+ val changedIds = mutableSetOf<String>()
262+ _state.update { s ->
263+ // Reset per-attempt: MutableStateFlow.update may re-run this lambda on CAS
264+ // contention, and we only want the ids that changed in the winning attempt.
265+ changedIds.clear()
266+ val byId = s.sessions.associateBy { it.id }.toMutableMap()
267+ batch.forEach { (id, session) ->
268+ // Final guard: never re-add a session that's pending deletion (its undo
269+ // window is still open), even if an update slipped into this batch.
270+ if (pendingDeletes.containsKey(id)) return@forEach
271+ if (byId[id] != session) { byId[id] = session; changedIds += id }
272+ }
273+ // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
274+ // new state (and trigger recomposition) when the batch held no real changes
275+ // (e.g. a duplicate SessionUpdated for an unchanged session).
276+ if (changedIds.isEmpty()) return@update s
277+ val sorted = byId.values.sortedByMode(s.sortMode)
278+ // If the new ordering matches the existing one (e.g. only the already-newest
279+ // session updated its timestamp), reuse the same list instance so downstream
280+ // skips a recomposition for an identical reference.
281+ val sessions = if (sorted == s.sessions) s.sessions else sorted
282+ s.copy(sessions = sessions)
283+ }
284+ // Only refetch previews for sessions that actually changed — a duplicate
285+ // SessionUpdated for an unchanged session shouldn't trigger a full-history download.
286+ changedIds.forEach { loadPreview(it) }
287+ }
288+
289+ fun setQuery(query: String) {
290+ _state.update { it.copy(query = query) }
291+ }
292+
293+ /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
294+ * re-fetch the session list so the UI reflects the current server state. */
295+ fun retryConnection() {
296+ container.activeConnection.value?.events?.triggerReconnect()
297+ refresh()
298+ }
299+
300+ fun refresh() {
301+ val conn = container.activeConnection.value
302+ if (conn == null) {
303+ if (_state.value.sessions.isEmpty()) {
304+ _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
305+ } else {
306+ _state.update { it.copy(loading = false) }
307+ _transientErrors.tryEmit(container.string(R.string.not_connected))
308+ }
309+ _refreshing.value = false
310+ return
311+ }
312+ _serverLabel.value = conn.profile.displayLabel
313+ // Only show the full-screen spinner on the initial load (empty list). For a
314+ // pull-to-refresh or SSE-reconnect refresh with an existing list, the dedicated
315+ // PullToRefreshBox indicator (_refreshing) covers it — flipping `loading` here
316+ // would blank the populated list behind a centered spinner.
317+ _state.update { it.copy(loading = it.sessions.isEmpty(), error = null) }
318+ _refreshing.value = true
319+ refreshJob?.cancel()
320+ refreshJob = viewModelScope.launch {
321+ val self = coroutineContext[Job]
322+ try {
323+ runCatchingCancellable { conn.repository.listSessions() }
324+ .onSuccess { list ->
325+ // Drop sessions that were optimistically deleted but whose deferred
326+ // REST delete hasn't fired yet — the server still returns them, and
327+ // re-adding them here would resurrect a just-hidden row (and later
328+ // collide on the LazyColumn id key when undo/restore re-appends it).
329+ val sorted = list.filterNot { pendingDeletes.containsKey(it.id) }.sortedByMode(_state.value.sortMode)
330+ _state.update { it.copy(sessions = sorted, loading = false, error = null) }
331+ loadPreviews(sorted)
332+ }
333+ .onFailure {
334+ if (_state.value.sessions.isNotEmpty()) {
335+ _state.update { it.copy(loading = false) }
336+ _transientErrors.tryEmit(container.friendlyError(it))
337+ } else {
338+ _state.value = SessionListState(loading = false, error = container.friendlyError(it))
339+ }
340+ }
341+ } finally {
342+ // Only clear the spinner if we're still the active refresh job. When a
343+ // newer refresh() cancels this one, its finally runs asynchronously on
344+ // the Main dispatcher and would otherwise clobber _refreshing back to
345+ // false while the newer refresh is still in flight.
346+ if (refreshJob === self) _refreshing.value = false
347+ }
348+ }
349+ }
350+
351+ /**
352+ * Reload the preview for a single session (used by the live SSE handler).
353+ * Cancels any prior in-flight load for the same session so a burst of
354+ * [SessionUpdated] events coalesces into a single request. A short debounce
355+ * delay before the fetch avoids launching one full-history download per event
356+ * during active streaming — the fetch runs only after events quiet down.
357+ */
358+ private fun loadPreview(sessionId: String) {
359+ val conn = container.activeConnection.value ?: return
360+ val api = conn.api
361+ val toolFormat = container.string(R.string.tool_preview)
362+ synchronized(previewLock) {
363+ livePreviewJobs.remove(sessionId)?.cancel()
364+ livePreviewJobs[sessionId] = viewModelScope.launch {
365+ val job = coroutineContext[kotlinx.coroutines.Job]
366+ try {
367+ delay(NetworkConfig.previewDebounceMs)
368+ val preview = fetchSessionPreview(api, sessionId, toolFormat) ?: return@launch
369+ _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
370+ } catch (e: CancellationException) {
371+ throw e
372+ } catch (e: Exception) {
373+ Log.w("SessionListVM", "Preview load failed for $sessionId", e)
374+ } finally {
375+ if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
376+ }
377+ }
378+ }
379+ }
380+
381+ /** Fetch the last text part of each session for list previews (best-effort, bounded).
382+ * Sessions that already have a cached preview are skipped — their previews are kept
383+ * fresh by the live SSE handler ([loadPreview] on [SessionUpdated]). Each preview
384+ * fetch downloads the full conversation history, so re-fetching all of them on every
385+ * refresh is wasteful when most are already cached. */
386+ private fun loadPreviews(sessions: List<Session>) {
387+ previewJob?.cancel()
388+ val conn = container.activeConnection.value ?: return
389+ val api = conn.api
390+ val toolFormat = container.string(R.string.tool_preview)
391+ // Keep previews for sessions still in the list; drop stale ones without
392+ // clearing the whole map (which would cause a visual flash).
393+ val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
394+ _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
395+ val cached = _state.value.previews.keys
396+ val toFetch = sessions.take(NetworkConfig.maxPreviewSessions).filter { it.id !in cached }
397+ if (toFetch.isEmpty()) return
398+ previewJob = viewModelScope.launch {
399+ val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
400+ toFetch.map { session ->
401+ launch {
402+ semaphore.withPermit {
403+ val preview = fetchSessionPreview(api, session.id, toolFormat) ?: return@withPermit
404+ _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
405+ }
406+ }
407+ }.joinAll()
408+ }
409+ }
410+
411+ fun createSession(onCreated: (String) -> Unit) {
412+ val conn = container.activeConnection.value ?: return
413+ if (!_creating.compareAndSet(false, true)) return
414+ viewModelScope.launch {
415+ try {
416+ runCatchingCancellable { conn.repository.createSession() }
417+ .onSuccess { onCreated(it.id); refresh() }
418+ .onFailure {
419+ _state.update { it.copy(error = null) }
420+ _transientErrors.tryEmit(container.friendlyError(it))
421+ }
422+ } finally {
423+ _creating.value = false
424+ }
425+ }
426+ }
427+
428+ /**
429+ * Mark [session] as pending deletion and hide it from the list immediately, then
430+ * surface an Undo via [undoEvents]. The actual REST delete is deferred by
431+ * [NetworkConfig.undoDeleteDelayMs]; if [undoDelete] is called before it fires,
432+ * the session is restored. Otherwise the delete proceeds.
433+ */
434+ fun deleteSession(session: Session) {
435+ // Optimistically remove from the visible list so the UI feels instant.
436+ _state.update { s ->
437+ s.copy(sessions = s.sessions.filterNot { it.id == session.id })
438+ }
439+ // Hold the pending delete so the snackbar can undo it.
440+ pendingDeletes[session.id] = session
441+ // Drop any already-buffered update for this session so a pending flush during the
442+ // undo window can't re-add the row we just optimistically hid.
443+ pendingSessionUpdates.remove(session.id)
444+ _undoEvents.tryEmit(session.id)
445+ // Run the deferred delete on the container's process-lived scope, not viewModelScope:
446+ // if this VM is cleared during the undo window (e.g. a disconnect pops the session
447+ // list), a viewModelScope job would be cancelled and the server-side delete would
448+ // never fire — the row was already optimistically hidden, so the session would
449+ // silently resurrect on the next refresh. The container also clears the stored draft.
450+ container.scheduleSessionDelete(
451+ id = session.id,
452+ delayMs = NetworkConfig.undoDeleteDelayMs,
453+ onDeleted = {
454+ pendingDeletes.remove(session.id)
455+ refresh()
456+ },
457+ onError = {
458+ // Restore the session since the delete failed (or there was no connection to
459+ // delete on). Guard against a concurrent refresh having already re-added it so
460+ // we never produce two rows with the same id.
461+ pendingDeletes.remove(session.id)
462+ _state.update { s ->
463+ s.copy(sessions = (s.sessions.filterNot { it.id == session.id } + session).sortedByMode(s.sortMode))
464+ }
465+ _transientErrors.tryEmit(container.friendlyError(it))
466+ },
467+ )
468+ }
469+
470+ /** Cancel a pending delete (Undo snackbar action) and restore the session. */
471+ fun undoDelete(sessionId: String) {
472+ // Cancel the deferred container-scoped delete first. If it already fired (cancel
473+ // returns false), the delete is committing/committed — drop the token and don't
474+ // restore, so we never re-add a session that's gone on the server.
475+ if (!container.cancelSessionDelete(sessionId)) {
476+ pendingDeletes.remove(sessionId)
477+ return
478+ }
479+ val pending = pendingDeletes.remove(sessionId) ?: return
480+ _state.update { s ->
481+ // Dedup by id in case a refresh during the undo window already re-added it.
482+ s.copy(sessions = (s.sessions.filterNot { it.id == pending.id } + pending).sortedByMode(s.sortMode))
483+ }
484+ }
485+
486+ fun setSortMode(mode: SessionSortMode) {
487+ if (mode == _state.value.sortMode) return
488+ _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
489+ }
490+
491+ fun renameSession(session: Session, newTitle: String) {
492+ val conn = container.activeConnection.value ?: return
493+ val title = newTitle.trim()
494+ if (title.isEmpty() || title == session.title) return
495+ viewModelScope.launch {
496+ runCatchingCancellable { conn.api.updateSession(session.id, title) }
497+ .onSuccess { refresh() }
498+ .onFailure {
499+ _state.update { it.copy(error = null) }
500+ _transientErrors.tryEmit(container.friendlyError(it))
501+ }
502+ }
503+ }
504+
505+ /** Quick-switch to a different saved server without leaving the session list. */
506+ fun switchServer(profile: ServerProfile) {
507+ if (profile.id == activeProfileId) return
508+ if (_switchingId.value != null) return
509+ val previousProfile = container.activeConnection.value?.profile
510+ val savedUnread = container.unread.value
511+ _switchingId.value = profile.id
512+ _state.update { it.copy(loading = true, error = null) }
513+ viewModelScope.launch {
514+ val result = runCatchingCancellable {
515+ val conn = container.connect(profile)
516+ conn.api.ping()
517+ }
518+ _switchingId.value = null
519+ result.onSuccess { refresh() }
520+ .onFailure { error ->
521+ // connect() already closed the old connection; try to restore the
522+ // previous profile so the session list survives. If there was none,
523+ // show the error state.
524+ container.disconnect()
525+ if (previousProfile != null) {
526+ val restored = runCatchingCancellable { container.connect(previousProfile) }
527+ // connect() clears unread state; restore what we saved so the
528+ // user doesn't lose unread badges on a failed server switch.
529+ // Pass the real captured count so a session badged with "5
530+ // unread" is restored as 5, not 1.
531+ savedUnread.forEach { (id, count) -> container.restoreUnread(id, count) }
532+ if (restored.isSuccess) {
533+ // Clear stale sessions from the old server; the SSE observer
534+ // will reload from the restored connection via refresh(). Preserve
535+ // the user's sort mode and search query — the switch failed and they
536+ // were bounced back to the same server, so resetting those (via a
537+ // fresh SessionListState) would be an unexpected loss on an error path.
538+ _state.update {
539+ it.copy(
540+ sessions = emptyList(),
541+ previews = emptyMap(),
542+ loading = false,
543+ error = null,
544+ )
545+ }
546+ // Surface the switch failure as a transient snackbar — refresh()
547+ // would clear a state.error, so use transientErrors which survives it.
548+ _transientErrors.tryEmit(container.friendlyError(error))
549+ refresh()
550+ } else {
551+ // Restore also failed — the user is now disconnected from
552+ // both servers. Surface both failures so the user understands
553+ // why they're looking at an empty list.
554+ val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
555+ _state.value = SessionListState(
556+ loading = false,
557+ error = container.string(R.string.error_switch_and_restore_failed,
558+ container.friendlyError(error), restoreMsg),
559+ )
560+ }
561+ } else {
562+ _state.value = SessionListState(loading = false, error = container.friendlyError(error))
563+ }
564+ }
565+ }
566+ }
567+}
568+
569+/**
570+ * Fetch a preview of a session's most recent activity, truncated to
571+ * [NetworkConfig.previewTextMaxLength]. Returns the last text part if present; otherwise
572+ * a short summary of the last tool call (e.g. "🔧 read") so a session whose last activity
573+ * was a tool call (no assistant text yet) still shows a meaningful preview instead of
574+ * stale/empty. Returns null on failure or when there's nothing usable.
575+ *
576+ * [toolPreviewFormat] is a format string (from resources) with a %1$s placeholder for
577+ * the tool title/name, so the wrench emoji prefix is a presentation concern resolved
578+ * by the caller rather than hardcoded here.
579+ */
580+private suspend fun fetchSessionPreview(
581+ api: soy.iko.opencode.data.network.OpencodeApiClient,
582+ sessionId: String,
583+ toolPreviewFormat: String,
584+): String? = runCatchingCancellable {
585+ api.listMessages(sessionId).lastOrNull()?.let { msg ->
586+ val text = msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
587+ if (text != null) return@let text
588+ val tool = msg.parts.filterIsInstance<ToolPart>().lastOrNull()
589+ if (tool != null) {
590+ val title = when (val st = tool.state) {
591+ is ToolRunning -> st.title
592+ is ToolCompleted -> st.title
593+ is ToolError -> st.error
594+ else -> null
595+ }
596+ return@let title?.takeIf { it.isNotBlank() }?.let { toolPreviewFormat.format(it) }
597+ ?: toolPreviewFormat.format(tool.tool)
598+ }
599+ null
600+ }
601+}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktnew file+157
@@ -0,0 +1,157 @@
1+package soy.iko.opencode.ui.session
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxSize
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.widthIn
10+import androidx.activity.compose.BackHandler
11+import androidx.compose.material.icons.Icons
12+import androidx.compose.material.icons.automirrored.filled.Chat
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.Text
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.LaunchedEffect
18+import androidx.compose.runtime.getValue
19+import androidx.compose.runtime.key
20+import androidx.compose.runtime.mutableStateOf
21+import androidx.compose.runtime.remember
22+import androidx.compose.runtime.saveable.rememberSaveable
23+import androidx.compose.runtime.setValue
24+import androidx.compose.ui.Alignment
25+import androidx.compose.ui.Modifier
26+import androidx.compose.ui.res.stringResource
27+import androidx.compose.ui.unit.dp
28+import androidx.lifecycle.compose.collectAsStateWithLifecycle
29+import soy.iko.opencode.R
30+import soy.iko.opencode.data.network.NetworkConfig
31+import soy.iko.opencode.di.AppContainer
32+import soy.iko.opencode.ui.chat.ChatScreen
33+
34+/**
35+ * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
36+ * the session list on the left, the selected conversation on the right. On compact
37+ * widths the app uses the single-pane [androidx.navigation] back stack instead.
38+ *
39+ * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
40+ * two panes are self-contained. Selecting a session updates the detail pane rather than
41+ * pushing a destination; the chat [onBack] clears the selection. The chat composition is
42+ * keyed by session id so each conversation gets its own ViewModel.
43+ */
44+@Composable
45+fun TwoPaneSessionChat(
46+ container: AppContainer,
47+ onOpenFiles: () -> Unit,
48+ onOpenFile: (String) -> Unit,
49+ onOpenSettings: () -> Unit,
50+ onDisconnect: () -> Unit,
51+ onAddServer: () -> Unit,
52+ modifier: Modifier = Modifier,
53+) {
54+ var selected by rememberSaveable { mutableStateOf<String?>(null) }
55+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
56+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
57+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
58+
59+ // A notification tap / deep link requests a session: open it in the detail pane.
60+ LaunchedEffect(pendingOpenSession) {
61+ pendingOpenSession?.let {
62+ selected = it
63+ container.consumePendingOpenSession()
64+ }
65+ }
66+
67+ // Clear the selection when the active server profile changes (a server switch via
68+ // the ServerSwitcherMenu). Without this, `selected` still points at the old
69+ // server's session id and the detail pane renders a ChatScreen for a session that
70+ // doesn't exist on the new server, showing a load error with no explanation.
71+ // Skips the initial transition (null -> a profile id) so a deep link / pending
72+ // open session that sets `selected` on the same first composition isn't clobbered.
73+ var lastProfileId by remember { mutableStateOf<String?>(null) }
74+ LaunchedEffect(activeConnection?.profile?.id) {
75+ val currentId = activeConnection?.profile?.id
76+ if (lastProfileId != null && lastProfileId != currentId) selected = null
77+ lastProfileId = currentId
78+ }
79+
80+ // Inject a pending share into the currently selected session's draft (if any).
81+ // Unlike single-pane mode where the session list is navigated to specifically for
82+ // the share, in two-pane mode the list is always visible — so we inject into the
83+ // session the user is already viewing. If no session is selected, the share is
84+ // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
85+ // changes to non-null).
86+ LaunchedEffect(pendingShare, selected) {
87+ val target = selected
88+ if (pendingShare != null && target != null) {
89+ container.consumePendingShare()?.let { container.draftStore.set(target, it) }
90+ }
91+ }
92+
93+ Row(modifier = modifier.fillMaxSize()) {
94+ Box(
95+ modifier = Modifier
96+ .weight(NetworkConfig.twoPaneLeftWeight)
97+ .widthIn(max = 460.dp),
98+ ) {
99+ SessionListScreen(
100+ container = container,
101+ onOpenSession = { id ->
102+ selected = id
103+ },
104+ onDisconnect = onDisconnect,
105+ onOpenFiles = onOpenFiles,
106+ onOpenSettings = onOpenSettings,
107+ onAddServer = onAddServer,
108+ selectedSessionId = selected,
109+ )
110+ }
111+
112+ Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
113+ val sessionId = selected
114+ if (sessionId == null) {
115+ EmptyDetail()
116+ } else {
117+ BackHandler { selected = null }
118+ // key() on the session id so switching conversations in two-pane mode
119+ // gives ChatScreen a fresh composition. Without it, position-anchored
120+ // state (rememberLazyListState, didInitialScroll, and the LaunchedEffect(Unit)
121+ // that wires error/retry snackbars) persists across the switch: the new
122+ // session inherits the old scroll offset, skips its initial scroll-to-bottom,
123+ // and its error events go unhandled. viewModel(key=sessionId) already swaps
124+ // the VM; this aligns the composition state with it.
125+ key(sessionId) {
126+ ChatScreen(
127+ container = container,
128+ sessionId = sessionId,
129+ onBack = { selected = null },
130+ onOpenFile = onOpenFile,
131+ )
132+ }
133+ }
134+ }
135+ }
136+}
137+
138+@Composable
139+private fun EmptyDetail() {
140+ Column(
141+ modifier = Modifier.fillMaxSize().padding(32.dp),
142+ verticalArrangement = Arrangement.Center,
143+ horizontalAlignment = Alignment.CenterHorizontally,
144+ ) {
145+ Icon(
146+ Icons.AutoMirrored.Filled.Chat,
147+ contentDescription = null,
148+ tint = MaterialTheme.colorScheme.outline,
149+ )
150+ Text(
151+ stringResource(R.string.empty_detail_pane),
152+ style = MaterialTheme.typography.bodyLarge,
153+ color = MaterialTheme.colorScheme.onSurfaceVariant,
154+ modifier = Modifier.padding(top = 12.dp),
155+ )
156+ }
157+}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktnew file+403
@@ -0,0 +1,403 @@
1+package soy.iko.opencode.ui.settings
2+
3+import android.content.Intent
4+import android.util.Log
5+import androidx.compose.foundation.background
6+import androidx.compose.foundation.clickable
7+import androidx.compose.foundation.layout.Arrangement
8+import androidx.compose.foundation.layout.Box
9+import androidx.compose.foundation.layout.Column
10+import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.fillMaxSize
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.heightIn
15+import androidx.compose.foundation.layout.padding
16+import androidx.compose.foundation.layout.size
17+import androidx.compose.foundation.lazy.LazyColumn
18+import androidx.compose.foundation.lazy.items
19+import androidx.compose.foundation.rememberScrollState
20+import androidx.compose.foundation.verticalScroll
21+import androidx.compose.material.icons.Icons
22+import androidx.compose.material.icons.automirrored.filled.ArrowBack
23+import androidx.compose.material.icons.filled.BugReport
24+import androidx.compose.material.icons.filled.DeleteSweep
25+import androidx.compose.material.icons.filled.Share
26+import androidx.compose.material3.AlertDialog
27+import androidx.compose.material3.CircularProgressIndicator
28+import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.HorizontalDivider
30+import androidx.compose.material3.Icon
31+import androidx.compose.material3.IconButton
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.Scaffold
34+import androidx.compose.material3.SnackbarDuration
35+import androidx.compose.material3.SnackbarHost
36+import androidx.compose.material3.SnackbarHostState
37+import androidx.compose.material3.SnackbarResult
38+import androidx.compose.material3.SwipeToDismissBox
39+import androidx.compose.material3.SwipeToDismissBoxValue
40+import androidx.compose.material3.Text
41+import androidx.compose.material3.TextButton
42+import androidx.compose.material3.TopAppBar
43+import androidx.compose.material3.rememberSwipeToDismissBoxState
44+import androidx.compose.runtime.Composable
45+import androidx.compose.runtime.CompositionLocalProvider
46+import androidx.compose.runtime.LaunchedEffect
47+import androidx.compose.runtime.getValue
48+import androidx.compose.runtime.mutableStateOf
49+import androidx.compose.runtime.remember
50+import androidx.compose.runtime.rememberCoroutineScope
51+import androidx.compose.runtime.saveable.rememberSaveable
52+import androidx.compose.runtime.setValue
53+import kotlinx.coroutines.Dispatchers
54+import kotlinx.coroutines.channels.BufferOverflow
55+import kotlinx.coroutines.coroutineScope
56+import kotlinx.coroutines.delay
57+import kotlinx.coroutines.flow.MutableSharedFlow
58+import kotlinx.coroutines.flow.collectLatest
59+import kotlinx.coroutines.launch
60+import kotlinx.coroutines.withContext
61+import soy.iko.opencode.util.runCatchingCancellable
62+import soy.iko.opencode.ui.components.showToast
63+import androidx.compose.ui.Alignment
64+import androidx.compose.ui.Modifier
65+import androidx.compose.ui.draw.clip
66+import androidx.compose.ui.platform.LocalContext
67+import androidx.compose.ui.platform.LocalHapticFeedback
68+import androidx.compose.ui.res.stringResource
69+import androidx.compose.ui.semantics.Role
70+import androidx.compose.ui.semantics.contentDescription
71+import androidx.compose.ui.semantics.heading
72+import androidx.compose.ui.semantics.semantics
73+import androidx.compose.ui.unit.dp
74+import androidx.lifecycle.compose.collectAsStateWithLifecycle
75+import soy.iko.opencode.R
76+import soy.iko.opencode.data.network.NetworkConfig
77+import soy.iko.opencode.data.repo.CrashLogger
78+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
79+import soy.iko.opencode.ui.components.rememberRelativeTime
80+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
81+
82+@OptIn(ExperimentalMaterial3Api::class)
83+@Composable
84+fun DiagnosticsScreen(onBack: () -> Unit) {
85+ val context = LocalContext.current
86+ val logger = remember { CrashLogger.get(context) }
87+ val reports by logger.reports.collectAsStateWithLifecycle()
88+ var viewing by rememberSaveable { mutableStateOf<String?>(null) }
89+ var showClearAll by rememberSaveable { mutableStateOf(false) }
90+ var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
91+ val shareScope = rememberCoroutineScope()
92+ val haptics = LocalHapticFeedback.current
93+ val snackbar = remember { SnackbarHostState() }
94+ val undoLabel = stringResource(R.string.undo)
95+ val reportDeletedLabel = stringResource(R.string.report_deleted)
96+ // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
97+ // so they survive both rotation and navigating away from this screen. The screen only
98+ // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
99+ val shareLabel = stringResource(R.string.share)
100+ val shareSubject = stringResource(R.string.crash_report_share_subject)
101+ val timeTick = rememberRelativeTimeTick()
102+
103+ // Report-delete undo events, shown via a single collectLatest collector rather than a
104+ // per-confirm launch. A serialized showSnackbar would queue each report's undo behind
105+ // the previous one's full window, so under rapid deletes a later report's delete timer
106+ // (started at confirm time) fires before its snackbar is ever shown — a dead Undo
107+ // button. collectLatest cancels the current snackbar and shows the newest immediately,
108+ // keeping each Undo window aligned with its own timer. Mirrors ServerListScreen.
109+ val reportDeleteEvents = remember {
110+ MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
111+ }
112+ LaunchedEffect(Unit) {
113+ reportDeleteEvents.collectLatest { name ->
114+ coroutineScope {
115+ val dismisser = launch {
116+ delay(NetworkConfig.undoReportDeleteDelayMs)
117+ snackbar.currentSnackbarData?.dismiss()
118+ }
119+ val result = snackbar.showSnackbar(
120+ message = reportDeletedLabel,
121+ actionLabel = undoLabel,
122+ duration = SnackbarDuration.Indefinite,
123+ )
124+ dismisser.cancel()
125+ if (result == SnackbarResult.ActionPerformed) {
126+ logger.cancelScheduledDelete(name)
127+ }
128+ }
129+ }
130+ }
131+
132+ Scaffold(
133+ topBar = {
134+ TopAppBar(
135+ title = { Text(stringResource(R.string.diagnostics)) },
136+ navigationIcon = {
137+ IconButton(onClick = onBack) {
138+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
139+ }
140+ },
141+ actions = {
142+ if (reports.isNotEmpty()) {
143+ IconButton(onClick = {
144+ showClearAll = true
145+ }) {
146+ Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
147+ }
148+ }
149+ },
150+ )
151+ },
152+ snackbarHost = { SnackbarHost(snackbar) },
153+ ) { padding ->
154+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
155+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
156+ Text(
157+ stringResource(R.string.diagnostics_desc),
158+ style = MaterialTheme.typography.bodySmall,
159+ color = MaterialTheme.colorScheme.onSurfaceVariant,
160+ modifier = Modifier.padding(16.dp),
161+ )
162+ Text(
163+ stringResource(R.string.crash_reports),
164+ style = MaterialTheme.typography.titleSmall,
165+ color = MaterialTheme.colorScheme.primary,
166+ modifier = Modifier
167+ .padding(start = 16.dp, bottom = 8.dp)
168+ .semantics { heading() },
169+ )
170+ if (reports.isEmpty()) {
171+ EmptyCrashReports(
172+ modifier = Modifier.fillMaxWidth().padding(24.dp),
173+ )
174+ } else {
175+ LazyColumn(modifier = Modifier.fillMaxSize()) {
176+ items(reports, key = { it.fileName }) { report ->
177+ // Swipe end-to-start reveals a delete affordance. The delete is
178+ // deferred for an undo window (matching the session/server lists);
179+ // confirmValueChange snaps back so the row remains while the undo
180+ // snackbar is shown, and is removed only when the window expires.
181+ val swipeState = rememberSwipeToDismissBoxState(
182+ confirmValueChange = {
183+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
184+ pendingReportDelete = report.fileName
185+ false
186+ },
187+ )
188+ SwipeToDismissBox(
189+ state = swipeState,
190+ enableDismissFromStartToEnd = false,
191+ modifier = Modifier.animateItem(),
192+ backgroundContent = {
193+ Box(
194+ modifier = Modifier
195+ .fillMaxSize()
196+ .clip(MaterialTheme.shapes.medium)
197+ .background(MaterialTheme.colorScheme.errorContainer)
198+ .padding(horizontal = 20.dp),
199+ contentAlignment = Alignment.CenterEnd,
200+ ) {
201+ Icon(
202+ Icons.Filled.DeleteSweep,
203+ contentDescription = stringResource(R.string.delete),
204+ tint = MaterialTheme.colorScheme.onErrorContainer,
205+ )
206+ }
207+ },
208+ ) {
209+ Row(
210+ modifier = Modifier
211+ .fillMaxWidth()
212+ .clickable(role = Role.Button) { viewing = report.fileName }
213+ .padding(horizontal = 16.dp, vertical = 12.dp),
214+ verticalAlignment = Alignment.CenterVertically,
215+ horizontalArrangement = Arrangement.spacedBy(12.dp),
216+ ) {
217+ Icon(
218+ Icons.Filled.BugReport,
219+ contentDescription = null,
220+ tint = MaterialTheme.colorScheme.error,
221+ )
222+ Column(modifier = Modifier.weight(1f)) {
223+ Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
224+ Text(
225+ rememberRelativeTime(report.timestamp),
226+ style = MaterialTheme.typography.bodySmall,
227+ color = MaterialTheme.colorScheme.onSurfaceVariant,
228+ )
229+ }
230+ IconButton(onClick = {
231+ shareScope.launch {
232+ val content = withContext(Dispatchers.IO) {
233+ logger.readReport(report.fileName).orEmpty()
234+ }
235+ val send = Intent(Intent.ACTION_SEND).apply {
236+ type = "text/plain"
237+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
238+ putExtra(Intent.EXTRA_TEXT, content)
239+ }
240+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
241+ .onFailure {
242+ Log.w("Diagnostics", "Failed to share crash report", it)
243+ showToast(context, context.getString(R.string.no_share_app))
244+ }
245+ }
246+ }) {
247+ Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
248+ }
249+ }
250+ }
251+ HorizontalDivider()
252+ }
253+ }
254+ }
255+ }
256+ }
257+ }
258+
259+ val reportName = viewing
260+ if (reportName != null) {
261+ var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
262+ var loadFailed by remember(reportName) { mutableStateOf(false) }
263+ LaunchedEffect(reportName) {
264+ val loaded = withContext(Dispatchers.IO) {
265+ // runCatchingCancellable (not runCatching) so dismissing the dialog or
266+ // switching reports mid-read lets the CancellationException propagate
267+ // instead of being swallowed and setting state on a torn-down effect.
268+ runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
269+ }
270+ if (loaded != null) reportContent = loaded else loadFailed = true
271+ }
272+ val content = reportContent
273+ AlertDialog(
274+ onDismissRequest = { viewing = null },
275+ confirmButton = {
276+ TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
277+ },
278+ dismissButton = {
279+ Row {
280+ TextButton(
281+ // Disable Share until the report has loaded — otherwise tapping it
282+ // while the async read is still in flight (content == null) shares an
283+ // empty body instead of the report.
284+ enabled = content != null,
285+ onClick = {
286+ val send = Intent(Intent.ACTION_SEND).apply {
287+ type = "text/plain"
288+ putExtra(Intent.EXTRA_SUBJECT, shareSubject)
289+ putExtra(Intent.EXTRA_TEXT, content.orEmpty())
290+ }
291+ runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
292+ .onFailure {
293+ Log.w("Diagnostics", "Failed to share crash report", it)
294+ showToast(context, context.getString(R.string.no_share_app))
295+ }
296+ },
297+ ) { Text(stringResource(R.string.share)) }
298+ Spacer(Modifier.size(8.dp))
299+ TextButton(onClick = {
300+ // Confirm before deleting a single report, matching clear-all.
301+ pendingReportDelete = reportName
302+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
303+ }
304+ },
305+ title = { Text(stringResource(R.string.crash_report)) },
306+ text = {
307+ when {
308+ content != null -> Box(
309+ modifier = Modifier
310+ .fillMaxWidth()
311+ .heightIn(max = 400.dp)
312+ .verticalScroll(rememberScrollState()),
313+ ) {
314+ Text(
315+ content,
316+ style = MaterialTheme.typography.bodySmall,
317+ modifier = Modifier.fillMaxWidth(),
318+ )
319+ }
320+ loadFailed -> Text(
321+ stringResource(R.string.report_load_failed),
322+ color = MaterialTheme.colorScheme.error,
323+ modifier = Modifier.padding(vertical = 24.dp),
324+ )
325+ else -> {
326+ val loadingLabel = stringResource(R.string.loading)
327+ Box(
328+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
329+ contentAlignment = Alignment.Center,
330+ ) {
331+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
332+ }
333+ }
334+ }
335+ },
336+ )
337+ }
338+
339+ pendingReportDelete?.let { name ->
340+ AlertDialog(
341+ onDismissRequest = { pendingReportDelete = null },
342+ title = { Text(stringResource(R.string.delete_report_title)) },
343+ text = { Text(stringResource(R.string.delete_report_text)) },
344+ confirmButton = {
345+ TextButton(onClick = {
346+ pendingReportDelete = null
347+ viewing = null
348+ // Defer the actual delete so the Undo snackbar can cancel it. The
349+ // deletion is owned by the CrashLogger's own scope, so it commits even
350+ // if the user navigates away during the undo window. The Undo snackbar
351+ // is shown by the single collectLatest collector above (not a per-confirm
352+ // launch) so rapid consecutive deletes don't produce a dead Undo button.
353+ logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
354+ reportDeleteEvents.tryEmit(name)
355+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
356+ },
357+ dismissButton = {
358+ TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
359+ },
360+ )
361+ }
362+
363+ if (showClearAll) {
364+ AlertDialog(
365+ onDismissRequest = { showClearAll = false },
366+ title = { Text(stringResource(R.string.clear_all)) },
367+ text = { Text(stringResource(R.string.clear_all_confirm)) },
368+ confirmButton = {
369+ TextButton(onClick = {
370+ showClearAll = false
371+ logger.clearAll()
372+ }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
373+ },
374+ dismissButton = {
375+ TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
376+ },
377+ )
378+ }
379+}
380+
381+/** Empty state for the crash reports list. Rendered with an icon + text to match the
382+ * empty-state pattern used by the session and server lists (the prior version was a
383+ * bare `Text` line that read as a status message rather than an intentional state). */
384+@Composable
385+private fun EmptyCrashReports(modifier: Modifier = Modifier) {
386+ Column(
387+ modifier = modifier,
388+ horizontalAlignment = Alignment.CenterHorizontally,
389+ ) {
390+ Icon(
391+ Icons.Filled.BugReport,
392+ contentDescription = null,
393+ modifier = Modifier.size(48.dp),
394+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
395+ )
396+ Spacer(Modifier.size(12.dp))
397+ Text(
398+ stringResource(R.string.no_crash_reports),
399+ style = MaterialTheme.typography.bodyMedium,
400+ color = MaterialTheme.colorScheme.onSurfaceVariant,
401+ )
402+ }
403+}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+411
@@ -0,0 +1,411 @@
1+package soy.iko.opencode.ui.settings
2+
3+import android.content.pm.PackageManager
4+import android.os.Build
5+import androidx.compose.foundation.background
6+import androidx.compose.foundation.clickable
7+import androidx.compose.foundation.layout.Arrangement
8+import androidx.compose.foundation.layout.Box
9+import androidx.compose.foundation.layout.Column
10+import androidx.compose.foundation.layout.Row
11+import androidx.compose.foundation.layout.Spacer
12+import androidx.compose.foundation.layout.defaultMinSize
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.layout.widthIn
17+import androidx.compose.foundation.rememberScrollState
18+import androidx.compose.foundation.selection.selectable
19+import androidx.compose.foundation.selection.toggleable
20+import androidx.compose.foundation.verticalScroll
21+import androidx.compose.foundation.shape.CircleShape
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.automirrored.filled.ArrowBack
24+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
25+import androidx.compose.material.icons.filled.BugReport
26+import androidx.compose.material.icons.filled.Dns
27+import androidx.compose.material3.ExperimentalMaterial3Api
28+import androidx.compose.material3.CircularProgressIndicator
29+import androidx.compose.material3.HorizontalDivider
30+import androidx.compose.material3.Icon
31+import androidx.compose.material3.IconButton
32+import androidx.compose.material3.MaterialTheme
33+import androidx.compose.material3.RadioButton
34+import androidx.compose.material3.Scaffold
35+import androidx.compose.material3.Switch
36+import androidx.compose.material3.Text
37+import androidx.compose.material3.TextButton
38+import androidx.compose.material3.TopAppBar
39+import androidx.compose.runtime.Composable
40+import androidx.compose.runtime.getValue
41+import androidx.compose.runtime.remember
42+import androidx.compose.runtime.rememberCoroutineScope
43+import androidx.compose.ui.Alignment
44+import androidx.compose.ui.Modifier
45+import androidx.compose.ui.draw.clip
46+import androidx.compose.ui.graphics.Color
47+import androidx.compose.ui.platform.LocalContext
48+import androidx.compose.ui.res.stringResource
49+import androidx.compose.ui.semantics.Role
50+import androidx.compose.ui.semantics.heading
51+import androidx.compose.ui.semantics.role
52+import androidx.compose.ui.semantics.semantics
53+import androidx.compose.ui.text.style.TextAlign
54+import androidx.compose.ui.unit.dp
55+import androidx.lifecycle.compose.collectAsStateWithLifecycle
56+import kotlinx.coroutines.ExperimentalCoroutinesApi
57+import kotlinx.coroutines.flow.combine
58+import kotlinx.coroutines.flow.flatMapLatest
59+import kotlinx.coroutines.flow.flowOf
60+import kotlinx.coroutines.launch
61+import soy.iko.opencode.data.network.EventStreamClient
62+import soy.iko.opencode.data.repo.CrashLogger
63+import soy.iko.opencode.data.repo.ThemeMode
64+import soy.iko.opencode.di.AppContainer
65+import soy.iko.opencode.R
66+import soy.iko.opencode.ui.theme.LightPaletteSwatches
67+import soy.iko.opencode.ui.theme.DarkPaletteSwatches
68+import soy.iko.opencode.util.runCatchingCancellable
69+
70+@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class)
71+@Composable
72+fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers: () -> Unit, onOpenDiagnostics: () -> Unit) {
73+ val scope = rememberCoroutineScope()
74+ // Combine the three settings into a single nullable state so the appearance section
75+ // renders only after the persisted values have loaded, avoiding a brief flash of
76+ // hardcoded defaults (SYSTEM/light/dynamic-on) on cold start.
77+ // Wrapped in remember so the combined Flow isn't re-created on every recomposition
78+ // (which would cancel and relaunch the DataStore collection each time — see the
79+ // connectionStateFlow below, remembered for the same reason).
80+ val settingsFlow = remember(container) {
81+ combine(
82+ container.settingsStore.themeMode,
83+ container.settingsStore.dynamicColor,
84+ container.settingsStore.sendOnEnter,
85+ ) { theme, dyn, enter -> SettingsValues(theme, dyn, enter) as SettingsValues? }
86+ }
87+ val settings by settingsFlow.collectAsStateWithLifecycle(initialValue = null)
88+ val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
89+ val activeProfile = container.activeConnection.collectAsStateWithLifecycle().value?.profile
90+ // SSE connection state so the Settings screen can show a dropped stream (the
91+ // ConnectionBanner surfaces this on the chat and session screens; without it here,
92+ // a user who opens Settings during a reconnect wouldn't know the stream dropped).
93+ // Wrapped in remember so the Flow operator isn't re-created every recomposition
94+ // (which would reset collectAsStateWithLifecycle and cause flicker).
95+ val connectionStateFlow = remember {
96+ container.activeConnection
97+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
98+ }
99+ val connectionState by connectionStateFlow
100+ .collectAsStateWithLifecycle(initialValue = EventStreamClient.ConnectionState.Disconnected)
101+ val context = LocalContext.current
102+ // Crash count badge: surface that there are reports to look at without making the
103+ // user open the screen to find out.
104+ val crashLogger = remember { CrashLogger.get(context) }
105+ val crashReports by crashLogger.reports.collectAsStateWithLifecycle()
106+ val crashCount = crashReports.size
107+ val unknownVersion = stringResource(R.string.unknown_version)
108+ val versionName = remember {
109+ runCatching {
110+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
111+ context.packageManager.getPackageInfo(
112+ context.packageName,
113+ PackageManager.PackageInfoFlags.of(0),
114+ ).versionName
115+ } else {
116+ @Suppress("DEPRECATION")
117+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
118+ }
119+ }.getOrNull() ?: unknownVersion
120+ }
121+
122+ Scaffold(
123+ topBar = {
124+ TopAppBar(
125+ title = { Text(stringResource(R.string.settings)) },
126+ navigationIcon = {
127+ IconButton(onClick = onBack) {
128+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
129+ }
130+ },
131+ )
132+ },
133+ ) { padding ->
134+ Column(modifier = Modifier.padding(padding).widthIn(max = 600.dp).verticalScroll(rememberScrollState()).padding(16.dp)) {
135+ val s = settings
136+ Text(
137+ stringResource(R.string.appearance),
138+ style = MaterialTheme.typography.titleSmall,
139+ color = MaterialTheme.colorScheme.primary,
140+ modifier = Modifier.semantics { heading() },
141+ )
142+ if (s != null) {
143+ ThemeMode.entries.forEach { mode ->
144+ ThemeRow(
145+ mode = mode,
146+ selected = s.themeMode == mode,
147+ onSelect = { scope.launch { runCatchingCancellable { container.settingsStore.setThemeMode(mode) } } },
148+ )
149+ }
150+
151+ Spacer(Modifier.size(8.dp))
152+ // Dynamic color (Material You) only works on Android 12+, so hide the toggle
153+ // on older devices instead of offering a control that silently does nothing.
154+ if (dynamicColorAvailable) {
155+ Row(
156+ modifier = Modifier
157+ .fillMaxWidth()
158+ .toggleable(
159+ value = s.dynamicColor,
160+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setDynamicColor(it) } } },
161+ role = Role.Switch,
162+ )
163+ .padding(vertical = 8.dp),
164+ verticalAlignment = Alignment.CenterVertically,
165+ ) {
166+ Column(modifier = Modifier.weight(1f)) {
167+ Text(stringResource(R.string.dynamic_color), style = MaterialTheme.typography.bodyLarge)
168+ Text(
169+ stringResource(R.string.dynamic_color_desc),
170+ style = MaterialTheme.typography.bodySmall,
171+ color = MaterialTheme.colorScheme.onSurfaceVariant,
172+ )
173+ }
174+ Switch(
175+ checked = s.dynamicColor,
176+ onCheckedChange = null,
177+ )
178+ }
179+ }
180+
181+ // Send-on-Enter: controls hardware-keyboard Enter behavior in the chat input.
182+ Spacer(Modifier.size(4.dp))
183+ Row(
184+ modifier = Modifier
185+ .fillMaxWidth()
186+ .toggleable(
187+ value = s.sendOnEnter,
188+ onValueChange = { scope.launch { runCatchingCancellable { container.settingsStore.setSendOnEnter(it) } } },
189+ role = Role.Switch,
190+ )
191+ .padding(vertical = 8.dp),
192+ verticalAlignment = Alignment.CenterVertically,
193+ ) {
194+ Column(modifier = Modifier.weight(1f)) {
195+ Text(stringResource(R.string.send_on_enter), style = MaterialTheme.typography.bodyLarge)
196+ Text(
197+ stringResource(R.string.send_on_enter_desc),
198+ style = MaterialTheme.typography.bodySmall,
199+ color = MaterialTheme.colorScheme.onSurfaceVariant,
200+ )
201+ }
202+ Switch(
203+ checked = s.sendOnEnter,
204+ onCheckedChange = null,
205+ )
206+ }
207+ } else {
208+ Box(
209+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
210+ contentAlignment = Alignment.Center,
211+ ) {
212+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp)
213+ }
214+ }
215+
216+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
217+
218+ Text(
219+ stringResource(R.string.connection),
220+ style = MaterialTheme.typography.titleSmall,
221+ color = MaterialTheme.colorScheme.primary,
222+ modifier = Modifier.semantics { heading() },
223+ )
224+ if (activeProfile != null) {
225+ Text(activeProfile.displayLabel, modifier = Modifier.padding(top = 8.dp))
226+ Text(
227+ activeProfile.baseUrl + if (activeProfile.hasAuth) stringResource(R.string.server_auth_basic) else "",
228+ style = MaterialTheme.typography.bodySmall,
229+ color = MaterialTheme.colorScheme.onSurfaceVariant,
230+ )
231+ // Surface the SSE stream state so a user who opens Settings during a
232+ // reconnect saw it (the ConnectionBanner on other screens handles this
233+ // elsewhere). Only show non-Connected states to avoid clutter. On a hard
234+ // failure offer an inline Retry — without it a user who opened Settings
235+ // during a failure is stuck on an error message with no recovery path
236+ // (the ConnectionBanner used elsewhere has Retry, but Settings doesn't
237+ // host it). Retry forces an SSE reconnect, which re-seeds from REST.
238+ val stateText = when (connectionState) {
239+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
240+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
241+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
242+ EventStreamClient.ConnectionState.Connected -> null
243+ }
244+ stateText?.let {
245+ Row(
246+ modifier = Modifier.padding(top = 4.dp),
247+ verticalAlignment = Alignment.CenterVertically,
248+ ) {
249+ Text(
250+ it,
251+ style = MaterialTheme.typography.bodySmall,
252+ color = if (connectionState == EventStreamClient.ConnectionState.Failed)
253+ MaterialTheme.colorScheme.error
254+ else MaterialTheme.colorScheme.onSurfaceVariant,
255+ )
256+ if (connectionState == EventStreamClient.ConnectionState.Failed) {
257+ TextButton(
258+ onClick = { container.activeConnection.value?.events?.triggerReconnect() },
259+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
260+ ) { Text(stringResource(R.string.retry_now)) }
261+ }
262+ }
263+ }
264+ } else {
265+ Text(
266+ stringResource(R.string.not_connected),
267+ modifier = Modifier.padding(top = 8.dp),
268+ color = MaterialTheme.colorScheme.onSurfaceVariant,
269+ )
270+ }
271+ Row(
272+ modifier = Modifier
273+ .fillMaxWidth()
274+ .defaultMinSize(minHeight = 48.dp)
275+ .padding(top = 8.dp)
276+ .clickable(role = Role.Button) { onManageServers() }
277+ .padding(vertical = 8.dp),
278+ verticalAlignment = Alignment.CenterVertically,
279+ ) {
280+ Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
281+ Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
282+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
283+ }
284+
285+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
286+
287+ Text(
288+ stringResource(R.string.about),
289+ style = MaterialTheme.typography.titleSmall,
290+ color = MaterialTheme.colorScheme.primary,
291+ modifier = Modifier.semantics { heading() },
292+ )
293+ Text(
294+ stringResource(R.string.about_version, versionName),
295+ style = MaterialTheme.typography.bodyMedium,
296+ modifier = Modifier.padding(top = 8.dp),
297+ )
298+ Text(
299+ stringResource(R.string.about_desc),
300+ style = MaterialTheme.typography.bodySmall,
301+ color = MaterialTheme.colorScheme.onSurfaceVariant,
302+ modifier = Modifier.padding(top = 4.dp),
303+ )
304+
305+ Spacer(Modifier.size(16.dp))
306+ Row(
307+ modifier = Modifier
308+ .fillMaxWidth()
309+ .defaultMinSize(minHeight = 48.dp)
310+ .clickable(role = Role.Button) { onOpenDiagnostics() }
311+ .padding(vertical = 8.dp),
312+ verticalAlignment = Alignment.CenterVertically,
313+ ) {
314+ Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
315+ Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
316+ // Badge with the crash count so the user can tell at a glance whether
317+ // there's something worth investigating.
318+ if (crashCount > 0) {
319+ Badge(count = crashCount)
320+ Spacer(Modifier.size(8.dp))
321+ }
322+ Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
323+ }
324+ }
325+ }
326+}
327+
328+@Composable
329+private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
330+ // Preview swatches so the user can see what each mode looks like before selecting,
331+ // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
332+ // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
333+ val swatches: List<Color> = when (mode) {
334+ ThemeMode.SYSTEM -> {
335+ val dark = isSystemInDarkThemeStatic()
336+ if (dark) DarkSwatches else LightSwatches
337+ }
338+ ThemeMode.LIGHT -> LightSwatches
339+ ThemeMode.DARK -> DarkSwatches
340+ }
341+ Row(
342+ modifier = Modifier
343+ .fillMaxWidth()
344+ .selectable(selected = selected, onClick = onSelect)
345+ .padding(vertical = 8.dp),
346+ horizontalArrangement = Arrangement.spacedBy(8.dp),
347+ verticalAlignment = Alignment.CenterVertically,
348+ ) {
349+ RadioButton(selected = selected, onClick = null)
350+ Text(
351+ when (mode) {
352+ ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
353+ ThemeMode.LIGHT -> stringResource(R.string.theme_light)
354+ ThemeMode.DARK -> stringResource(R.string.theme_dark)
355+ },
356+ style = MaterialTheme.typography.bodyLarge,
357+ modifier = Modifier.weight(1f),
358+ )
359+ // Three small color dots previewing the palette for this mode.
360+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
361+ swatches.forEach { color ->
362+ Box(
363+ modifier = Modifier
364+ .size(14.dp)
365+ .clip(CircleShape)
366+ .background(color),
367+ )
368+ }
369+ }
370+ }
371+}
372+
373+// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
374+// the character of each scheme at a glance without rendering a full sample surface.
375+private val DarkSwatches: List<Color> = DarkPaletteSwatches
376+private val LightSwatches: List<Color> = LightPaletteSwatches
377+
378+// Composable-side read of the system dark setting for the "System" theme row preview.
379+// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
380+// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
381+// recomposition surprises.
382+@Composable
383+private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
384+
385+/** Small wrapper so the three appearance settings load atomically and the UI can gate
386+ * on a single null check instead of flashing hardcoded defaults. */
387+private data class SettingsValues(
388+ val themeMode: ThemeMode,
389+ val dynamicColor: Boolean,
390+ val sendOnEnter: Boolean,
391+)
392+
393+/** Small circular count badge used to indicate pending crash reports. */
394+@Composable
395+private fun Badge(count: Int) {
396+ val overflow = stringResource(R.string.crash_count_overflow)
397+ Box(
398+ modifier = Modifier
399+ .size(20.dp)
400+ .clip(CircleShape)
401+ .background(MaterialTheme.colorScheme.error),
402+ contentAlignment = Alignment.Center,
403+ ) {
404+ Text(
405+ if (count > 99) overflow else count.toString(),
406+ style = MaterialTheme.typography.labelSmall,
407+ color = MaterialTheme.colorScheme.onError,
408+ textAlign = TextAlign.Center,
409+ )
410+ }
411+}
app/src/main/java/soy/iko/opencode/ui/theme/Color.ktdeleted−19
@@ -1,19 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.ui.graphics.Color
4−
5−// A calm, terminal-adjacent palette. Used when dynamic color isn't available.
6−val Emerald = Color(0xFF3DDC84)
7−val EmeraldDark = Color(0xFF1B8F52)
8−
9−val DarkBackground = Color(0xFF0F1115)
10−val DarkSurface = Color(0xFF171A21)
11−val DarkSurfaceVariant = Color(0xFF232833)
12−val DarkOnSurface = Color(0xFFE6E9EF)
13−val DarkOutline = Color(0xFF3A4150)
14−
15−val LightBackground = Color(0xFFF7F8FA)
16−val LightSurface = Color(0xFFFFFFFF)
17−val LightSurfaceVariant = Color(0xFFEBEDF2)
18−val LightOnSurface = Color(0xFF1A1C1F)
19−val LightOutline = Color(0xFFC4C9D4)
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.foundation.shape.RoundedCornerShape
4+import androidx.compose.material3.Shapes
5+import androidx.compose.ui.unit.dp
6+
7+/**
8+ * Material 3 shape scale tuned for the chat UI. Bubbles, cards, and sheets use rounded
9+ * corners that read as friendly and conversational; the previous code scattered bare
10+ * `RoundedCornerShape(16.dp)` literals across message bubbles and cards — centralizing
11+ * them here lets the shape scale change in one place and keeps components consistent.
12+ */
13+val OpencodeShapes = Shapes(
14+ extraSmall = RoundedCornerShape(4.dp),
15+ small = RoundedCornerShape(8.dp),
16+ medium = RoundedCornerShape(16.dp),
17+ large = RoundedCornerShape(24.dp),
18+ extraLarge = RoundedCornerShape(32.dp),
19+)
app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+110−38

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktdeleted−15

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

app/src/main/res/values-night/colors.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values-night/themes.xmldeleted−8

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktdeleted−69

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

build.gradle.kts+1−1

This diff is too big to show here.

config/detekt/detekt.ymlnew file+60

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+17−27

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javadeleted−38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javadeleted−33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javadeleted−12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javadeleted−195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javadeleted−146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javadeleted−56

This diff is too big to show here.

generated/com/novi/serde/Int128.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javadeleted−58

This diff is too big to show here.

generated/com/novi/serde/Slice.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javadeleted−46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javadeleted−61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javadeleted−67

This diff is too big to show here.

generated/com/novi/serde/Unit.javadeleted−20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javadeleted−11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javadeleted−131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javadeleted−27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javadeleted−77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javadeleted−86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javadeleted−71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javadeleted−185

This diff is too big to show here.

gradle.properties+2−5

This diff is too big to show here.

gradle/libs.versions.toml+50−25

This diff is too big to show here.

renovate.jsonnew file+32

This diff is too big to show here.

settings.gradle.kts−3

This diff is too big to show here.

shared/Cargo.lockdeleted−1358

This diff is too big to show here.

shared/Cargo.tomldeleted−31

This diff is too big to show here.

shared/src/app.rsdeleted−1051

This diff is too big to show here.

shared/src/bin/codegen.rsdeleted−35

This diff is too big to show here.

shared/src/lib.rsdeleted−17

This diff is too big to show here.

shared/src/protocol.rsdeleted−248

This diff is too big to show here.