git.iko.soy/opencode-android
on master help sign in
169 files changed+8092−19806
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+18−16 .github/workflows/release.yml+113 .gitignore+20 AGENTS.md−197 CLAUDE.md+40 README.md+126−64 app/build.gradle.kts+44−92 app/detekt-baseline.xml−15 app/proguard-rules.pro+13−44 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+7−35 app/src/main/java/soy/iko/opencode/CoreViewModel.kt+66 app/src/main/java/soy/iko/opencode/MainActivity.kt+13−115 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−214 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−302 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt−37 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt−202 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt−229 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt−78 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt−209 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt−366 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt−50 app/src/main/java/soy/iko/opencode/di/AppContainer.kt−508 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+100−195 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−192 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt−900 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt−688 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt−193 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt−100 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt−276 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt−171 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt−475 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt−114 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt−262 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt−113 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−143 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt−159 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt−362 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt−188 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt−59 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt−511 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−343 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt−275 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt−413 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt−139 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt−715 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt−558 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt−146 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt−381 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt−397 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+38−110 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+1−2 app/src/main/res/values/strings.xml−258 app/src/main/res/values/themes.xml+9−10 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−368 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+27−17 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+5−2 gradle/libs.versions.toml+25−50 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-reloaddeleted−19
@@ -1,19 +0,0 @@
1−#!/usr/bin/env bash
2−set -e
3−if [[ ! -d "/home/iko/opencode-android" ]]; then
4− echo "Cannot find source directory; Did you move it?"
5− echo "(Looking for "/home/iko/opencode-android")"
6− echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
7− exit 1
8−fi
9−
10−# rebuild the cache forcefully
11−_nix_direnv_force_reload=1 direnv exec "/home/iko/opencode-android" true
12−
13−# Update the mtime for .envrc.
14−# This will cause direnv to reload again - but without re-building.
15−touch "/home/iko/opencode-android/.envrc"
16−
17−# Also update the timestamp of whatever profile_rc we have.
18−# This makes sure that we know we are up to date.
19−touch -r "/home/iko/opencode-android/.envrc" "/home/iko/opencode-android/.direnv"/*.rc
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfadeleted−1
@@ -1 +0,0 @@
1−/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcdeleted−2250
@@ -1,2250 +0,0 @@
1−unset shellHook
2−PATH=${PATH:-}
3−nix_saved_PATH="$PATH"
4−XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
5−nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
6−ANDROID_HOME='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
7−export ANDROID_HOME
8−ANDROID_SDK_ROOT='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
9−export ANDROID_SDK_ROOT
10−AR='ar'
11−export AR
12−AS='as'
13−export AS
14−BASH='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
15−CC='gcc'
16−export CC
17−CLASSPATH=''
18−export CLASSPATH
19−CONFIG_SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
20−export CONFIG_SHELL
21−CXX='g++'
22−export CXX
23−GRADLE_OPTS='-Dorg.gradle.project.android.aapt2FromMavenOverride=/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2'
24−export GRADLE_OPTS
25−HOSTTYPE='x86_64'
26−HOST_PATH='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
27−export HOST_PATH
28−IFS='
29−'
30−IN_NIX_SHELL='impure'
31−export IN_NIX_SHELL
32−JAVA_HOME='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2'
33−export JAVA_HOME
34−LD='ld'
35−export LD
36−LINENO='80'
37−MACHTYPE='x86_64-pc-linux-gnu'
38−NIX_BINTOOLS='/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46'
39−export NIX_BINTOOLS
40−NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
41−export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
42−NIX_BUILD_CORES='4'
43−export NIX_BUILD_CORES
44−NIX_CC='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
45−export NIX_CC
46−NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
47−export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
48−NIX_CFLAGS_COMPILE=' -frandom-seed=n53brq0w7p -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include'
49−export NIX_CFLAGS_COMPILE
50−NIX_ENFORCE_NO_NATIVE='1'
51−export NIX_ENFORCE_NO_NATIVE
52−NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
53−export NIX_HARDENING_ENABLE
54−NIX_LDFLAGS='-rpath /home/iko/opencode-android/outputs/out/lib '
55−export NIX_LDFLAGS
56−NIX_NO_SELF_RPATH='1'
57−NIX_STORE='/nix/store'
58−export NIX_STORE
59−NM='nm'
60−export NM
61−OBJCOPY='objcopy'
62−export OBJCOPY
63−OBJDUMP='objdump'
64−export OBJDUMP
65−OLDPWD=''
66−export OLDPWD
67−OPTERR='1'
68−OSTYPE='linux-gnu'
69−PATH='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/bin:/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0/bin:/nix/store/sq0nrnfwhkc5ljvklnrk5ps4358g4nbj-gcc-15.2.0/bin:/nix/store/521dd0054ifhzmjfmpx9mz0hr7wh7sig-glibc-2.42-67-bin/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46/bin:/nix/store/39lyf5g2iz4jd2r6q4g5whk9vw09zglj-binutils-2.46/bin:/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
70−export PATH
71−PS4='+ '
72−RANLIB='ranlib'
73−export RANLIB
74−READELF='readelf'
75−export READELF
76−SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
77−export SHELL
78−SIZE='size'
79−export SIZE
80−SOURCE_DATE_EPOCH='315532800'
81−export SOURCE_DATE_EPOCH
82−STRINGS='strings'
83−export STRINGS
84−STRIP='strip'
85−export STRIP
86−XDG_DATA_DIRS='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/share'
87−export XDG_DATA_DIRS
88−__structuredAttrs=''
89−export __structuredAttrs
90−_substituteStream_has_warned_replace_deprecation='false'
91−buildInputs='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2 /nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4 /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk'
92−export buildInputs
93−buildPhase='{ echo "------------------------------------------------------------";
94− echo " WARNING: the existence of this path is not guaranteed.";
95− echo " It is an internal implementation detail for pkgs.mkShell.";
96− echo "------------------------------------------------------------";
97− echo;
98− # Record all build inputs as runtime dependencies
99− export;
100−} >> "$out"
101−'
102−export buildPhase
103−builder='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
104−export builder
105−checkPhase='gradleCheckPhase'
106−cmakeFlags=''
107−export cmakeFlags
108−configureFlags=''
109−export configureFlags
110−defaultBuildInputs=''
111−defaultNativeBuildInputs='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2 /nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
112−depsBuildBuild=''
113−export depsBuildBuild
114−depsBuildBuildPropagated=''
115−export depsBuildBuildPropagated
116−depsBuildTarget=''
117−export depsBuildTarget
118−depsBuildTargetPropagated=''
119−export depsBuildTargetPropagated
120−depsHostHost=''
121−export depsHostHost
122−depsHostHostPropagated=''
123−export depsHostHostPropagated
124−depsTargetTarget=''
125−export depsTargetTarget
126−depsTargetTargetPropagated=''
127−export depsTargetTargetPropagated
128−doCheck=''
129−export doCheck
130−doInstallCheck=''
131−export doInstallCheck
132−dontAddDisableDepTrack='1'
133−export dontAddDisableDepTrack
134−declare -a envBuildBuildHooks=()
135−declare -a envBuildHostHooks=()
136−declare -a envBuildTargetHooks=()
137−declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
138−declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
139−declare -a envTargetTargetHooks=('addPkgToClassPath' )
140−declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
141−initialPath='/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11 /nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0 /nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12 /nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9 /nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12 /nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0 /nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35 /nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14 /nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin /nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1 /nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9 /nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8 /nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin /nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47'
142−mesonFlags=''
143−export mesonFlags
144−name='nix-shell-env'
145−export name
146−nativeBuildInputs=''
147−export nativeBuildInputs
148−out='/home/iko/opencode-android/outputs/out'
149−export out
150−outputBin='out'
151−outputDev='out'
152−outputDevdoc='REMOVE'
153−outputDevman='out'
154−outputDoc='out'
155−outputInclude='out'
156−outputInfo='out'
157−outputLib='out'
158−outputMan='out'
159−outputs='out'
160−export outputs
161−patches=''
162−export patches
163−phases='buildPhase'
164−export phases
165−pkg='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
166−declare -a pkgsBuildBuild=()
167−declare -a pkgsBuildHost=('/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2' '/nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0' '/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46' )
168−declare -a pkgsBuildTarget=()
169−declare -a pkgsHostHost=()
170−declare -a pkgsHostTarget=('/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2' '/nix/store/2dsv152lhz5rxl24fkb6sis3q165swv6-set-java-classpath-hook' '/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4' '/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk' )
171−declare -a pkgsTargetTarget=()
172−declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
173−declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
174−declare -a preConfigureHooks=('mitmCacheConfigureHook' '_multioutConfig' 'gradleConfigureHook' )
175−preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
176−declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
177−preferLocalBuild='1'
178−export preferLocalBuild
179−prefix='/home/iko/opencode-android/outputs/out'
180−declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
181−propagatedBuildInputs=''
182−export propagatedBuildInputs
183−declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
184−propagatedNativeBuildInputs=''
185−export propagatedNativeBuildInputs
186−declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
187−shell='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
188−export shell
189−shellHook='echo "opencode-android dev shell"
190−echo " JDK: $(java -version 2>&1 | head -1)"
191−echo " ANDROID_HOME: $ANDROID_HOME"
192−echo " aapt2: /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2"
193−echo ""
194−echo "Build: ./gradlew assembleDebug"
195−echo "Device: adb devices"
196−'
197−export shellHook
198−stdenv='/nix/store/xknwpg5yjxxsffmwdmpys269543wpdy5-stdenv-linux'
199−export stdenv
200−strictDeps=''
201−export strictDeps
202−system='x86_64-linux'
203−export system
204−declare -a unpackCmdHooks=('_defaultUnpack' )
205−_activatePkgs ()
206−{
207−
208− local hostOffset targetOffset;
209− local pkg;
210− for hostOffset in "${allPlatOffsets[@]}";
211− do
212− local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
213− for targetOffset in "${allPlatOffsets[@]}";
214− do
215− (( hostOffset <= targetOffset )) || continue;
216− local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
217− local pkgsSlice="${!pkgsRef}[@]";
218− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
219− do
220− activatePackage "$pkg" "$hostOffset" "$targetOffset";
221− done;
222− done;
223− done
224−}
225−_addRpathPrefix ()
226−{
227−
228− if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
229− export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
230− fi
231−}
232−_addToEnv ()
233−{
234−
235− local depHostOffset depTargetOffset;
236− local pkg;
237− for depHostOffset in "${allPlatOffsets[@]}";
238− do
239− local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
240− local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
241− for depTargetOffset in "${allPlatOffsets[@]}";
242− do
243− (( depHostOffset <= depTargetOffset )) || continue;
244− local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
245− if [[ -z "${strictDeps-}" ]]; then
246− local visitedPkgs="";
247− for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
248− do
249− if [[ "$visitedPkgs" = *"$pkg"* ]]; then
250− continue;
251− fi;
252− runHook "${!hookRef}" "$pkg";
253− visitedPkgs+=" $pkg";
254− done;
255− else
256− local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
257− local pkgsSlice="${!pkgsRef}[@]";
258− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
259− do
260− runHook "${!hookRef}" "$pkg";
261− done;
262− fi;
263− done;
264− done
265−}
266−_allFlags ()
267−{
268−
269− export system pname name version;
270− while IFS='' read -r varName; do
271− nixTalkativeLog "@${varName}@ -> ${!varName}";
272− args+=("--subst-var" "$varName");
273− done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
274−}
275−_assignFirst ()
276−{
277−
278− local varName="$1";
279− local _var;
280− local REMOVE=REMOVE;
281− shift;
282− for _var in "$@";
283− do
284− if [ -n "${!_var-}" ]; then
285− eval "${varName}"="${_var}";
286− return;
287− fi;
288− done;
289− echo;
290− echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
291− echo " The following variables were all unset or empty:";
292− echo " $*";
293− if [ -z "${out:-}" ]; then
294− echo ' If you do not want an "out" output in your derivation, make sure to define';
295− echo ' the other specific required outputs. This can be achieved by picking one';
296− echo " of the above as an output.";
297− echo ' You do not have to remove "out" if you want to have a different default';
298− echo ' output, because the first output is taken as a default.';
299− echo;
300− fi;
301− return 1
302−}
303−_callImplicitHook ()
304−{
305−
306− local def="$1";
307− local hookName="$2";
308− if declare -F "$hookName" > /dev/null; then
309− nixTalkativeLog "calling implicit '$hookName' function hook";
310− "$hookName";
311− else
312− if type -p "$hookName" > /dev/null; then
313− nixTalkativeLog "sourcing implicit '$hookName' script hook";
314− source "$hookName";
315− else
316− if [ -n "${!hookName:-}" ]; then
317− nixTalkativeLog "evaling implicit '$hookName' string hook";
318− eval "${!hookName}";
319− else
320− return "$def";
321− fi;
322− fi;
323− fi
324−}
325−_defaultUnpack ()
326−{
327−
328− local fn="$1";
329− local destination;
330− if [ -d "$fn" ]; then
331− destination="$(stripHash "$fn")";
332− if [ -e "$destination" ]; then
333− echo "Cannot copy $fn to $destination: destination already exists!";
334− echo "Did you specify two \"srcs\" with the same \"name\"?";
335− return 1;
336− fi;
337− cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
338− else
339− case "$fn" in
340− *.tar.xz | *.tar.lzma | *.txz)
341− ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
342− true ) | tar xf - --mode=+w --warning=no-timestamp
343− ;;
344− *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
345− tar xf "$fn" --mode=+w --warning=no-timestamp
346− ;;
347− *)
348− return 1
349− ;;
350− esac;
351− fi
352−}
353−_doStrip ()
354−{
355−
356− local -ra flags=(dontStripHost dontStripTarget);
357− local -ra debugDirs=(stripDebugList stripDebugListTarget);
358− local -ra allDirs=(stripAllList stripAllListTarget);
359− local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
360− local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
361− stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
362− stripDebugListTarget=${stripDebugListTarget[*]:-};
363− stripAllList=${stripAllList[*]:-};
364− stripAllListTarget=${stripAllListTarget[*]:-};
365− local i;
366− for i in ${!stripCmds[@]};
367− do
368− local -n flag="${flags[$i]}";
369− local -n debugDirList="${debugDirs[$i]}";
370− local -n allDirList="${allDirs[$i]}";
371− local -n stripCmd="${stripCmds[$i]}";
372− local -n ranlibCmd="${ranlibCmds[$i]}";
373− if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
374− continue;
375− fi;
376− stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
377− stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
378− done
379−}
380−_eval ()
381−{
382−
383− if declare -F "$1" > /dev/null 2>&1; then
384− "$@";
385− else
386− eval "$1";
387− fi
388−}
389−_logHook ()
390−{
391−
392− if [[ -z ${NIX_LOG_FD-} ]]; then
393− return;
394− fi;
395− local hookKind="$1";
396− local hookExpr="$2";
397− shift 2;
398− if declare -F "$hookExpr" > /dev/null 2>&1; then
399− nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
400− else
401− if type -p "$hookExpr" > /dev/null; then
402− nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
403− else
404− if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
405− local exprToOutput;
406− if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
407− exprToOutput="$hookExpr";
408− else
409− local hookExprLine;
410− while IFS= read -r hookExprLine; do
411− hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
412− if [[ -n "$hookExprLine" ]]; then
413− exprToOutput+="$hookExprLine\\n ";
414− fi;
415− done <<< "$hookExpr";
416− exprToOutput="${exprToOutput%%\\n }";
417− fi;
418− nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
419− fi;
420− fi;
421− fi
422−}
423−_makeSymlinksRelative ()
424−{
425−
426− local prefixes;
427− prefixes=();
428− for output in $(getAllOutputNames);
429− do
430− [ ! -e "${!output}" ] && continue;
431− prefixes+=("${!output}");
432− done;
433− find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
434− output="$1"
435− link="$2"
436−
437− linkTarget=$(readlink "$link")
438−
439− # only touch links that point inside the same output tree
440− [[ $linkTarget == "$output"/* ]] || exit 0
441−
442− if [ ! -e "$linkTarget" ]; then
443− echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
444− fi
445−
446− echo "making symlink relative: $link"
447− ln -snrf "$linkTarget" "$link"
448− ' _
449−}
450−_moveLib64 ()
451−{
452−
453− if [ "${dontMoveLib64-}" = 1 ]; then
454− return;
455− fi;
456− if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
457− return;
458− fi;
459− echo "moving $prefix/lib64/* to $prefix/lib";
460− mkdir -p $prefix/lib;
461− shopt -s dotglob;
462− for i in $prefix/lib64/*;
463− do
464− mv --no-clobber "$i" $prefix/lib;
465− done;
466− shopt -u dotglob;
467− rmdir $prefix/lib64;
468− ln -s lib $prefix/lib64
469−}
470−_moveSbin ()
471−{
472−
473− if [ "${dontMoveSbin-}" = 1 ]; then
474− return;
475− fi;
476− if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
477− return;
478− fi;
479− echo "moving $prefix/sbin/* to $prefix/bin";
480− mkdir -p $prefix/bin;
481− shopt -s dotglob;
482− for i in $prefix/sbin/*;
483− do
484− mv "$i" $prefix/bin;
485− done;
486− shopt -u dotglob;
487− rmdir $prefix/sbin;
488− ln -s bin $prefix/sbin
489−}
490−_moveSystemdUserUnits ()
491−{
492−
493− if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
494− return;
495− fi;
496− if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
497− return;
498− fi;
499− local source="$prefix/lib/systemd/user";
500− local target="$prefix/share/systemd/user";
501− echo "moving $source/* to $target";
502− mkdir -p "$target";
503− ( shopt -s dotglob;
504− for i in "$source"/*;
505− do
506− mv "$i" "$target";
507− done );
508− rmdir "$source";
509− ln -s "$target" "$source"
510−}
511−_moveToShare ()
512−{
513−
514− if [ -n "$__structuredAttrs" ]; then
515− if [ -z "${forceShare-}" ]; then
516− forceShare=(man doc info);
517− fi;
518− else
519− forceShare=(${forceShare:-man doc info});
520− fi;
521− if [[ -z "$out" ]]; then
522− return;
523− fi;
524− for d in "${forceShare[@]}";
525− do
526− if [ -d "$out/$d" ]; then
527− if [ -d "$out/share/$d" ]; then
528− echo "both $d/ and share/$d/ exist!";
529− else
530− echo "moving $out/$d to $out/share/$d";
531− mkdir -p $out/share;
532− mv $out/$d $out/share/;
533− fi;
534− fi;
535− done
536−}
537−_multioutConfig ()
538−{
539−
540− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
541− return;
542− fi;
543− if [ -z "${shareDocName:-}" ]; then
544− local confScript="${configureScript:-}";
545− if [ -z "$confScript" ] && [ -x ./configure ]; then
546− confScript=./configure;
547− fi;
548− if [ -f "$confScript" ]; then
549− local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
550− fi;
551− if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
552− shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
553− fi;
554− fi;
555− prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
556− prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
557−}
558−_multioutDevs ()
559−{
560−
561− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
562− return;
563− fi;
564− moveToOutput include "${!outputInclude}";
565− moveToOutput lib/pkgconfig "${!outputDev}";
566− moveToOutput share/pkgconfig "${!outputDev}";
567− moveToOutput lib/cmake "${!outputDev}";
568− moveToOutput share/aclocal "${!outputDev}";
569− for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
570− do
571− echo "Patching '$f' includedir to output ${!outputInclude}";
572− sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
573− done
574−}
575−_multioutDocs ()
576−{
577−
578− local REMOVE=REMOVE;
579− moveToOutput share/info "${!outputInfo}";
580− moveToOutput share/doc "${!outputDoc}";
581− moveToOutput share/gtk-doc "${!outputDevdoc}";
582− moveToOutput share/devhelp/books "${!outputDevdoc}";
583− moveToOutput share/man "${!outputMan}";
584− moveToOutput share/man/man3 "${!outputDevman}"
585−}
586−_multioutPropagateDev ()
587−{
588−
589− if [ "$(getAllOutputNames)" = "out" ]; then
590− return;
591− fi;
592− local outputFirst;
593− for outputFirst in $(getAllOutputNames);
594− do
595− break;
596− done;
597− local propagaterOutput="$outputDev";
598− if [ -z "$propagaterOutput" ]; then
599− propagaterOutput="$outputFirst";
600− fi;
601− if [ -z "${propagatedBuildOutputs+1}" ]; then
602− local po_dirty="$outputBin $outputInclude $outputLib";
603− set +o pipefail;
604− propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
605− set -o pipefail;
606− fi;
607− if [ -z "$propagatedBuildOutputs" ]; then
608− return;
609− fi;
610− mkdir -p "${!propagaterOutput}"/nix-support;
611− for output in $propagatedBuildOutputs;
612− do
613− echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
614− done
615−}
616−_nixLogWithLevel ()
617−{
618−
619− [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
620− local logLevel;
621− case "${1:?}" in
622− 0)
623− logLevel=ERROR
624− ;;
625− 1)
626− logLevel=WARN
627− ;;
628− 2)
629− logLevel=NOTICE
630− ;;
631− 3)
632− logLevel=INFO
633− ;;
634− 4)
635− logLevel=TALKATIVE
636− ;;
637− 5)
638− logLevel=CHATTY
639− ;;
640− 6)
641− logLevel=DEBUG
642− ;;
643− 7)
644− logLevel=VOMIT
645− ;;
646− *)
647− echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
648− return 1
649− ;;
650− esac;
651− local callerName="${FUNCNAME[2]}";
652− if [[ $callerName == "_callImplicitHook" ]]; then
653− callerName="${hookName:?}";
654− fi;
655− printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
656−}
657−_overrideFirst ()
658−{
659−
660− if [ -z "${!1-}" ]; then
661− _assignFirst "$@";
662− fi
663−}
664−_pruneLibtoolFiles ()
665−{
666−
667− if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
668− return;
669− fi;
670− find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
671−}
672−_updateSourceDateEpochFromSourceRoot ()
673−{
674−
675− if [ -n "$sourceRoot" ]; then
676− updateSourceDateEpoch "$sourceRoot";
677− fi
678−}
679−activatePackage ()
680−{
681−
682− local pkg="$1";
683− local -r hostOffset="$2";
684− local -r targetOffset="$3";
685− (( hostOffset <= targetOffset )) || exit 1;
686− if [ -f "$pkg" ]; then
687− nixTalkativeLog "sourcing setup hook '$pkg'";
688− source "$pkg";
689− fi;
690− if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
691− addToSearchPath _PATH "$pkg/bin";
692− fi;
693− if (( hostOffset <= -1 )); then
694− addToSearchPath _XDG_DATA_DIRS "$pkg/share";
695− fi;
696− if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
697− addToSearchPath _HOST_PATH "$pkg/bin";
698− fi;
699− if [[ -f "$pkg/nix-support/setup-hook" ]]; then
700− nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
701− source "$pkg/nix-support/setup-hook";
702− fi
703−}
704−addEnvHooks ()
705−{
706−
707− local depHostOffset="$1";
708− shift;
709− local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
710− local pkgHookVar;
711− for pkgHookVar in "${!pkgHookVarsSlice}";
712− do
713− eval "${pkgHookVar}s"'+=("$@")';
714− done
715−}
716−addPkgToClassPath ()
717−{
718−
719− local jar;
720− for jar in $1/share/java/*.jar;
721− do
722− export CLASSPATH=''${CLASSPATH-}''${CLASSPATH:+:}''${jar};
723− done
724−}
725−addToSearchPath ()
726−{
727−
728− addToSearchPathWithCustomDelimiter ":" "$@"
729−}
730−addToSearchPathWithCustomDelimiter ()
731−{
732−
733− local delimiter="$1";
734− local varName="$2";
735− local dir="$3";
736− if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
737− export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
738− fi
739−}
740−appendToVar ()
741−{
742−
743− local -n nameref="$1";
744− local useArray type;
745− if [ -n "$__structuredAttrs" ]; then
746− useArray=true;
747− else
748− useArray=false;
749− fi;
750− if type=$(declare -p "$1" 2> /dev/null); then
751− case "${type#* }" in
752− -A*)
753− echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
754− return 1
755− ;;
756− -a*)
757− useArray=true
758− ;;
759− *)
760− useArray=false
761− ;;
762− esac;
763− fi;
764− shift;
765− if $useArray; then
766− nameref=(${nameref+"${nameref[@]}"} "$@");
767− else
768− nameref="${nameref-} $*";
769− fi
770−}
771−auditTmpdir ()
772−{
773−
774− local dir="$1";
775− [ -e "$dir" ] || return 0;
776− echo "checking for references to $TMPDIR/ in $dir...";
777− local tmpdir elf_fifo script_fifo;
778− tmpdir="$(mktemp -d)";
779− elf_fifo="$tmpdir/elf";
780− script_fifo="$tmpdir/script";
781− mkfifo "$elf_fifo" "$script_fifo";
782− ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
783− if isELF "$file"; then
784− printf '%s\0' "$file" 1>&3;
785− else
786− if isScript "$file"; then
787− filename=${file##*/};
788− dir=${file%/*};
789− if [ -e "$dir/.$filename-wrapped" ]; then
790− printf '%s\0' "$file" 1>&4;
791− fi;
792− fi;
793− fi;
794− done;
795− exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
796− if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
797− echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
798− exit 1
799− fi
800− ' _ < "$elf_fifo" ) & local pid_elf=$!;
801− local pid_script;
802− ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
803− if grep -q -F "$TMPDIR/" "$1"; then
804− echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
805− exit 1
806− fi
807− ' _ < "$script_fifo" ) & local pid_script=$!;
808− wait "$pid_elf" || {
809− echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
810− exit 1
811− };
812− wait "$pid_script" || {
813− echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
814− exit 1
815− };
816− rm -r "$tmpdir"
817−}
818−bintoolsWrapper_addLDVars ()
819−{
820−
821− local role_post;
822− getHostRoleEnvHook;
823− if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
824− export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
825− fi;
826− if [[ -d "$1/lib" ]]; then
827− local -a glob=($1/lib/lib*);
828− if [ "${#glob[*]}" -gt 0 ]; then
829− export NIX_LDFLAGS${role_post}+=" -L$1/lib";
830− fi;
831− fi
832−}
833−buildPhase ()
834−{
835−
836− runHook preBuild;
837− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
838− echo "no Makefile or custom buildPhase, doing nothing";
839− else
840− foundMakefile=1;
841− local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
842− concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
843− echoCmd 'build flags' "${flagsArray[@]}";
844− make ${makefile:+-f $makefile} "${flagsArray[@]}";
845− unset flagsArray;
846− fi;
847− runHook postBuild
848−}
849−ccWrapper_addCVars ()
850−{
851−
852− local role_post;
853− getHostRoleEnvHook;
854− local found=;
855− if [ -d "$1/include" ]; then
856− export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
857− found=1;
858− fi;
859− if [ -d "$1/Library/Frameworks" ]; then
860− export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
861− found=1;
862− fi;
863− if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
864− local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
865− export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
866− fi
867−}
868−checkPhase ()
869−{
870−
871− runHook preCheck;
872− if [[ -z "${foundMakefile:-}" ]]; then
873− echo "no Makefile or custom checkPhase, doing nothing";
874− runHook postCheck;
875− return;
876− fi;
877− if [[ -z "${checkTarget:-}" ]]; then
878− if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
879− checkTarget="check";
880− else
881− if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
882− checkTarget="test";
883− fi;
884− fi;
885− fi;
886− if [[ -z "${checkTarget:-}" ]]; then
887− echo "no check/test target in ${makefile:-Makefile}, doing nothing";
888− else
889− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
890− concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
891− echoCmd 'check flags' "${flagsArray[@]}";
892− make ${makefile:+-f $makefile} "${flagsArray[@]}";
893− unset flagsArray;
894− fi;
895− runHook postCheck
896−}
897−compressManPages ()
898−{
899−
900− local dir="$1";
901− if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
902− return;
903− fi;
904− echo "gzipping man pages under $dir/share/man/";
905− find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
906− find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
907− local target;
908− target="$(readlink -f "$f")";
909− if [ -f "$target".gz ]; then
910− ln -sf "$target".gz "$f".gz && rm "$f";
911− fi;
912− done
913−}
914−concatStringsSep ()
915−{
916−
917− local sep="$1";
918− local name="$2";
919− local type oldifs;
920− if type=$(declare -p "$name" 2> /dev/null); then
921− local -n nameref="$name";
922− case "${type#* }" in
923− -A*)
924− echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
925− return 1
926− ;;
927− -a*)
928− local IFS="$(printf '\036')"
929− ;;
930− *)
931− local IFS=" "
932− ;;
933− esac;
934− local ifs_separated="${nameref[*]}";
935− echo -n "${ifs_separated//"$IFS"/"$sep"}";
936− fi
937−}
938−concatTo ()
939−{
940−
941− local -;
942− set -o noglob;
943− local -n targetref="$1";
944− shift;
945− local arg default name type;
946− for arg in "$@";
947− do
948− IFS="=" read -r name default <<< "$arg";
949− local -n nameref="$name";
950− if [[ -z "${nameref[*]}" && -n "$default" ]]; then
951− targetref+=("$default");
952− else
953− if type=$(declare -p "$name" 2> /dev/null); then
954− case "${type#* }" in
955− -A*)
956− echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
957− return 1
958− ;;
959− -a*)
960− targetref+=("${nameref[@]}")
961− ;;
962− *)
963− if [[ "$name" = *"Array" ]]; then
964− nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
965− targetref+=(${nameref+"${nameref[@]}"});
966− else
967− targetref+=(${nameref-});
968− fi
969− ;;
970− esac;
971− fi;
972− fi;
973− done
974−}
975−configurePhase ()
976−{
977−
978− runHook preConfigure;
979− : "${configureScript=}";
980− if [[ -z "$configureScript" && -x ./configure ]]; then
981− configureScript=./configure;
982− fi;
983− if [ -z "${dontFixLibtool:-}" ]; then
984− export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
985− local i;
986− find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
987− echo "fixing libtool script $i";
988− fixLibtool "$i";
989− done;
990− CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
991− find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
992− rm -f "$CONFIGURE_MTIME_REFERENCE";
993− fi;
994− if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
995− local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
996− if [ "${prefixKeyOrDefault: -1}" = " " ]; then
997− prependToVar configureFlags "$prefix";
998− prependToVar configureFlags "${prefixKeyOrDefault::-1}";
999− else
1000− prependToVar configureFlags "$prefixKeyOrDefault$prefix";
1001− fi;
1002− fi;
1003− if [[ -f "$configureScript" ]]; then
1004− if [ -z "${dontAddDisableDepTrack:-}" ]; then
1005− if grep -q dependency-tracking "$configureScript"; then
1006− prependToVar configureFlags --disable-dependency-tracking;
1007− fi;
1008− fi;
1009− if [ -z "${dontDisableStatic:-}" ]; then
1010− if grep -q enable-static "$configureScript"; then
1011− prependToVar configureFlags --disable-static;
1012− fi;
1013− fi;
1014− if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
1015− patchShebangs --build "$configureScript";
1016− fi;
1017− fi;
1018− if [ -n "$configureScript" ]; then
1019− local -a flagsArray;
1020− concatTo flagsArray configureFlags configureFlagsArray;
1021− echoCmd 'configure flags' "${flagsArray[@]}";
1022− $configureScript "${flagsArray[@]}";
1023− unset flagsArray;
1024− else
1025− echo "no configure script, doing nothing";
1026− fi;
1027− runHook postConfigure
1028−}
1029−consumeEntire ()
1030−{
1031−
1032− if IFS='' read -r -d '' "$1"; then
1033− echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
1034− return 1;
1035− fi
1036−}
1037−definePhases ()
1038−{
1039−
1040− if [ -z "${phases[*]:-}" ]; then
1041− phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
1042− fi
1043−}
1044−distPhase ()
1045−{
1046−
1047− runHook preDist;
1048− local flagsArray=();
1049− concatTo flagsArray distFlags distFlagsArray distTarget=dist;
1050− echo 'dist flags: %q' "${flagsArray[@]}";
1051− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1052− if [ "${dontCopyDist:-0}" != 1 ]; then
1053− mkdir -p "$out/tarballs";
1054− cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
1055− fi;
1056− runHook postDist
1057−}
1058−dumpVars ()
1059−{
1060−
1061− if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
1062− local old_umask;
1063− old_umask=$(umask);
1064− umask 0077;
1065− export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
1066− umask "$old_umask";
1067− fi
1068−}
1069−echoCmd ()
1070−{
1071−
1072− printf "%s:" "$1";
1073− shift;
1074− printf ' %q' "$@";
1075− echo
1076−}
1077−exitHandler ()
1078−{
1079−
1080− exitCode="$?";
1081− set +e;
1082− if [ -n "${showBuildStats:-}" ]; then
1083− read -r -d '' -a buildTimes < <(times);
1084− echo "build times:";
1085− echo "user time for the shell ${buildTimes[0]}";
1086− echo "system time for the shell ${buildTimes[1]}";
1087− echo "user time for all child processes ${buildTimes[2]}";
1088− echo "system time for all child processes ${buildTimes[3]}";
1089− fi;
1090− if (( "$exitCode" != 0 )); then
1091− runHook failureHook;
1092− if [ -n "${succeedOnFailure:-}" ]; then
1093− echo "build failed with exit code $exitCode (ignored)";
1094− mkdir -p "$out/nix-support";
1095− printf "%s" "$exitCode" > "$out/nix-support/failed";
1096− exit 0;
1097− fi;
1098− else
1099− runHook exitHook;
1100− fi;
1101− return "$exitCode"
1102−}
1103−findInputs ()
1104−{
1105−
1106− local -r pkg="$1";
1107− local -r hostOffset="$2";
1108− local -r targetOffset="$3";
1109− (( hostOffset <= targetOffset )) || exit 1;
1110− local varVar="${pkgAccumVarVars[hostOffset + 1]}";
1111− local varRef="$varVar[$((targetOffset - hostOffset))]";
1112− local var="${!varRef}";
1113− unset -v varVar varRef;
1114− local varSlice="$var[*]";
1115− case " ${!varSlice-} " in
1116− *" $pkg "*)
1117− return 0
1118− ;;
1119− esac;
1120− unset -v varSlice;
1121− eval "$var"'+=("$pkg")';
1122− if ! [ -e "$pkg" ]; then
1123− echo "build input $pkg does not exist" 1>&2;
1124− exit 1;
1125− fi;
1126− function mapOffset ()
1127− {
1128− local -r inputOffset="$1";
1129− local -n outputOffset="$2";
1130− if (( inputOffset <= 0 )); then
1131− outputOffset=$((inputOffset + hostOffset));
1132− else
1133− outputOffset=$((inputOffset - 1 + targetOffset));
1134− fi
1135− };
1136− local relHostOffset;
1137− for relHostOffset in "${allPlatOffsets[@]}";
1138− do
1139− local files="${propagatedDepFilesVars[relHostOffset + 1]}";
1140− local hostOffsetNext;
1141− mapOffset "$relHostOffset" hostOffsetNext;
1142− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1143− local relTargetOffset;
1144− for relTargetOffset in "${allPlatOffsets[@]}";
1145− do
1146− (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
1147− local fileRef="${files}[$relTargetOffset - $relHostOffset]";
1148− local file="${!fileRef}";
1149− unset -v fileRef;
1150− local targetOffsetNext;
1151− mapOffset "$relTargetOffset" targetOffsetNext;
1152− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1153− [[ -f "$pkg/nix-support/$file" ]] || continue;
1154− local pkgNext;
1155− read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
1156− for pkgNext in $pkgNext;
1157− do
1158− findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
1159− done;
1160− done;
1161− done
1162−}
1163−fixLibtool ()
1164−{
1165−
1166− local search_path;
1167− for flag in $NIX_LDFLAGS;
1168− do
1169− case $flag in
1170− -L*)
1171− search_path+=" ${flag#-L}"
1172− ;;
1173− esac;
1174− done;
1175− sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
1176−}
1177−fixupPhase ()
1178−{
1179−
1180− local output;
1181− for output in $(getAllOutputNames);
1182− do
1183− if [ -e "${!output}" ]; then
1184− chmod -R u+w,u-s,g-s "${!output}";
1185− fi;
1186− done;
1187− runHook preFixup;
1188− local output;
1189− for output in $(getAllOutputNames);
1190− do
1191− prefix="${!output}" runHook fixupOutput;
1192− done;
1193− recordPropagatedDependencies;
1194− if [ -n "${setupHook:-}" ]; then
1195− mkdir -p "${!outputDev}/nix-support";
1196− substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
1197− fi;
1198− if [ -n "${setupHooks:-}" ]; then
1199− mkdir -p "${!outputDev}/nix-support";
1200− local hook;
1201− for hook in ${setupHooks[@]};
1202− do
1203− local content;
1204− consumeEntire content < "$hook";
1205− substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
1206− unset -v content;
1207− done;
1208− unset -v hook;
1209− fi;
1210− if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
1211− mkdir -p "${!outputBin}/nix-support";
1212− printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
1213− fi;
1214− runHook postFixup
1215−}
1216−genericBuild ()
1217−{
1218−
1219− export GZIP_NO_TIMESTAMPS=1;
1220− if [ -f "${buildCommandPath:-}" ]; then
1221− source "$buildCommandPath";
1222− return;
1223− fi;
1224− if [ -n "${buildCommand:-}" ]; then
1225− eval "$buildCommand";
1226− return;
1227− fi;
1228− definePhases;
1229− for curPhase in ${phases[*]};
1230− do
1231− runPhase "$curPhase";
1232− done
1233−}
1234−getAllOutputNames ()
1235−{
1236−
1237− if [ -n "$__structuredAttrs" ]; then
1238− echo "${!outputs[*]}";
1239− else
1240− echo "$outputs";
1241− fi
1242−}
1243−getHostRole ()
1244−{
1245−
1246− getRole "$hostOffset"
1247−}
1248−getHostRoleEnvHook ()
1249−{
1250−
1251− getRole "$depHostOffset"
1252−}
1253−getRole ()
1254−{
1255−
1256− case $1 in
1257− -1)
1258− role_post='_FOR_BUILD'
1259− ;;
1260− 0)
1261− role_post=''
1262− ;;
1263− 1)
1264− role_post='_FOR_TARGET'
1265− ;;
1266− *)
1267− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1268− return 1
1269− ;;
1270− esac
1271−}
1272−getTargetRole ()
1273−{
1274−
1275− getRole "$targetOffset"
1276−}
1277−getTargetRoleEnvHook ()
1278−{
1279−
1280− getRole "$depTargetOffset"
1281−}
1282−getTargetRoleWrapper ()
1283−{
1284−
1285− case $targetOffset in
1286− -1)
1287− export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
1288− ;;
1289− 0)
1290− export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
1291− ;;
1292− 1)
1293− export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
1294− ;;
1295− *)
1296− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1297− return 1
1298− ;;
1299− esac
1300−}
1301−gradle ()
1302−{
1303−
1304− local flagsArray=();
1305− concatTo flagsArray gradleFlags gradleFlagsArray;
1306− command gradle "${flagsArray[@]}" "$@"
1307−}
1308−gradleBuildPhase ()
1309−{
1310−
1311− runHook preBuild;
1312− gradle ${enableParallelBuilding:+--parallel} ${gradleBuildTask:-assemble};
1313− runHook postBuild
1314−}
1315−gradleCheckPhase ()
1316−{
1317−
1318− runHook preCheck;
1319− gradle ${enableParallelChecking:+--parallel} ${gradleCheckTask:-test};
1320− runHook postCheck
1321−}
1322−gradleConfigureHook ()
1323−{
1324−
1325− if [ -z "${GRADLE_USER_HOME-}" ]; then
1326− GRADLE_USER_HOME="$(mktemp -d)";
1327− fi;
1328− export GRADLE_USER_HOME;
1329− export TERM=dumb;
1330− gradleFlagsArray+=(--no-daemon -Dorg.gradle.console=plain --init-script "${gradleInitScript:-/nix/store/qyybvyq09wv36br9lfd8jac92fgwy8f2-init-build.gradle}");
1331− if [ -n "${MITM_CACHE_CA-}" ]; then
1332− if [ -z "${MITM_CACHE_KEYSTORE-}" ]; then
1333− MITM_CACHE_KEYSTORE="$MITM_CACHE_CERT_DIR/keystore";
1334− MITM_CACHE_KS_PWD="$(head -c10 /dev/random | base32)";
1335− echo y | /nix/store/vs2j17dr25ph3h1k4w1ngxnn8y5br1sa-openjdk-21.0.12+2/bin/keytool -importcert -file "$MITM_CACHE_CA" -alias alias -keystore "$MITM_CACHE_KEYSTORE" -storepass "$MITM_CACHE_KS_PWD";
1336− fi;
1337− gradleFlagsArray+=(-Dhttp.proxyHost="$MITM_CACHE_HOST" -Dhttp.proxyPort="$MITM_CACHE_PORT");
1338− gradleFlagsArray+=(-Dhttps.proxyHost="$MITM_CACHE_HOST" -Dhttps.proxyPort="$MITM_CACHE_PORT");
1339− gradleFlagsArray+=(-Djavax.net.ssl.trustStore="$MITM_CACHE_KEYSTORE" -Djavax.net.ssl.trustStorePassword="$MITM_CACHE_KS_PWD");
1340− else
1341− gradleFlagsArray+=(--offline);
1342− fi;
1343− if ! [[ -v enableParallelBuilding ]]; then
1344− enableParallelBuilding=1;
1345− fi;
1346− if ! [[ -v enableParallelChecking ]]; then
1347− enableParallelChecking=1;
1348− fi;
1349− if ! [[ -v enableParallelUpdating ]]; then
1350− enableParallelUpdating=1;
1351− fi
1352−}
1353−gradleUpdateScript ()
1354−{
1355−
1356− runHook preBuild;
1357− runHook preGradleUpdate;
1358− gradle ${enableParallelUpdating:+--parallel} ${gradleUpdateTask:-nixDownloadDeps};
1359− runHook postGradleUpdate
1360−}
1361−installCheckPhase ()
1362−{
1363−
1364− runHook preInstallCheck;
1365− if [[ -z "${foundMakefile:-}" ]]; then
1366− echo "no Makefile or custom installCheckPhase, doing nothing";
1367− else
1368− if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
1369− echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
1370− else
1371− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1372− concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
1373− echoCmd 'installcheck flags' "${flagsArray[@]}";
1374− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1375− unset flagsArray;
1376− fi;
1377− fi;
1378− runHook postInstallCheck
1379−}
1380−installPhase ()
1381−{
1382−
1383− runHook preInstall;
1384− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
1385− echo "no Makefile or custom installPhase, doing nothing";
1386− runHook postInstall;
1387− return;
1388− else
1389− foundMakefile=1;
1390− fi;
1391− if [ -n "$prefix" ]; then
1392− mkdir -p "$prefix";
1393− fi;
1394− local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1395− concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
1396− echoCmd 'install flags' "${flagsArray[@]}";
1397− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1398− unset flagsArray;
1399− runHook postInstall
1400−}
1401−isELF ()
1402−{
1403−
1404− local fn="$1";
1405− local fd;
1406− local magic;
1407− exec {fd}< "$fn";
1408− LANG=C read -r -n 4 -u "$fd" magic;
1409− exec {fd}>&-;
1410− if [ "$magic" = 'ELF' ]; then
1411− return 0;
1412− else
1413− return 1;
1414− fi
1415−}
1416−isMachO ()
1417−{
1418−
1419− local fn="$1";
1420− local fd;
1421− local magic;
1422− exec {fd}< "$fn";
1423− LANG=C read -r -n 4 -u "$fd" magic;
1424− exec {fd}>&-;
1425− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
1426− return 0;
1427− else
1428− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
1429− return 0;
1430− else
1431− if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
1432− return 0;
1433− else
1434− return 1;
1435− fi;
1436− fi;
1437− fi
1438−}
1439−isScript ()
1440−{
1441−
1442− local fn="$1";
1443− local fd;
1444− local magic;
1445− exec {fd}< "$fn";
1446− LANG=C read -r -n 2 -u "$fd" magic;
1447− exec {fd}>&-;
1448− if [[ "$magic" =~ \#! ]]; then
1449− return 0;
1450− else
1451− return 1;
1452− fi
1453−}
1454−mapOffset ()
1455−{
1456−
1457− local -r inputOffset="$1";
1458− local -n outputOffset="$2";
1459− if (( inputOffset <= 0 )); then
1460− outputOffset=$((inputOffset + hostOffset));
1461− else
1462− outputOffset=$((inputOffset - 1 + targetOffset));
1463− fi
1464−}
1465−mitmCacheConfigureHook ()
1466−{
1467−
1468− if [ -d "$mitmCache" ] && [ -z "$MITM_CACHE_CERT_DIR" ]; then
1469− MITM_CACHE_CERT_DIR="$(mktemp -d)";
1470− pushd "$MITM_CACHE_CERT_DIR";
1471− MITM_CACHE_CA="$MITM_CACHE_CERT_DIR/ca.cer";
1472− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl genrsa -out ca.key 2048;
1473− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 -out ca.cer -subj "/C=AL/ST=a/L=a/O=a/OU=a/CN=example.org";
1474− MITM_CACHE_HOST="127.0.0.1";
1475− MITM_CACHE_PORT="${mitmCachePort:-$(/nix/store/ibdb2kp2gkqqbicwxp663s39sd5824zg-python3.13-ephemeral-port-reserve-1.1.4/bin/ephemeral-port-reserve "$MITM_CACHE_HOST")}";
1476− MITM_CACHE_ADDRESS="$MITM_CACHE_HOST:$MITM_CACHE_PORT";
1477− export http_proxy="$MITM_CACHE_ADDRESS";
1478− export https_proxy="$MITM_CACHE_ADDRESS";
1479− export SSL_CERT_FILE="$MITM_CACHE_CA";
1480− export NIX_SSL_CERT_FILE="$MITM_CACHE_CA";
1481− mitm-cache -l"$MITM_CACHE_ADDRESS" replay "$mitmCache" > /dev/null 2> /dev/null & popd;
1482− fi
1483−}
1484−moveToOutput ()
1485−{
1486−
1487− local patt="$1";
1488− local dstOut="$2";
1489− local output;
1490− for output in $(getAllOutputNames);
1491− do
1492− if [ "${!output}" = "$dstOut" ]; then
1493− continue;
1494− fi;
1495− local srcPath;
1496− for srcPath in "${!output}"/$patt;
1497− do
1498− if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
1499− continue;
1500− fi;
1501− if [ "$dstOut" = REMOVE ]; then
1502− echo "Removing $srcPath";
1503− rm -r "$srcPath";
1504− else
1505− local dstPath="$dstOut${srcPath#${!output}}";
1506− echo "Moving $srcPath to $dstPath";
1507− if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
1508− rmdir "$srcPath" --ignore-fail-on-non-empty;
1509− if [ -d "$srcPath" ]; then
1510− mv -t "$dstPath" "$srcPath"/*;
1511− rmdir "$srcPath";
1512− fi;
1513− else
1514− mkdir -p "$(readlink -m "$dstPath/..")";
1515− mv "$srcPath" "$dstPath";
1516− fi;
1517− fi;
1518− local srcParent="$(readlink -m "$srcPath/..")";
1519− if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
1520− echo "Removing empty $srcParent/ and (possibly) its parents";
1521− rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
1522− fi;
1523− done;
1524− done
1525−}
1526−nixChattyLog ()
1527−{
1528−
1529− _nixLogWithLevel 5 "$*"
1530−}
1531−nixDebugLog ()
1532−{
1533−
1534− _nixLogWithLevel 6 "$*"
1535−}
1536−nixErrorLog ()
1537−{
1538−
1539− _nixLogWithLevel 0 "$*"
1540−}
1541−nixInfoLog ()
1542−{
1543−
1544− _nixLogWithLevel 3 "$*"
1545−}
1546−nixLog ()
1547−{
1548−
1549− [[ -z ${NIX_LOG_FD-} ]] && return 0;
1550− local callerName="${FUNCNAME[1]}";
1551− if [[ $callerName == "_callImplicitHook" ]]; then
1552− callerName="${hookName:?}";
1553− fi;
1554− printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
1555−}
1556−nixNoticeLog ()
1557−{
1558−
1559− _nixLogWithLevel 2 "$*"
1560−}
1561−nixTalkativeLog ()
1562−{
1563−
1564− _nixLogWithLevel 4 "$*"
1565−}
1566−nixVomitLog ()
1567−{
1568−
1569− _nixLogWithLevel 7 "$*"
1570−}
1571−nixWarnLog ()
1572−{
1573−
1574− _nixLogWithLevel 1 "$*"
1575−}
1576−noBrokenSymlinks ()
1577−{
1578−
1579− local -r output="${1:?}";
1580− local path;
1581− local pathParent;
1582− local symlinkTarget;
1583− local -i numDanglingSymlinks=0;
1584− local -i numReflexiveSymlinks=0;
1585− local -i numUnreadableSymlinks=0;
1586− if [[ ! -e $output ]]; then
1587− nixWarnLog "skipping non-existent output $output";
1588− return 0;
1589− fi;
1590− nixInfoLog "running on $output";
1591− while IFS= read -r -d '' path; do
1592− pathParent="$(dirname "$path")";
1593− if ! symlinkTarget="$(readlink "$path")"; then
1594− nixErrorLog "the symlink $path is unreadable";
1595− numUnreadableSymlinks+=1;
1596− continue;
1597− fi;
1598− if [[ $symlinkTarget == /* ]]; then
1599− nixInfoLog "symlink $path points to absolute target $symlinkTarget";
1600− else
1601− nixInfoLog "symlink $path points to relative target $symlinkTarget";
1602− symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
1603− fi;
1604− if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
1605− nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
1606− numDanglingSymlinks+=1;
1607− continue;
1608− fi;
1609− if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
1610− nixInfoLog "symlink $path points outside the Nix store; ignoring";
1611− continue;
1612− fi;
1613− if [[ $path == "$symlinkTarget" ]]; then
1614− nixErrorLog "the symlink $path is reflexive";
1615− numReflexiveSymlinks+=1;
1616− else
1617− if [[ ! -e $symlinkTarget ]]; then
1618− nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
1619− numDanglingSymlinks+=1;
1620− else
1621− nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
1622− fi;
1623− fi;
1624− done < <(find "$output" -type l -print0);
1625− if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
1626− nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
1627− exit 1;
1628− fi;
1629− return 0
1630−}
1631−noBrokenSymlinksInAllOutputs ()
1632−{
1633−
1634− if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
1635− for output in $(getAllOutputNames);
1636− do
1637− noBrokenSymlinks "${!output}";
1638− done;
1639− fi
1640−}
1641−patchELF ()
1642−{
1643−
1644− local dir="$1";
1645− [ -e "$dir" ] || return 0;
1646− echo "shrinking RPATHs of ELF executables and libraries in $dir";
1647− local i;
1648− while IFS= read -r -d '' i; do
1649− if [[ "$i" =~ .build-id ]]; then
1650− continue;
1651− fi;
1652− if ! isELF "$i"; then
1653− continue;
1654− fi;
1655− echo "shrinking $i";
1656− patchelf --shrink-rpath "$i" || true;
1657− done < <(find "$dir" -type f -print0)
1658−}
1659−patchPhase ()
1660−{
1661−
1662− runHook prePatch;
1663− local -a patchesArray;
1664− concatTo patchesArray patches;
1665− local -a flagsArray;
1666− concatTo flagsArray patchFlags=-p1;
1667− for i in "${patchesArray[@]}";
1668− do
1669− echo "applying patch $i";
1670− local uncompress=cat;
1671− case "$i" in
1672− *.gz)
1673− uncompress="gzip -d"
1674− ;;
1675− *.bz2)
1676− uncompress="bzip2 -d"
1677− ;;
1678− *.xz)
1679− uncompress="xz -d"
1680− ;;
1681− *.lzma)
1682− uncompress="lzma -d"
1683− ;;
1684− esac;
1685− $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
1686− done;
1687− runHook postPatch
1688−}
1689−patchShebangs ()
1690−{
1691−
1692− local pathName;
1693− local update=false;
1694− while [[ $# -gt 0 ]]; do
1695− case "$1" in
1696− --host)
1697− pathName=HOST_PATH;
1698− shift
1699− ;;
1700− --build)
1701− pathName=PATH;
1702− shift
1703− ;;
1704− --update)
1705− update=true;
1706− shift
1707− ;;
1708− --)
1709− shift;
1710− break
1711− ;;
1712− -* | --*)
1713− echo "Unknown option $1 supplied to patchShebangs" 1>&2;
1714− return 1
1715− ;;
1716− *)
1717− break
1718− ;;
1719− esac;
1720− done;
1721− echo "patching script interpreter paths in $@";
1722− local f;
1723− local oldPath;
1724− local newPath;
1725− local arg0;
1726− local args;
1727− local oldInterpreterLine;
1728− local newInterpreterLine;
1729− if [[ $# -eq 0 ]]; then
1730− echo "No arguments supplied to patchShebangs" 1>&2;
1731− return 0;
1732− fi;
1733− local f;
1734− while IFS= read -r -d '' f; do
1735− isScript "$f" || continue;
1736− read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
1737− read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
1738− if [[ -z "${pathName:-}" ]]; then
1739− if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
1740− pathName=HOST_PATH;
1741− else
1742− pathName=PATH;
1743− fi;
1744− fi;
1745− if [[ "$oldPath" == *"/bin/env" ]]; then
1746− if [[ $arg0 == "-S" ]]; then
1747− arg0=${args%% *};
1748− [[ "$args" == *" "* ]] && args=${args#* } || args=;
1749− newPath="$(PATH="${!pathName}" type -P "env" || true)";
1750− args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
1751− else
1752− if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
1753− echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
1754− exit 1;
1755− else
1756− newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
1757− fi;
1758− fi;
1759− else
1760− if [[ -z $oldPath ]]; then
1761− oldPath="/bin/sh";
1762− fi;
1763− newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
1764− args="$arg0 $args";
1765− fi;
1766− newInterpreterLine="$newPath $args";
1767− newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
1768− if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
1769− if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
1770− echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
1771− escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
1772− timestamp=$(stat --printf "%y" "$f");
1773− tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
1774− sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
1775− local restoreReadOnly;
1776− if [[ ! -w "$f" ]]; then
1777− chmod +w "$f";
1778− restoreReadOnly=true;
1779− fi;
1780− cat "$tmpFile" > "$f";
1781− rm "$tmpFile";
1782− if [[ -n "${restoreReadOnly:-}" ]]; then
1783− chmod -w "$f";
1784− fi;
1785− touch --date "$timestamp" "$f";
1786− fi;
1787− fi;
1788− done < <(find "$@" -type f -perm -0100 -print0)
1789−}
1790−patchShebangsAuto ()
1791−{
1792−
1793− if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
1794− if [[ "$output" != out && "$output" = "$outputDev" ]]; then
1795− patchShebangs --build "$prefix";
1796− else
1797− patchShebangs --host "$prefix";
1798− fi;
1799− fi
1800−}
1801−prependToVar ()
1802−{
1803−
1804− local -n nameref="$1";
1805− local useArray type;
1806− if [ -n "$__structuredAttrs" ]; then
1807− useArray=true;
1808− else
1809− useArray=false;
1810− fi;
1811− if type=$(declare -p "$1" 2> /dev/null); then
1812− case "${type#* }" in
1813− -A*)
1814− echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
1815− return 1
1816− ;;
1817− -a*)
1818− useArray=true
1819− ;;
1820− *)
1821− useArray=false
1822− ;;
1823− esac;
1824− fi;
1825− shift;
1826− if $useArray; then
1827− nameref=("$@" ${nameref+"${nameref[@]}"});
1828− else
1829− nameref="$* ${nameref-}";
1830− fi
1831−}
1832−printLines ()
1833−{
1834−
1835− (( "$#" > 0 )) || return 0;
1836− printf '%s\n' "$@"
1837−}
1838−printPhases ()
1839−{
1840−
1841− definePhases;
1842− local phase;
1843− for phase in ${phases[*]};
1844− do
1845− printf '%s\n' "$phase";
1846− done
1847−}
1848−printWords ()
1849−{
1850−
1851− (( "$#" > 0 )) || return 0;
1852− printf '%s ' "$@"
1853−}
1854−recordPropagatedDependencies ()
1855−{
1856−
1857− declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
1858− declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
1859− local propagatedInputsIndex;
1860− for propagatedInputsIndex in "${!flatVars[@]}";
1861− do
1862− local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
1863− local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
1864− [[ -n "${!propagatedInputsSlice}" ]] || continue;
1865− mkdir -p "${!outputDev}/nix-support";
1866− printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
1867− done
1868−}
1869−runHook ()
1870−{
1871−
1872− local hookName="$1";
1873− shift;
1874− local hooksSlice="${hookName%Hook}Hooks[@]";
1875− local hook;
1876− for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1877− do
1878− _logHook "$hookName" "$hook" "$@";
1879− _eval "$hook" "$@";
1880− done;
1881− return 0
1882−}
1883−runOneHook ()
1884−{
1885−
1886− local hookName="$1";
1887− shift;
1888− local hooksSlice="${hookName%Hook}Hooks[@]";
1889− local hook ret=1;
1890− for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1891− do
1892− _logHook "$hookName" "$hook" "$@";
1893− if _eval "$hook" "$@"; then
1894− ret=0;
1895− break;
1896− fi;
1897− done;
1898− return "$ret"
1899−}
1900−runPhase ()
1901−{
1902−
1903− local curPhase="$*";
1904− if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
1905− return;
1906− fi;
1907− if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
1908− return;
1909− fi;
1910− if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
1911− return;
1912− fi;
1913− if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
1914− return;
1915− fi;
1916− if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
1917− return;
1918− fi;
1919− if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
1920− return;
1921− fi;
1922− if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
1923− return;
1924− fi;
1925− if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
1926− return;
1927− fi;
1928− if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
1929− return;
1930− fi;
1931− showPhaseHeader "$curPhase";
1932− dumpVars;
1933− local startTime endTime;
1934− startTime=$(date +"%s");
1935− eval "${!curPhase:-$curPhase}";
1936− endTime=$(date +"%s");
1937− showPhaseFooter "$curPhase" "$startTime" "$endTime";
1938− if [ "$curPhase" = unpackPhase ]; then
1939− [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
1940− cd -- "${sourceRoot:-.}";
1941− fi
1942−}
1943−showPhaseFooter ()
1944−{
1945−
1946− local phase="$1";
1947− local startTime="$2";
1948− local endTime="$3";
1949− local delta=$(( endTime - startTime ));
1950− (( delta < 30 )) && return;
1951− local H=$((delta/3600));
1952− local M=$((delta%3600/60));
1953− local S=$((delta%60));
1954− echo -n "$phase completed in ";
1955− (( H > 0 )) && echo -n "$H hours ";
1956− (( M > 0 )) && echo -n "$M minutes ";
1957− echo "$S seconds"
1958−}
1959−showPhaseHeader ()
1960−{
1961−
1962− local phase="$1";
1963− echo "Running phase: $phase";
1964− if [[ -z ${NIX_LOG_FD-} ]]; then
1965− return;
1966− fi;
1967− printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
1968−}
1969−stripDirs ()
1970−{
1971−
1972− local cmd="$1";
1973− local ranlibCmd="$2";
1974− local paths="$3";
1975− local stripFlags="$4";
1976− local excludeFlags=();
1977− local pathsNew=;
1978− [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
1979− [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
1980− local pattern;
1981− if [ -n "${stripExclude:-}" ]; then
1982− for pattern in "${stripExclude[@]}";
1983− do
1984− excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
1985− done;
1986− fi;
1987− local p;
1988− for p in ${paths};
1989− do
1990− if [ -e "$prefix/$p" ]; then
1991− pathsNew="${pathsNew} $prefix/$p";
1992− fi;
1993− done;
1994− paths=${pathsNew};
1995− if [ -n "${paths}" ]; then
1996− echo "stripping (with command $cmd and flags $stripFlags) in $paths";
1997− local striperr;
1998− striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
1999− find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
2000− [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
2001− rm "$striperr";
2002− find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
2003− fi
2004−}
2005−stripHash ()
2006−{
2007−
2008− local strippedName casematchOpt=0;
2009− strippedName="$(basename -- "$1")";
2010− shopt -q nocasematch && casematchOpt=1;
2011− shopt -u nocasematch;
2012− if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
2013− echo "${strippedName:33}";
2014− else
2015− echo "$strippedName";
2016− fi;
2017− if (( casematchOpt )); then
2018− shopt -s nocasematch;
2019− fi
2020−}
2021−substitute ()
2022−{
2023−
2024− local input="$1";
2025− local output="$2";
2026− shift 2;
2027− if [ ! -f "$input" ]; then
2028− echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
2029− return 1;
2030− fi;
2031− local content;
2032− consumeEntire content < "$input";
2033− if [ -e "$output" ]; then
2034− chmod +w "$output";
2035− fi;
2036− substituteStream content "file '$input'" "$@" > "$output"
2037−}
2038−substituteAll ()
2039−{
2040−
2041− local input="$1";
2042− local output="$2";
2043− local -a args=();
2044− _allFlags;
2045− substitute "$input" "$output" "${args[@]}"
2046−}
2047−substituteAllInPlace ()
2048−{
2049−
2050− local fileName="$1";
2051− shift;
2052− substituteAll "$fileName" "$fileName" "$@"
2053−}
2054−substituteAllStream ()
2055−{
2056−
2057− local -a args=();
2058− _allFlags;
2059− substituteStream "$1" "$2" "${args[@]}"
2060−}
2061−substituteInPlace ()
2062−{
2063−
2064− local -a fileNames=();
2065− for arg in "$@";
2066− do
2067− if [[ "$arg" = "--"* ]]; then
2068− break;
2069− fi;
2070− fileNames+=("$arg");
2071− shift;
2072− done;
2073− if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
2074− echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
2075− return 1;
2076− fi;
2077− for file in "${fileNames[@]}";
2078− do
2079− substitute "$file" "$file" "$@";
2080− done
2081−}
2082−substituteStream ()
2083−{
2084−
2085− local var=$1;
2086− local description=$2;
2087− shift 2;
2088− while (( "$#" )); do
2089− local replace_mode="$1";
2090− case "$1" in
2091− --replace)
2092− if ! "$_substituteStream_has_warned_replace_deprecation"; then
2093− echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
2094− _substituteStream_has_warned_replace_deprecation=true;
2095− fi;
2096− replace_mode='--replace-warn'
2097− ;&
2098− --replace-quiet | --replace-warn | --replace-fail)
2099− pattern="$2";
2100− replacement="$3";
2101− shift 3;
2102− if ! [[ "${!var}" == *"$pattern"* ]]; then
2103− if [ "$replace_mode" == --replace-warn ]; then
2104− printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2105− else
2106− if [ "$replace_mode" == --replace-fail ]; then
2107− printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2108− return 1;
2109− fi;
2110− fi;
2111− fi;
2112− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2113− ;;
2114− --subst-var)
2115− local varName="$2";
2116− shift 2;
2117− if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
2118− echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
2119− return 1;
2120− fi;
2121− if [ -z ${!varName+x} ]; then
2122− echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
2123− return 1;
2124− fi;
2125− pattern="@$varName@";
2126− replacement="${!varName}";
2127− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2128− ;;
2129− --subst-var-by)
2130− pattern="@$2@";
2131− replacement="$3";
2132− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
2133− shift 3
2134− ;;
2135− *)
2136− echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
2137− return 1
2138− ;;
2139− esac;
2140− done;
2141− printf "%s" "${!var}"
2142−}
2143−unpackFile ()
2144−{
2145−
2146− curSrc="$1";
2147− echo "unpacking source archive $curSrc";
2148− if ! runOneHook unpackCmd "$curSrc"; then
2149− echo "do not know how to unpack source archive $curSrc";
2150− exit 1;
2151− fi
2152−}
2153−unpackPhase ()
2154−{
2155−
2156− runHook preUnpack;
2157− if [ -z "${srcs:-}" ]; then
2158− if [ -z "${src:-}" ]; then
2159− echo 'variable $src or $srcs should point to the source';
2160− exit 1;
2161− fi;
2162− srcs="$src";
2163− fi;
2164− local -a srcsArray;
2165− concatTo srcsArray srcs;
2166− local dirsBefore="";
2167− for i in *;
2168− do
2169− if [ -d "$i" ]; then
2170− dirsBefore="$dirsBefore $i ";
2171− fi;
2172− done;
2173− for i in "${srcsArray[@]}";
2174− do
2175− unpackFile "$i";
2176− done;
2177− : "${sourceRoot=}";
2178− if [ -n "${setSourceRoot:-}" ]; then
2179− runOneHook setSourceRoot;
2180− else
2181− if [ -z "$sourceRoot" ]; then
2182− for i in *;
2183− do
2184− if [ -d "$i" ]; then
2185− case $dirsBefore in
2186− *\ $i\ *)
2187−
2188− ;;
2189− *)
2190− if [ -n "$sourceRoot" ]; then
2191− echo "unpacker produced multiple directories";
2192− exit 1;
2193− fi;
2194− sourceRoot="$i"
2195− ;;
2196− esac;
2197− fi;
2198− done;
2199− fi;
2200− fi;
2201− if [ -z "$sourceRoot" ]; then
2202− echo "unpacker appears to have produced no directories";
2203− exit 1;
2204− fi;
2205− echo "source root is $sourceRoot";
2206− if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
2207− chmod -R u+w -- "$sourceRoot";
2208− fi;
2209− runHook postUnpack
2210−}
2211−updateAutotoolsGnuConfigScriptsPhase ()
2212−{
2213−
2214− if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
2215− return;
2216− fi;
2217− for script in config.sub config.guess;
2218− do
2219− for f in $(find . -type f -name "$script");
2220− do
2221− echo "Updating Autotools / GNU config script to a newer upstream version: $f";
2222− cp -f "/nix/store/4i7gvqwiq5mbl5pxh43frzq2nibxvwb5-gnu-config-2024-01-01/$script" "$f";
2223− done;
2224− done
2225−}
2226−updateSourceDateEpoch ()
2227−{
2228−
2229− local path="$1";
2230− [[ $path == -* ]] && path="./$path";
2231− local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
2232− local time="${res[0]//\.[0-9]*/}";
2233− local newestFile="${res[1]}";
2234− if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
2235− echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
2236− export SOURCE_DATE_EPOCH="$time";
2237− local now="$(date +%s)";
2238− if [ "$time" -gt $((now - 60)) ]; then
2239− echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
2240− fi;
2241− fi
2242−}
2243−PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
2244−XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
2245−export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
2246−export TMP="$NIX_BUILD_TOP"
2247−export TMPDIR="$NIX_BUILD_TOP"
2248−export TEMP="$NIX_BUILD_TOP"
2249−export TEMPDIR="$NIX_BUILD_TOP"
2250−eval "${shellHook:-}"
.github/workflows/build.yml+18−16
@@ -5,8 +5,26 @@ on:
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+
814jobs:
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+
926 build:
27+ name: Android app
1028 runs-on: ubuntu-latest
1129
1230 steps:
@@ -24,31 +42,15 @@ jobs:
2442 - name: Run unit tests
2543 run: ./gradlew testDebugUnitTest --no-daemon
2644
27− - name: Static analysis (detekt)
28− run: ./gradlew detekt --no-daemon
29−
3045 - name: Lint
3146 run: ./gradlew lintDebug --no-daemon
3247
3348 - name: Build debug APK
3449 run: ./gradlew assembleDebug --no-daemon
3550
36− - name: Build release APK
37− run: ./gradlew assembleRelease --no-daemon
38−
3951 - name: Upload debug APK
4052 uses: actions/upload-artifact@v4
4153 with:
4254 name: opencode-debug.apk
4355 path: app/build/outputs/apk/debug/*.apk
4456 if-no-files-found: error
45−
46− # The release output dir is `release/` when unsigned but `release-signed/` (or a
47− # variant name) once a signing config is applied. Match both so CI keeps working
48− # once the OPENCODE_STORE_* secrets are wired up.
49− - name: Upload release APK
50− uses: actions/upload-artifact@v4
51− with:
52− name: opencode-release.apk
53− path: app/build/outputs/apk/**/*.apk
54− if-no-files-found: error
.github/workflows/release.ymlnew file+113
@@ -0,0 +1,113 @@
1+name: Release
2+
3+# Build a minified, signed release APK and publish it as a GitHub Release.
4+#
5+# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6+# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7+# Actions tab (workflow_dispatch). For a branch push or manual run the version
8+# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9+# tag name (with the leading `v` stripped) as the version.
10+#
11+# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12+# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13+#
14+# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15+# with that keystore; otherwise the Gradle build falls back to the debug key so
16+# the released APK is still installable (see app/build.gradle.kts). The version
17+# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
18+on:
19+ push:
20+ branches: [master]
21+ tags: ["v*"]
22+ workflow_dispatch:
23+
24+# Never abort a release mid-flight — a half-published release with a missing
25+# artifact is worse than a slightly delayed one.
26+concurrency:
27+ group: ${{ github.workflow }}-${{ github.ref }}
28+ cancel-in-progress: false
29+
30+jobs:
31+ release:
32+ runs-on: ubuntu-latest
33+ permissions:
34+ contents: write # push the tag and create the release
35+ id-token: write # OIDC so GitHub can sign the provenance attestation
36+ attestations: write # publish the attestation record
37+ env:
38+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
42+ steps:
43+ - uses: actions/checkout@v4
44+ with:
45+ # Full history so the versionCode (git rev-list --count HEAD) is
46+ # accurate instead of collapsing to 1 on a shallow clone.
47+ fetch-depth: 0
48+
49+ - name: Compute version
50+ id: version
51+ run: |
52+ if [ "${{ github.ref_type }}" = "tag" ]; then
53+ VERSION="${GITHUB_REF_NAME#v}"
54+ TAG="${GITHUB_REF_NAME}"
55+ else
56+ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57+ TAG="v${VERSION}"
58+ fi
59+ CODE="$(git rev-list --count HEAD)"
60+ {
61+ echo "version=$VERSION"
62+ echo "tag=$TAG"
63+ echo "code=$CODE"
64+ } >> "$GITHUB_OUTPUT"
65+ echo "Releasing $TAG (version $VERSION, code $CODE)"
66+
67+ - uses: actions/setup-java@v4
68+ with:
69+ distribution: temurin
70+ java-version: 17
71+
72+ - uses: android-actions/setup-android@v3
73+
74+ - uses: gradle/actions/setup-gradle@v4
75+
76+ # The keystore ships as base64 in a secret so it never touches the repo.
77+ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
78+ - name: Decode signing keystore
79+ if: env.OPENCODE_STORE_BASE64 != ''
80+ run: |
81+ STORE_FILE="$RUNNER_TEMP/release.keystore"
82+ printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83+ echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84+
85+ - name: Build release APK
86+ run: |
87+ ./gradlew assembleRelease \
88+ -PversionName="${{ steps.version.outputs.version }}" \
89+ -PversionCode="${{ steps.version.outputs.code }}" \
90+ --no-daemon
91+
92+ # Rename to a stable, version-tagged filename for the release attachment.
93+ - name: Stage APK
94+ run: |
95+ mkdir -p staging
96+ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97+ cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98+
99+ # Signed build-provenance attestation for the exact artifact being
100+ # published; later verifiable with `gh attestation verify <apk>`.
101+ - name: Attest build provenance
102+ uses: actions/attest-build-provenance@v2
103+ with:
104+ subject-path: staging/*.apk
105+
106+ - name: Create release
107+ uses: softprops/action-gh-release@v2
108+ with:
109+ tag_name: ${{ steps.version.outputs.tag }}
110+ name: ${{ steps.version.outputs.version }}
111+ make_latest: true
112+ generate_release_notes: true
113+ files: staging/*.apk
.gitignore+20
@@ -1,6 +1,8 @@
11# Gradle
22.gradle/
33build/
4+*.apk
5+*.aab
46
57# Kotlin
68.kotlin/
@@ -29,3 +31,21 @@ Thumbs.db
2931
3032# Nix
3133result
34+result-*
35+.direnv/
36+
37+# Local tooling / editor state (not part of the project)
38+.crush/
39+.crush.db
40+*.log
41+
42+# Rust
43+shared/target/
44+/target/
45+
46+# The `generated/` shared type sources ARE committed (regenerable via
47+# `cargo run --features typegen --bin codegen`); only the built JNI .so
48+# artifacts are ignored.
49+generated/jniLibs/
50+
51+# Gradle wrapper jar is committed so CI can run ./gradlew
AGENTS.mddeleted−197
@@ -1,197 +0,0 @@
1−# AGENTS.md
2−
3−Guidance for AI coding agents (and humans) working in this repository.
4−
5−## Project
6−
7−opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8−[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9−remote opencode server over REST plus a long-lived Server-Sent Events stream,
10−and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11−an adaptive single-pane / two-pane Material 3 UI.
12−
13−- Application id / namespace: `soy.iko.opencode`
14−- Package root: `app/src/main/java/soy/iko/opencode`
15−- minSdk 26, compileSdk/targetSdk 35, JDK 17, Kotlin 2.0.x, AGP 8.7.x
16−- Single Gradle module (`:app`); no dynamic feature modules.
17−
18−## Build & environment
19−
20−A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 35, and a
21−Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22−
23−```bash
24−nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26−./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27−```
28−
29−Outside Nix, you need JDK 17 and the Android SDK (platform 35, build-tools
30−35.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31−
32−Install on a connected device/emulator:
33−
34−```bash
35−adb install -r app/build/outputs/apk/debug/app-debug.apk
36−adb shell am start -n soy.iko.opencode/.MainActivity
37−```
38−
39−## Test, lint, and static analysis
40−
41−```bash
42−./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43−./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44−./gradlew lintDebug # Android Lint (abortOnError = true)
45−./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46−```
47−
48−- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49− `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50− that override the `open` members of the real stores/clients). JUnit 4 +
51− `kotlinx-coroutines-test`; no Robolectric — `testOptions.unitTests.isReturnDefaultValues = true`
52− stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53−- Instrumented tests live in `app/src/androidTest/...` and need a device.
54−- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55− findings block CI; existing ones are grandfathered.
56−- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` → `detekt` →
57− `lintDebug` → `assembleDebug` → `assembleRelease`, then uploads both APKs.
58−
59−## Running a server to develop against
60−
61−```bash
62−opencode serve --hostname 0.0.0.0 --port 4096
63−```
64−
65−Connect the app over LAN (`http://<host-ip>:4096`) or USB
66−(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67−(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68−"Set up a server and connect to it" section for full details.
69−
70−## Architecture
71−
72−Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73−
74−```
75−OpencodeApp (Application)
76− └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77− unread tracker, auto-reconnect, pending share / open-session signals)
78− └─ OpencodeConnection (one per active server profile)
79− ├─ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80− ├─ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81− ├─ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82− └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83− └─ MessageStore (in-memory reducer; internal, unit-tested)
84−
85−ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86− └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87− └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88−```
89−
90−Key invariants:
91−
92−- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93− the SSE stream *before* the initial REST load and before any prompt POST, so
94− early streamed parts aren't missed. Preserve this ordering if you touch it.
95−- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96− for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97− discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98− throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99− `UnknownEvent` — the whole point is that a new server release can't break the
100− client. Add new modeled variants alongside the `Unknown` fallback.
101−- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102− per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103−- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104− `Connected` after a prior connection, the repository re-fetches messages from
105− REST and merges (without pruning) under a generation counter so a stale fetch
106− can't clobber a newer one. See `SessionRepository.kt` for the contract.
107−- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108− the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109− eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110− for HTTP profiles the `Authorization` header is set proactively in
111− `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112− installed so credentials can't be re-sent silently on a cleartext 401.
113−- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114− `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115− called on real devices) that cancels the app scope and closes the active
116− connection. `AppContainer.shutdown()` is guarded against double-invocation.
117−
118−## Source map
119−
120−```
121−data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122−data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123− OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124−data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125− EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126− DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127−di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128−notification NotificationChannels, RunForegroundService (holds process priority
129− during a run), SessionNotifications (completion notifications)
130−ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131−ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132− agent/model/command picker sheets, permission dialog, export
133−ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134−ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135−ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136−ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137−util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138−```
139−
140−## Conventions
141−
142−- **No comments unless asked** — existing code has extensive `//` comments
143− explaining *why* (invariants, race conditions, security rationale). When you
144− add code, match that style only for genuinely non-obvious invariants; don't
145− narrate *what* the code does.
146−- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147− inside coroutines — plain `runCatching` swallows `CancellationException` and
148− breaks structured concurrency. Use it for any try/catch in a suspend context.
149−- **`safeExceptionSummary`** for logging Ktor exceptions — `ClientRequestException`
150− messages embed the full request URL (may contain auth/paths); never log the
151− raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152−- **`open` classes with protected no-arg test constructors.** Stores, the API
153− client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154− `open` and have a `protected constructor()` so tests can subclass and override
155− individual members without instantiating Android frameworks. Keep this pattern
156− when adding new injectable components.
157−- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158− sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159− than scattering literals.
160−- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161− `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162− against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163−- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164− `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165− Preserve this when adding mutating endpoints.
166−- **`withRetry`** wraps all REST reads and the permission response. Transient
167− failures back off with jitter; see `NetworkConfig` for the parameters.
168−- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169− 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170− use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171− composables and long parameter lists are accepted (detekt `LongMethod` /
172− `LongParameterList` disabled).
173−- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174− (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175− Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176−- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177− correctly.
178−- **Kotlin code style: `official`** (see `gradle.properties`).
179−
180−## Things to avoid
181−
182−- Don't add Hilt/Dagger/KSP — DI is intentionally hand-written (`AppContainer`).
183−- Don't add Robolectric — JVM tests use `isReturnDefaultValues = true` stubs and
184− fakes; only device-dependent code goes in `androidTest`.
185−- Don't run `nix flake check` manually (CI only — per repo convention).
186−- Don't read or query `/nix/store` (extremely large — per repo convention).
187−- Don't introduce new detekt findings; the baseline only grandfathers existing
188− ones. Run `./gradlew detekt` before considering work done.
189−- Don't log raw Ktor/OkHttp exceptions — use `safeExceptionSummary`.
190−- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191− variants exist for forward compatibility and must remain reachable.
192−
193−## Renovate
194−
195−`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196−Ktor is grouped; AndroidX majors are held for manual review (pinned to
197−compileSdk 35). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mdnew file+40
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+126−64
@@ -1,91 +1,153 @@
11# opencode-android
22
3−A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4+`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5+and **Jetpack Compose**.
46
5−Application id: `soy.iko.opencode`
7+Connect to a running `opencode serve`, browse and create sessions, and chat with
8+the agent — assistant replies (text, reasoning, and tool calls) stream in live
9+over the server's SSE event stream.
610
7−## Set up a server and connect to it
11+<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
812
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.
11−
12−### 1. Start the server
13+## Architecture
1314
14−On the host that has your project and opencode installed:
15+The app follows the Crux pattern: **all application logic lives in a pure,
16+platform-agnostic core; the platform is a thin shell** that renders the core's
17+view model, sends it events, and performs the side effects it requests.
1518
16−```bash
17−opencode serve --hostname 0.0.0.0 --port 4096
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+└───────────────────────────────────────────────────────────────────────┘
1833```
1934
20−Useful flags:
35+### Why a Kotlin port instead of a JNI `.so`?
2136
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) |
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:
2841
29−The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
42+- The **Rust core is the canonical specification** of behaviour and is fully
43+ unit-tested (`cd shared && cargo test`).
44+- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45+ reducer, driving an identical `UiState`/`ViewModel`.
3046
31−### 2. (Optional) Add authentication
47+The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48+can be swapped for the compiled `.so` behind the same seam. The crate already
49+builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50+type-generation.
3251
33−opencode has no auth by default. To protect the server with HTTP Basic auth:
52+## Project layout
3453
35−```bash
36−OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
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
3766```
3867
39−The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
40−app's add-server dialog.
41−
42−### 3. Connect from the app
68+## Getting started
4369
44−**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
70+### Dev shell
4571
46−**Over USB only** — forward the port to the device and point the app at localhost:
72+Everything you need is provided by the Nix flake:
4773
48−```bash
49−adb reverse tcp:4096 tcp:4096
74+```sh
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
5076```
5177
52−Then use base URL `http://127.0.0.1:4096` in the app.
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79+build-tools 35.0.0) on your `PATH`.
5380
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.
81+### Build & test
5682
57−## Architecture
83+```sh
84+# Rust core — logic + streaming unit tests
85+cd shared && cargo test
86+
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89+
90+# Android unit tests + lint
91+./gradlew testDebugUnitTest lintDebug
92+```
93+
94+### Run against a server
95+
96+Start opencode's server on your machine or LAN:
5897
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)
98+```sh
99+opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
83100```
84101
85−## Install & run
102+Install the APK, launch the app, and enter the server URL (e.g.
103+`http://192.168.1.10:4096`). If the server was started with a password the app
104+detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105+
106+> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
107+
108+## The opencode API this client speaks
109+
110+- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111+ HTTP Basic auth.
112+- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113+- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114+- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115+ streams over SSE).
116+- **Cancel** — `POST /session/{id}/abort`.
117+- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118+ `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119+ `session.idle` (clears the "generating" state), `session.error`, and
120+ `session.{created,updated,deleted}` incrementally. Unknown event types are
121+ ignored so new server versions don't break the stream.
122+
123+## Continuous integration & releases
124+
125+- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126+ unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127+ and builds the debug APK (uploaded as an artifact).
128+- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129+ release — it builds a minified, signed release APK and publishes a GitHub
130+ Release with the APK attached and a signed build-provenance attestation.
131+
132+ ```sh
133+ git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134+ ```
135+
136+ The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137+ timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138+
139+ **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140+ keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141+ `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142+ build falls back to the debug key, so the APK is still installable.
143+
144+## Notes & limitations
145+
146+- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147+ **permission** prompts (`permission.updated`) are not yet surfaced.
148+- Model/agent selection uses the server's configured default (no picker yet).
149+- No `.so` is built in this environment (see *Architecture* above).
150+
151+## License
86152
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−```
153+Apache-2.0.
app/build.gradle.kts+44−92
@@ -1,21 +1,21 @@
11plugins {
22 alias(libs.plugins.android.application)
33 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.serialization)
54 alias(libs.plugins.kotlin.compose)
6− alias(libs.plugins.detekt)
5+ alias(libs.plugins.kotlin.serialization)
76}
87
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)
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()
1919
2020android {
2121 namespace = "soy.iko.opencode"
@@ -25,37 +25,35 @@ android {
2525 applicationId = "soy.iko.opencode"
2626 minSdk = 26
2727 targetSdk = 35
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"
28+ versionCode = appVersionCode
29+ versionName = appVersionName
30+
3431 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3532 }
3633
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
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+ }
4742 }
4843 }
4944
5045 buildTypes {
5146 release {
5247 isMinifyEnabled = true
53− isShrinkResources = true
5448 proguardFiles(
5549 getDefaultProguardFile("proguard-android-optimize.txt"),
5650 "proguard-rules.pro",
5751 )
58− releaseSigning?.let { signingConfig = it }
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
5957 }
6058 }
6159
@@ -64,88 +62,42 @@ android {
6462 targetCompatibility = JavaVersion.VERSION_17
6563 }
6664
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− }
65+ kotlinOptions {
66+ jvmTarget = "17"
7367 }
7468
7569 buildFeatures {
7670 compose = true
7771 }
7872
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−
8773 packaging {
8874 resources {
8975 excludes += "/META-INF/{AL2.0,LGPL2.1}"
9076 }
9177 }
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− }
10278}
10379
10480dependencies {
10581 implementation(libs.androidx.core.ktx)
106− implementation(libs.androidx.core.splashscreen)
107− implementation(libs.androidx.activity.compose)
108− implementation(libs.androidx.lifecycle.viewmodel.compose)
82+ implementation(libs.androidx.lifecycle.runtime.ktx)
10983 implementation(libs.androidx.lifecycle.runtime.compose)
110− implementation(libs.androidx.navigation.compose)
111−
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)
84+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
85+ implementation(libs.androidx.lifecycle.viewmodel.compose)
86+ implementation(libs.androidx.activity.compose)
11987
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)
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)
12595
126− implementation(libs.kotlinx.serialization.json)
12796 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)
97+ implementation(libs.kotlinx.serialization.json)
98+ implementation(libs.okhttp)
99+ implementation(libs.okhttp.sse)
134100
135101 testImplementation(libs.junit)
136102 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")
151103}
app/detekt-baseline.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" ?>
2−<SmellBaseline>
3− <ManuallySuppressedIssues/>
4− <CurrentIssues>
5− <ID>ComplexCondition:ChatScreen.kt$event.type == KeyEventType.KeyDown &amp;&amp; event.key == Key.Enter &amp;&amp; !event.isShiftPressed &amp;&amp; enabled &amp;&amp; value.isNotBlank()</ID>
6− <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, )</ID>
8− <ID>InstanceOfCheckForException:OpencodeApiClient.kt$t is ClientRequestException</ID>
9− <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
10− <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
11− <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
12− <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
13− <ID>UnusedParameter:MarkdownText.kt$style: TextStyle = MaterialTheme.typography.bodyLarge</ID>
14− </CurrentIssues>
15−</SmellBaseline>
app/proguard-rules.pro+13−44
@@ -1,47 +1,16 @@
1−# --- kotlinx.serialization ---
2−# The plugin generates serializers; keep them and the $$serializer companion members.
3−-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2+-keepattributes *Annotation*, InnerClasses
43-dontnote kotlinx.serialization.**
5−-keepclassmembers class soy.iko.opencode.** {
6− *** Companion;
7−}
8−-keepclasseswithmembers class soy.iko.opencode.** {
9− kotlinx.serialization.KSerializer serializer(...);
10−}
11−# Keep @Serializable classes themselves so their generated serializers can reference them.
12−-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13−-keepclassmembers class soy.iko.opencode.** {
14− *** Companion;
15−}
16−# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
184
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.**
5+-keepclassmembers class **$$serializer { *; }
6+-keepclasseswithmembers class * {
7+ @kotlinx.serialization.Serializable <methods>;
8+}
9+-if @kotlinx.serialization.Serializable class **
10+-keep, allowobfuscation, allowshrinking class <1>
4411
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.**
12+# OkHttp / okio — platform-specific bits referenced reflectively.
13+-dontwarn okhttp3.internal.platform.**
14+-dontwarn org.conscrypt.**
15+-dontwarn org.bouncycastle.**
16+-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertNotNull
6−import org.junit.Assert.assertTrue
7−import org.junit.Test
8−import org.junit.runner.RunWith
9−
10−/**
11− * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12− * are initialized on startup, and the notification channels are created.
13− */
14−@RunWith(AndroidJUnit4::class)
15−class ApplicationBootstrapTest {
16−
17− @Test
18− fun containerIsInitialized() {
19− val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20− assertNotNull(app.container)
21− assertNotNull(app.container.settingsStore)
22− }
23−
24− @Test
25− fun notificationChannelsExist() {
26− if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27− val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28− // Touching the app instance runs Application.onCreate, which creates the channels.
29− ApplicationProvider.getApplicationContext<OpencodeApp>()
30− val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31− as android.app.NotificationManager
32− val ids = nm.notificationChannels.map { it.id }
33− assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34− assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35− }
36−}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktdeleted−67
@@ -1,67 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertNotNull
7−import org.junit.Assert.assertNull
8−import org.junit.Assert.assertTrue
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−/**
13− * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14− * real app-private filesystem: a simulated crash is written, then read back, listed, and
15− * deleted through the public API.
16− */
17−@RunWith(AndroidJUnit4::class)
18−class CrashLoggerTest {
19−
20− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21− private val logger = CrashLogger.get(context)
22−
23− @Test
24− fun writeReadDeleteRoundTrip() {
25− logger.clearAll()
26−
27− // Simulate an uncaught exception by invoking the report path directly.
28− val report = writeFakeReport()
29−
30− logger.refresh()
31− assertEquals(1, logger.reportCount())
32−
33− val content = logger.readReport(report)
34− assertNotNull(content)
35− assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36− assertTrue("report should contain device metadata", content.contains("Android:"))
37−
38− logger.deleteReport(report)
39− assertEquals(0, logger.reportCount())
40− assertNull(logger.readReport(report))
41− }
42−
43− @Test
44− fun clearAllRemovesEveryReport() {
45− writeFakeReport("a")
46− writeFakeReport("b")
47− logger.refresh()
48− assertTrue(logger.reportCount() >= 2)
49−
50− logger.clearAll()
51− assertEquals(0, logger.reportCount())
52− }
53−
54− private fun writeFakeReport(tag: String = "test"): String {
55− val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56− val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57− java.io.File(dir, name).writeText(
58− """
59− opencode-android crash report
60− Android: test
61− java.lang.IllegalStateException: boom $tag
62− at Crasher.crash(Crasher.kt:1)
63− """.trimIndent(),
64− )
65− return name
66− }
67−}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktdeleted−49
@@ -1,49 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import kotlinx.coroutines.CoroutineScope
6−import kotlinx.coroutines.Dispatchers
7−import kotlinx.coroutines.SupervisorJob
8−import org.junit.Assert.assertEquals
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−@RunWith(AndroidJUnit4::class)
13−class DraftStoreTest {
14−
15− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
16−
17− @Test
18− fun setGetRemoveRoundTrip() {
19− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
20− val store = DraftStore(context, scope)
21−
22− // Use a unique session ID so it doesn't collide with other tests.
23− val sid = "test-${System.nanoTime()}"
24−
25− store.set(sid, "hello world")
26− assertEquals("hello world", store.get(sid))
27−
28− // Overwrite
29− store.set(sid, "updated")
30− assertEquals("updated", store.get(sid))
31−
32− // Blank clears the entry
33− store.set(sid, " ")
34− assertEquals("", store.get(sid))
35−
36− // Explicit remove
37− store.set(sid, "text")
38− store.remove(sid)
39− assertEquals("", store.get(sid))
40−
41− scope.cancel()
42− }
43−
44− @Test
45− fun getReturnsEmptyForUnknownSession() {
46− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
47− assertEquals("", store.get("never-set-${System.nanoTime()}"))
48− }
49−}
app/src/main/AndroidManifest.xml+7−35
@@ -1,54 +1,26 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3− xmlns:tools="http://schemas.android.com/tools">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
43
54 <uses-permission android:name="android.permission.INTERNET" />
65 <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" />
106
117 <application
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"
8+ android:allowBackup="true"
9+ android:icon="@android:drawable/sym_def_app_icon"
1710 android:label="@string/app_name"
18− android:networkSecurityConfig="@xml/network_security_config"
1911 android:supportsRtl="true"
20− android:theme="@style/Theme.OpencodeAndroid">
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
2114
2215 <activity
2316 android:name=".MainActivity"
2417 android:exported="true"
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">
18+ android:windowSoftInputMode="adjustResize"
19+ android:theme="@style/Theme.OpencodeAndroid">
3020 <intent-filter>
3121 <action android:name="android.intent.action.MAIN" />
3222 <category android:name="android.intent.category.LAUNCHER" />
3323 </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>
4624 </activity>
47−
48− <service
49− android:name=".notification.RunForegroundService"
50− android:exported="false"
51− android:foregroundServiceType="dataSync" />
5225 </application>
53−
5426</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import androidx.core.content.edit
6+import androidx.lifecycle.AndroidViewModel
7+import androidx.lifecycle.viewModelScope
8+import kotlinx.coroutines.flow.distinctUntilChangedBy
9+import kotlinx.coroutines.launch
10+import soy.iko.opencode.core.Event
11+import soy.iko.opencode.core.OpencodeCore
12+
13+/**
14+ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15+ * connection) survives configuration changes. It also persists the last server
16+ * URL / username so reconnecting is one tap.
17+ */
18+class CoreViewModel(app: Application) : AndroidViewModel(app) {
19+ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20+
21+ val core = OpencodeCore(viewModelScope)
22+
23+ init {
24+ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25+ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26+ // The password is stored in plain text in SharedPreferences — a deliberate
27+ // convenience trade-off for a local dev tool so reconnecting is one tap.
28+ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29+
30+ // On a fresh process after a configuration change, resume the SSE stream if
31+ // the core is already connected (no-op otherwise — it guards on `connected`).
32+ core.resumeStreamingIfConnected()
33+
34+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35+ // aren't already connected, kick off the connection attempt on launch. The core
36+ // handles an empty/invalid URL, so this only fires when there's something to try.
37+ core.view.value.let { restored ->
38+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39+ core.dispatch(Event.Connect)
40+ }
41+ }
42+
43+ // Persist connection details once a connection succeeds.
44+ viewModelScope.launch {
45+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46+ if (state.connected) {
47+ prefs.edit {
48+ putString(KEY_URL, state.serverUrl)
49+ putString(KEY_USER, state.username)
50+ putString(KEY_PASSWORD, state.password)
51+ }
52+ }
53+ }
54+ }
55+ }
56+
57+ override fun onCleared() {
58+ core.shutdown()
59+ }
60+
61+ private companion object {
62+ const val KEY_URL = "serverUrl"
63+ const val KEY_USER = "username"
64+ const val KEY_PASSWORD = "password"
65+ }
66+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+13−115
@@ -1,133 +1,31 @@
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
73import android.os.Bundle
84import androidx.activity.ComponentActivity
5+import androidx.activity.SystemBarStyle
96import androidx.activity.compose.setContent
107import androidx.activity.enableEdgeToEdge
11−import androidx.activity.result.contract.ActivityResultContracts
12−import androidx.compose.foundation.isSystemInDarkTheme
13−import androidx.compose.runtime.LaunchedEffect
148import 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
199import androidx.lifecycle.compose.collectAsStateWithLifecycle
20−import soy.iko.opencode.data.repo.ThemeMode
21−import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
10+import androidx.lifecycle.viewmodel.compose.viewModel
11+import soy.iko.opencode.ui.OpencodeApp
2212import soy.iko.opencode.ui.theme.OpencodeTheme
2313
2414class 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−
3315 override fun onCreate(savedInstanceState: Bundle?) {
34− val splash = installSplashScreen()
16+ enableEdgeToEdge(
17+ statusBarStyle = SystemBarStyle.auto(
18+ lightScrim = 0xFF0F1115.toInt(),
19+ darkScrim = 0xFF0F1115.toInt(),
20+ ),
21+ )
3522 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 }
4923 setContent {
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
24+ OpencodeTheme {
25+ val vm: CoreViewModel = viewModel()
26+ val state by vm.core.view.collectAsStateWithLifecycle()
27+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
8728 }
8829 }
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)
13230 }
13331}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−59
@@ -1,59 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import coil.ImageLoader
5−import coil.ImageLoaderFactory
6−import coil.disk.DiskCache
7−import coil.memory.MemoryCache
8−import soy.iko.opencode.data.repo.CrashLogger
9−import soy.iko.opencode.di.AppContainer
10−import java.util.concurrent.atomic.AtomicBoolean
11−
12−class OpencodeApp : Application(), ImageLoaderFactory {
13− lateinit var container: AppContainer
14− private set
15−
16− private val shutdownCalled = AtomicBoolean(false)
17−
18− override fun onCreate() {
19− super.onCreate()
20− CrashLogger.get(this).install()
21− container = AppContainer(this)
22− // onTerminate() is only called in emulated process environments, not on real
23− // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
24− // scope cancellation, connection close) runs when the process is actually killed.
25− Runtime.getRuntime().addShutdownHook(Thread {
26− shutdown()
27− })
28− }
29−
30− // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
31− // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
32− // the (auth-protected) network every time. Data-URI (base64) images skip the disk
33− // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
34− override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
35− .memoryCache {
36− MemoryCache.Builder(this)
37− .maxSizePercent(0.25)
38− .build()
39− }
40− .diskCache {
41− DiskCache.Builder()
42− .directory(cacheDir.resolve("image_cache"))
43− .maxSizeBytes(50L * 1024 * 1024)
44− .build()
45− }
46− .build()
47−
48− override fun onTerminate() {
49− super.onTerminate()
50− shutdown()
51− }
52−
53− private fun shutdown() {
54− // Guard against double shutdown — onTerminate and the shutdown hook can both
55− // fire in emulated environments.
56− if (!shutdownCalled.compareAndSet(false, true)) return
57− container.shutdown()
58− }
59−}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.Dispatchers
4+import kotlinx.coroutines.suspendCancellableCoroutine
5+import kotlinx.coroutines.withContext
6+import okhttp3.Call
7+import okhttp3.Callback
8+import okhttp3.MediaType.Companion.toMediaType
9+import okhttp3.OkHttpClient
10+import okhttp3.Request
11+import okhttp3.RequestBody.Companion.toRequestBody
12+import okhttp3.Response
13+import java.io.IOException
14+import java.util.concurrent.TimeUnit
15+import kotlin.coroutines.resume
16+import kotlin.coroutines.resumeWithException
17+
18+/** A completed HTTP response (any status code — non-2xx is not an error here). */
19+data class HttpResp(val code: Int, val body: String)
20+
21+/**
22+ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23+ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24+ * carrying its status code, matching the Rust core's `HttpResult` handling.
25+ */
26+class HttpClient(
27+ val okhttp: OkHttpClient = OkHttpClient.Builder()
28+ .connectTimeout(15, TimeUnit.SECONDS)
29+ .readTimeout(30, TimeUnit.SECONDS)
30+ .build(),
31+) {
32+ private val jsonMedia = "application/json".toMediaType()
33+
34+ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35+ exec(url, auth) { get() }
36+
37+ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38+ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39+
40+ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41+ exec(url, auth) { delete() }
42+
43+ // Build and run the request off the main thread. Building it — parsing [url]
44+ // in particular — can throw (e.g. a URL with no scheme), so that happens
45+ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46+ // instead of crashing the caller's coroutine.
47+ private suspend fun exec(
48+ url: String,
49+ auth: String?,
50+ method: Request.Builder.() -> Request.Builder,
51+ ): Result<HttpResp> = withContext(Dispatchers.IO) {
52+ runCatching {
53+ val builder = Request.Builder().url(url).method()
54+ builder.header("User-Agent", "opencode-android/1.0")
55+ if (auth != null) builder.header("Authorization", auth)
56+ await(okhttp.newCall(builder.build()))
57+ }
58+ }
59+
60+ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61+ cont.invokeOnCancellation { runCatching { call.cancel() } }
62+ call.enqueue(object : Callback {
63+ override fun onFailure(call: Call, e: IOException) {
64+ if (cont.isActive) cont.resumeWithException(e)
65+ }
66+
67+ override fun onResponse(call: Call, response: Response) {
68+ response.use {
69+ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70+ if (cont.isActive) cont.resume(HttpResp(it.code, body))
71+ }
72+ }
73+ })
74+ }
75+}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktnew file+668
@@ -0,0 +1,668 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
5+import kotlinx.coroutines.delay
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import java.util.Base64
11+
12+/**
13+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14+ *
15+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16+ * observable [view]. Effects are executed here in the shell: REST calls through
17+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18+ * server-sent event is fed back in and applied incrementally, so assistant
19+ * replies stream in token-by-token.
20+ *
21+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22+ * network I/O hops to a background thread inside the clients.
23+ */
24+class OpencodeCore(
25+ private val scope: CoroutineScope,
26+ private val http: HttpClient = HttpClient(),
27+) {
28+ // ── Model ────────────────────────────────────────────────────────────────
29+ private var serverUrl = ""
30+ private var username = ""
31+ private var password = ""
32+ private var authRequired = false
33+ private var connected = false
34+ private var loading = false
35+ private var error: String? = null
36+ private var screen = Screen.Connect
37+ private val sessions = mutableListOf<SessionView>()
38+ private var currentSessionId: String? = null
39+ private var currentSessionTitle = ""
40+ private val messages = mutableListOf<MsgState>()
41+ private var draft = ""
42+ private var generating = false
43+ private var sseConnected = true
44+ private var localSeq = 0L
45+
46+ private val _view = MutableStateFlow(snapshot())
47+ val view: StateFlow<UiState> = _view.asStateFlow()
48+
49+ private var sse: SseClient? = null
50+ private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
53+
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
77+
78+ // ── Event entry point ──────────────────────────────────────────────────────
79+ fun dispatch(event: Event) {
80+ when (event) {
81+ // Store the field verbatim while typing; normalizing here would fight
82+ // the user's edits (e.g. eat the "://" as they type it). The URL is
83+ // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89+
90+ Event.Connect -> {
91+ serverUrl = normalizeUrl(serverUrl)
92+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
98+ loading = true; error = null; emit()
99+ connectJob?.cancel()
100+ connectJob = scope.launch { probeHealth() }
101+ }
102+
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
107+ loadSessionsJob = scope.launch { loadSessions() }
108+ }
109+
110+ is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
116+ currentSessionId = event.id
117+ messages.clear()
118+ removedMessageIds.clear()
119+ screen = Screen.Chat
120+ loading = true
121+ generating = false
122+ error = null
123+ emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
130+ }
131+
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
136+ createSessionJob = scope.launch { createSession() }
137+ }
138+
139+ is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
141+ val id = event.id
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
150+ sessions.removeAll { it.id == id }
151+ if (isCurrent(id)) {
152+ currentSessionId = null
153+ messages.clear()
154+ screen = Screen.Sessions
155+ }
156+ } else {
157+ error = "Failed to delete session"
158+ }
159+ loading = false
160+ },
161+ onFailure = {
162+ error = "Failed to delete session"
163+ loading = false
164+ },
165+ )
166+ emit()
167+ }
168+ }
169+
170+ is Event.DraftChanged -> { draft = event.text; emit() }
171+
172+ is Event.SendMessage -> sendMessage(event.text)
173+
174+ Event.CancelGeneration -> {
175+ generating = false
176+ messages.forEach { it.streaming = false }
177+ emit()
178+ val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
181+ abortJob?.cancel()
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183+ }
184+
185+ Event.NavigateToSessions -> {
186+ screen = Screen.Sessions
187+ currentSessionId = null
188+ messages.clear()
189+ removedMessageIds.clear()
190+ draft = ""
191+ generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
195+ loading = true
196+ emit()
197+ connectJob?.cancel()
198+ abortJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
204+ loadSessionsJob = scope.launch { loadSessions() }
205+ }
206+
207+ Event.NavigateToConnect -> {
208+ screen = Screen.Connect
209+ connected = false
210+ loading = false
211+ generating = false
212+ sessions.clear()
213+ messages.clear()
214+ removedMessageIds.clear()
215+ currentSessionId = null
216+ draft = ""
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
219+ sseConnected = true
220+ connectJob?.cancel()
221+ abortJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
226+ closeSse()
227+ emit()
228+ }
229+
230+ Event.DismissError -> { error = null; emit() }
231+ }
232+ }
233+
234+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235+ fun resumeStreamingIfConnected() {
236+ if (connected && sse == null) startSse()
237+ }
238+
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
248+ abortJob?.cancel()
249+ closeSse()
250+ }
251+
252+ // ── Effects ────────────────────────────────────────────────────────────────
253+ private fun sendMessage(raw: String) {
254+ val text = raw.trim()
255+ val session = currentSessionId ?: return
256+ if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
260+ localSeq += 1
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262+ status = MessageStatus.Pending
263+ textParts["local"] = text
264+ }
265+ messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
269+ draft = ""
270+ generating = true
271+ error = null
272+ emit()
273+
274+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
276+ sendMessageJob = scope.launch {
277+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278+ result.fold(
279+ onSuccess = { resp ->
280+ if (resp.code in 200..299) {
281+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282+ } else {
283+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284+ generating = false
285+ messages.forEach { it.streaming = false }
286+ draft = savedDraft
287+ error = "Server returned status ${resp.code}"
288+ }
289+ },
290+ onFailure = { e ->
291+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292+ generating = false
293+ messages.forEach { it.streaming = false }
294+ draft = savedDraft
295+ error = "Request failed: ${e.message}"
296+ },
297+ )
298+ emit()
299+ }
300+ }
301+
302+ private suspend fun probeHealth() {
303+ val hasCreds = hasCredentials()
304+ http.get("$serverUrl/global/health", authHeader()).fold(
305+ onSuccess = { resp ->
306+ when {
307+ resp.code == 401 -> {
308+ loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
312+ connected = false
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
315+ authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
317+ emit()
318+ }
319+ resp.code in 200..299 -> {
320+ connected = true
321+ authRequired = false
322+ error = null
323+ screen = Screen.Sessions
324+ loading = true
325+ emit()
326+ startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
330+ loadSessionsJob = scope.launch { loadSessions() }
331+ }
332+ else -> {
333+ loading = false
334+ connected = false
335+ error = "Server returned status ${resp.code}"
336+ emit()
337+ }
338+ }
339+ },
340+ onFailure = { e ->
341+ loading = false
342+ connected = false
343+ error = "Connection failed: ${e.message}"
344+ emit()
345+ },
346+ )
347+ }
348+
349+ private suspend fun loadSessions() {
350+ http.get("$serverUrl/session", authHeader()).fold(
351+ onSuccess = { resp ->
352+ if (resp.code in 200..299) {
353+ sessions.clear()
354+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
358+ } else {
359+ error = "Server returned status ${resp.code}"
360+ }
361+ },
362+ onFailure = { e -> error = "Request failed: ${e.message}" },
363+ )
364+ loading = false
365+ emit()
366+ }
367+
368+ private suspend fun createSession() {
369+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370+ onSuccess = { resp ->
371+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372+ if (session != null) {
373+ if (sessions.none { it.id == session.id }) {
374+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
379+ }
380+ emit()
381+ // SelectSession takes over the loading state (loads messages).
382+ dispatch(Event.SelectSession(session.id))
383+ return
384+ }
385+ error = "Server returned status ${resp.code}"
386+ },
387+ onFailure = { e -> error = "Request failed: ${e.message}" },
388+ )
389+ loading = false
390+ emit()
391+ }
392+
393+ private suspend fun loadMessages(sessionId: String) {
394+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395+ onSuccess = { resp ->
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397+ loading = false
398+ if (resp.code in 200..299) {
399+ messages.clear()
400+ removedMessageIds.clear()
401+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402+ generating = messages.any { it.streaming }
403+ } else {
404+ error = "Server returned status ${resp.code}"
405+ }
406+ emit()
407+ },
408+ onFailure = { e ->
409+ if (!isCurrent(sessionId)) { loading = false; return }
410+ loading = false
411+ generating = false
412+ error = "Request failed: ${e.message}"
413+ emit()
414+ },
415+ )
416+ }
417+
418+ // ── SSE ─────────────────────────────────────────────────────────────────────
419+ private fun startSse() {
420+ closeSse()
421+ sseClosedIntentionally = false
422+ val client = SseClient(
423+ url = "$serverUrl/event",
424+ auth = authHeader(),
425+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426+ onStateChange = { open ->
427+ scope.launch {
428+ sseConnected = open
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
430+ emit()
431+ }
432+ },
433+ )
434+ sse = client
435+ client.connect()
436+ }
437+
438+ private fun closeSse() {
439+ sseClosedIntentionally = true
440+ reconnectJob?.cancel()
441+ reconnectJob = null
442+ sse?.close()
443+ sse = null
444+ }
445+
446+ private fun scheduleReconnect() {
447+ if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
455+ reconnectJob = scope.launch {
456+ delay(delayMs)
457+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458+ }
459+ }
460+
461+ private fun handleServerEvent(json: String) {
462+ val event = Protocol.parseEvent(json) ?: return
463+ // Only re-render if the event actually changed our state; applyEvent returns
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
466+ }
467+
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
469+ private fun applyEvent(event: Protocol.Event): Boolean {
470+ when (event) {
471+ is Protocol.Event.MessageUpdated -> {
472+ val info = event.info
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
477+ val streaming = info.time.completed == null && info.error == null
478+ val existing = messages.firstOrNull { it.id == info.id }
479+ if (existing != null) {
480+ existing.streaming = streaming
481+ if (existing.time == 0L) existing.time = info.time.created
482+ } else {
483+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484+ }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486+ return true
487+ }
488+
489+ is Protocol.Event.PartUpdated -> {
490+ val part = event.part
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
494+ return when (part) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
496+ false
497+ } else {
498+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499+ true
500+ }
501+ is Protocol.Part.Reasoning -> {
502+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503+ true
504+ }
505+ is Protocol.Part.Tool -> {
506+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507+ true
508+ }
509+ }
510+ }
511+
512+ is Protocol.Event.PartRemoved -> {
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
515+ return true
516+ }
517+
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
521+ return messages.removeAll { it.id == event.messageId }
522+ }
523+
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
526+ generating = false
527+ messages.forEach { it.streaming = false }
528+ return true
529+ }
530+
531+ is Protocol.Event.SessionError -> {
532+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533+ if (!scoped) return false
534+ event.error?.let { error = it.message() }
535+ generating = false
536+ messages.forEach { it.streaming = false }
537+ return true
538+ }
539+
540+ is Protocol.Event.SessionUpserted -> {
541+ val title = displayTitle(event.info.title)
542+ val existing = sessions.firstOrNull { it.id == event.info.id }
543+ if (existing != null) {
544+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545+ } else {
546+ sessions.add(0, SessionView(event.info.id, title))
547+ }
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
551+ if (isCurrent(event.info.id)) currentSessionTitle = title
552+ return true
553+ }
554+
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
557+ val removed = sessions.removeAll { it.id == id }
558+ if (isCurrent(id)) {
559+ currentSessionId = null
560+ messages.clear()
561+ screen = Screen.Sessions
562+ return true
563+ }
564+ return removed
565+ }
566+
567+ Protocol.Event.Other -> return false
568+ }
569+ }
570+
571+ // ── View / helpers ──────────────────────────────────────────────────────────
572+ private fun snapshot() = UiState(
573+ screen = screen,
574+ serverUrl = serverUrl,
575+ username = username,
576+ password = password,
577+ authRequired = authRequired,
578+ connected = connected,
579+ loading = loading,
580+ error = error,
581+ sessions = sessions.toList(),
582+ currentSessionId = currentSessionId,
583+ currentSessionTitle = currentSessionTitle,
584+ messages = messages.map { it.toView() },
585+ draft = draft,
586+ generating = generating,
587+ sseConnected = sseConnected,
588+ )
589+
590+ private fun emit() { _view.value = snapshot() }
591+
592+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594+
595+ private fun authHeader(): String? {
596+ if (!hasCredentials()) return null
597+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598+ return "Basic $encoded"
599+ }
600+}
601+
602+// ── Internal message state (parts tracked by id for streaming) ─────────────────
603+private class MsgState(val id: String, val role: String, var time: Long) {
604+ var status = MessageStatus.Sent
605+ var streaming = false
606+ val textParts = LinkedHashMap<String, String>()
607+ val reasoningParts = LinkedHashMap<String, String>()
608+ val toolParts = LinkedHashMap<String, ToolView>()
609+
610+ fun remove(partId: String) {
611+ textParts.remove(partId)
612+ reasoningParts.remove(partId)
613+ toolParts.remove(partId)
614+ }
615+
616+ fun toView(): MessageView {
617+ val reasoning = reasoningParts.values.joinToString("")
618+ return MessageView(
619+ id = id,
620+ role = role,
621+ text = textParts.values.joinToString(""),
622+ reasoning = reasoning.ifEmpty { null },
623+ tools = toolParts.values.toList(),
624+ time = time,
625+ status = status,
626+ streaming = streaming,
627+ )
628+ }
629+}
630+
631+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632+ val info = env.info
633+ val msg = MsgState(info.id, info.role, info.time.created)
634+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635+ for (raw in env.parts) {
636+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637+ when (val part = Protocol.parsePart(obj)) {
638+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641+ null -> Unit
642+ }
643+ }
644+ return msg
645+}
646+
647+private fun pickText(text: String, delta: String?, prior: String?): String =
648+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649+
650+private fun normalizeUrl(url: String): String {
651+ val trimmed = url.trim().trimEnd('/')
652+ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653+ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654+ return when {
655+ trimmed.isEmpty() -> ""
656+ "://" in trimmed -> trimmed
657+ else -> "http://$trimmed"
658+ }
659+}
660+
661+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662+
663+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664+@kotlinx.serialization.Serializable
665+private data class PromptBody(val parts: List<TextInput>)
666+
667+@kotlinx.serialization.Serializable
668+private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktnew file+148
@@ -0,0 +1,148 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.Json
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+import kotlinx.serialization.json.booleanOrNull
10+import kotlinx.serialization.json.contentOrNull
11+import kotlinx.serialization.json.decodeFromJsonElement
12+import kotlinx.serialization.json.jsonObject
13+
14+/**
15+ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16+ *
17+ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18+ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19+ * catch-all so new server versions never break the stream parser.
20+ */
21+object Protocol {
22+ val json = Json {
23+ ignoreUnknownKeys = true
24+ isLenient = true
25+ coerceInputValues = true
26+ }
27+
28+ // ─── Sessions ────────────────────────────────────────────────────────────
29+
30+ @Serializable
31+ data class WireSession(val id: String, val title: String = "")
32+
33+ fun parseSessions(body: String): List<WireSession> =
34+ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35+
36+ fun parseSession(body: String): WireSession? =
37+ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38+
39+ // ─── Messages ────────────────────────────────────────────────────────────
40+
41+ @Serializable
42+ data class WireTime(val created: Long = 0, val completed: Long? = null)
43+
44+ @Serializable
45+ data class WireError(val name: String = "", val data: JsonElement? = null) {
46+ fun message(): String {
47+ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48+ return when {
49+ !msg.isNullOrEmpty() -> msg
50+ name.isNotEmpty() -> name
51+ else -> "Unknown error"
52+ }
53+ }
54+ }
55+
56+ @Serializable
57+ data class WireMessageInfo(
58+ val id: String,
59+ val role: String = "",
60+ @SerialName("sessionID") val sessionId: String = "",
61+ val time: WireTime = WireTime(),
62+ val error: WireError? = null,
63+ )
64+
65+ @Serializable
66+ data class WireMessageEnvelope(
67+ val info: WireMessageInfo,
68+ val parts: List<JsonElement> = emptyList(),
69+ )
70+
71+ fun parseMessages(body: String): List<WireMessageEnvelope> =
72+ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73+
74+ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75+
76+ sealed interface Part {
77+ val id: String
78+ val messageId: String
79+
80+ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81+ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82+ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83+ }
84+
85+ /** Parse a single part JSON object; returns null for kinds we don't render. */
86+ fun parsePart(obj: JsonObject): Part? {
87+ val id = obj.str("id")
88+ val messageId = obj.str("messageID")
89+ return when (obj.str("type")) {
90+ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91+ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92+ "tool" -> {
93+ val state = obj["state"] as? JsonObject
94+ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95+ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96+ Part.Tool(id, messageId, obj.str("tool"), status, title)
97+ }
98+ else -> null
99+ }
100+ }
101+
102+ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103+
104+ sealed interface Event {
105+ data class MessageUpdated(val info: WireMessageInfo) : Event
106+ data class PartUpdated(val part: Part, val delta: String?) : Event
107+ data class PartRemoved(val messageId: String, val partId: String) : Event
108+ data class MessageRemoved(val messageId: String) : Event
109+ data class SessionIdle(val sessionId: String) : Event
110+ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111+ data class SessionUpserted(val info: WireSession) : Event
112+ data class SessionDeleted(val sessionId: String?) : Event
113+ data object Other : Event
114+ }
115+
116+ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117+ fun parseEvent(raw: String): Event? {
118+ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119+ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120+ return when (root.str("type")) {
121+ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122+ "message.part.updated" -> {
123+ val partObj = props["part"] as? JsonObject ?: return Event.Other
124+ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125+ }
126+ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127+ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128+ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129+ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130+ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131+ "session.deleted" -> Event.SessionDeleted(
132+ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133+ )
134+ else -> Event.Other
135+ }
136+ }
137+
138+ private inline fun <reified T> decode(el: JsonElement?): T? =
139+ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140+
141+ // ─── Small JSON helpers ───────────────────────────────────────────────────
142+
143+ private fun JsonObject.str(key: String): String =
144+ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145+
146+ private fun JsonObject.bool(key: String): Boolean =
147+ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+63
@@ -0,0 +1,63 @@
1+package soy.iko.opencode.core
2+
3+import okhttp3.OkHttpClient
4+import okhttp3.Request
5+import okhttp3.Response
6+import okhttp3.sse.EventSource
7+import okhttp3.sse.EventSourceListener
8+import okhttp3.sse.EventSources
9+import java.util.concurrent.TimeUnit
10+
11+/**
12+ * Long-lived subscription to the opencode `/event` SSE stream.
13+ *
14+ * Crux only models request/response HTTP, so the streaming connection lives here
15+ * in the shell: each decoded event's `data` payload (a JSON object whose own
16+ * `type` field is the discriminant) is handed to [onEvent], which the core then
17+ * parses via [Protocol.parseEvent].
18+ */
19+class SseClient(
20+ private val url: String,
21+ private val auth: String?,
22+ private val onEvent: (String) -> Unit,
23+ private val onStateChange: (Boolean) -> Unit,
24+) {
25+ // A dedicated client with no read timeout — the stream is meant to stay open.
26+ private val client = OkHttpClient.Builder()
27+ .connectTimeout(15, TimeUnit.SECONDS)
28+ .readTimeout(0, TimeUnit.SECONDS)
29+ .retryOnConnectionFailure(true)
30+ .build()
31+
32+ private var source: EventSource? = null
33+
34+ fun connect() {
35+ val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ builder.header("User-Agent", "opencode-android/1.0")
37+ if (auth != null) builder.header("Authorization", auth)
38+ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39+ }
40+
41+ fun close() {
42+ source?.cancel()
43+ source = null
44+ }
45+
46+ private val listener = object : EventSourceListener() {
47+ override fun onOpen(eventSource: EventSource, response: Response) {
48+ onStateChange(true)
49+ }
50+
51+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52+ if (data.isNotBlank()) onEvent(data)
53+ }
54+
55+ override fun onClosed(eventSource: EventSource) {
56+ onStateChange(false)
57+ }
58+
59+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60+ onStateChange(false)
61+ }
62+ }
63+}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5+ * the two implementations stay behaviourally identical.
6+ */
7+
8+enum class Screen { Connect, Sessions, Chat }
9+
10+enum class MessageStatus { Sent, Pending, Failed }
11+
12+data class SessionView(
13+ val id: String,
14+ val title: String,
15+)
16+
17+data class ToolView(
18+ val name: String,
19+ val status: String,
20+ val title: String?,
21+)
22+
23+data class MessageView(
24+ val id: String,
25+ val role: String,
26+ val text: String,
27+ val reasoning: String?,
28+ val tools: List<ToolView>,
29+ val time: Long,
30+ val status: MessageStatus,
31+ val streaming: Boolean,
32+) {
33+ val isUser: Boolean get() = role == "user"
34+}
35+
36+data class UiState(
37+ val screen: Screen = Screen.Connect,
38+ val serverUrl: String = "",
39+ val username: String = "",
40+ val password: String = "",
41+ val authRequired: Boolean = false,
42+ val connected: Boolean = false,
43+ val loading: Boolean = false,
44+ val error: String? = null,
45+ val sessions: List<SessionView> = emptyList(),
46+ val currentSessionId: String? = null,
47+ val currentSessionTitle: String = "",
48+ val messages: List<MessageView> = emptyList(),
49+ val draft: String = "",
50+ val generating: Boolean = false,
51+ val sseConnected: Boolean = true,
52+)
53+
54+/**
55+ * Everything the shell can ask the core to do. Internal HTTP results are not
56+ * events here (unlike the Rust core, which routes them through `then_send`);
57+ * the Kotlin driver awaits them inline in coroutines instead.
58+ */
59+sealed interface Event {
60+ data class ServerUrlChanged(val url: String) : Event
61+ data class UsernameChanged(val value: String) : Event
62+ data class PasswordChanged(val value: String) : Event
63+ data object Connect : Event
64+
65+ data object LoadSessions : Event
66+ data class SelectSession(val id: String) : Event
67+ data object CreateSession : Event
68+ data class DeleteSession(val id: String) : Event
69+
70+ data class DraftChanged(val text: String) : Event
71+ data class SendMessage(val text: String) : Event
72+ data object CancelGeneration : Event
73+
74+ data object NavigateToSessions : Event
75+ data object NavigateToConnect : Event
76+ data object DismissError : Event
77+}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktdeleted−32
@@ -1,32 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /agent`. */
7−@Immutable
8−@Serializable
9−data class Agent(
10− val name: String,
11− val description: String? = null,
12− val mode: String? = null,
13− val builtIn: Boolean = false,
14− val prompt: String? = null,
15−) {
16− val isPrimary: Boolean get() = mode == "primary"
17− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
18−}
19−
20−/** Entry from `GET /command`. */
21−@Immutable
22−@Serializable
23−data class Command(
24− val name: String,
25− val description: String? = null,
26− val agent: String? = null,
27− val model: String? = null,
28− val template: String = "",
29− val subtask: Boolean = false,
30−) {
31− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
32−}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktdeleted−109
@@ -1,109 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8− * Discriminated on `type` (the global discriminator). Unknown events decode to
9− * [UnknownEvent] so the stream never dies on a new event type.
10− *
11− * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12− * drift across opencode versions; the reducer only reads the fields it needs.
13− */
14−@Serializable
15−sealed interface BusEvent
16−
17−@Serializable
18−@SerialName("server.connected")
19−data object ServerConnected : BusEvent
20−
21−@Serializable
22−@SerialName("message.updated")
23−data class MessageUpdated(val properties: Props) : BusEvent {
24− @Serializable
25− data class Props(val info: MessageInfo)
26−}
27−
28−@Serializable
29−@SerialName("message.part.updated")
30−data class MessagePartUpdated(val properties: Props) : BusEvent {
31− @Serializable
32− data class Props(
33− val part: Part,
34− val sessionID: String? = null,
35− val messageID: String? = null,
36− )
37−}
38−
39−@Serializable
40−@SerialName("message.part.removed")
41−data class MessagePartRemoved(val properties: Props) : BusEvent {
42− @Serializable
43− data class Props(
44− val sessionID: String? = null,
45− val messageID: String? = null,
46− val partID: String? = null,
47− )
48−}
49−
50−@Serializable
51−@SerialName("message.removed")
52−data class MessageRemoved(val properties: Props) : BusEvent {
53− @Serializable
54− data class Props(
55− val sessionID: String? = null,
56− val messageID: String? = null,
57− )
58−}
59−
60−@Serializable
61−@SerialName("session.idle")
62−data class SessionIdle(val properties: Props) : BusEvent {
63− @Serializable
64− data class Props(val sessionID: String? = null)
65−}
66−
67−@Serializable
68−@SerialName("session.error")
69−data class SessionError(val properties: Props) : BusEvent {
70− @Serializable
71− data class Props(
72− val sessionID: String? = null,
73− val error: kotlinx.serialization.json.JsonElement? = null,
74− )
75−}
76−
77−@Serializable
78−@SerialName("session.updated")
79−data class SessionUpdated(val properties: Props) : BusEvent {
80− @Serializable
81− data class Props(val info: Session)
82−}
83−
84−@Serializable
85−@SerialName("session.deleted")
86−data class SessionDeleted(val properties: Props) : BusEvent {
87− @Serializable
88− data class Props(val info: Session? = null, val sessionID: String? = null)
89−}
90−
91−@Serializable
92−@SerialName("permission.updated")
93−data class PermissionUpdated(val properties: Permission) : BusEvent
94−
95−@Serializable
96−@SerialName("permission.replied")
97−data class PermissionReplied(val properties: Props) : BusEvent {
98− @Serializable
99− data class Props(
100− val sessionID: String? = null,
101− val permissionID: String? = null,
102− val response: String? = null,
103− )
104−}
105−
106−/** Fallback for any event type we don't model. */
107−@Serializable
108−@SerialName("__unknown")
109−data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktdeleted−28
@@ -1,28 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−@Immutable
7−@Serializable
8−data class TimeInfo(
9− val created: Long? = null,
10− val updated: Long? = null,
11− val completed: Long? = null,
12−)
13−
14−@Immutable
15−@Serializable
16−data class Tokens(
17− val input: Long = 0,
18− val output: Long = 0,
19− val reasoning: Long = 0,
20− val cache: Cache = Cache(),
21−) {
22− @Serializable
23− @Immutable
24− data class Cache(
25− val read: Long = 0,
26− val write: Long = 0,
27− )
28−}
app/src/main/java/soy/iko/opencode/data/model/Files.ktdeleted−38
@@ -1,38 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /file?path=` (directory listing). */
7−@Immutable
8−@Serializable
9−data class FileNode(
10− val name: String = "",
11− val path: String = "",
12− val type: String? = null,
13−) {
14− val isDirectory: Boolean get() = type == "directory"
15−}
16−
17−/** Response of `GET /file/content?path=`. */
18−@Immutable
19−@Serializable
20−data class FileContent(
21− val type: String? = null, // "text" | "binary"
22− val content: String = "",
23− val diff: String? = null,
24− val mimeType: String? = null,
25− val encoding: String? = null,
26−) {
27− val isBinary: Boolean get() = type == "binary" || encoding == "base64"
28−}
29−
30−/** Entry from `GET /file/status` (VCS status). */
31−@Immutable
32−@Serializable
33−data class FileStatusEntry(
34− val path: String = "",
35− val added: Int = 0,
36− val removed: Int = 0,
37− val status: String? = null, // "added" | "deleted" | "modified"
38−)
app/src/main/java/soy/iko/opencode/data/model/Message.ktdeleted−65
@@ -1,65 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonObject
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The `info` object of a message. Discriminated on `role`, which differs from the
11− * global `type` discriminator used by [Part], so this hierarchy overrides it.
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("role")
16−sealed interface MessageInfo {
17− val id: String
18− val sessionID: String
19− val time: TimeInfo?
20−}
21−
22−@Immutable
23−@Serializable
24−@SerialName("user")
25−data class UserMessage(
26− override val id: String,
27− override val sessionID: String,
28− override val time: TimeInfo? = null,
29−) : MessageInfo
30−
31−@Immutable
32−@Serializable
33−@SerialName("assistant")
34−data class AssistantMessage(
35− override val id: String,
36− override val sessionID: String,
37− val parentID: String? = null,
38− val providerID: String? = null,
39− val modelID: String? = null,
40− val cost: Double? = null,
41− val tokens: Tokens? = null,
42− val error: JsonObject? = null,
43− override val time: TimeInfo? = null,
44−) : MessageInfo {
45− /** A run is complete once the server stamps a completion time. */
46− val isComplete: Boolean get() = time?.completed != null
47−}
48−
49−/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50−@Immutable
51−@Serializable
52−@SerialName("__unknown")
53−data class UnknownMessage(
54− override val id: String = "",
55− override val sessionID: String = "",
56− override val time: TimeInfo? = null,
57−) : MessageInfo
58−
59−/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
60−@Immutable
61−@Serializable
62−data class MessageWithParts(
63− val info: MessageInfo,
64− val parts: List<Part> = emptyList(),
65−)
app/src/main/java/soy/iko/opencode/data/model/Part.ktdeleted−99
@@ -1,99 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−
7−/**
8− * A message part — a discriminated union on `type` (the global class discriminator).
9− * Unknown types decode to [UnknownPart] via the registered polymorphic default,
10− * so the stream survives server additions.
11− *
12− * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
13− * uses to attach streamed parts to the right message.
14− */
15−@Serializable
16−sealed interface Part {
17− val id: String
18− val messageID: String?
19− val sessionID: String?
20−}
21−
22−@Immutable
23−@Serializable
24−@SerialName("text")
25−data class TextPart(
26− override val id: String = "",
27− override val messageID: String? = null,
28− override val sessionID: String? = null,
29− val text: String = "",
30− val synthetic: Boolean = false,
31− val ignored: Boolean = false,
32− val time: TimeInfo? = null,
33−) : Part
34−
35−@Immutable
36−@Serializable
37−@SerialName("reasoning")
38−data class ReasoningPart(
39− override val id: String = "",
40− override val messageID: String? = null,
41− override val sessionID: String? = null,
42− val text: String = "",
43− val time: TimeInfo? = null,
44−) : Part
45−
46−@Immutable
47−@Serializable
48−@SerialName("tool")
49−data class ToolPart(
50− override val id: String = "",
51− override val messageID: String? = null,
52− override val sessionID: String? = null,
53− val callID: String = "",
54− val tool: String = "",
55− val state: ToolState = ToolPending,
56−) : Part
57−
58−@Immutable
59−@Serializable
60−@SerialName("file")
61−data class FilePart(
62− override val id: String = "",
63− override val messageID: String? = null,
64− override val sessionID: String? = null,
65− val mime: String? = null,
66− val filename: String? = null,
67− val url: String? = null,
68− val source: String? = null,
69−) : Part
70−
71−@Immutable
72−@Serializable
73−@SerialName("step-start")
74−data class StepStartPart(
75− override val id: String = "",
76− override val messageID: String? = null,
77− override val sessionID: String? = null,
78−) : Part
79−
80−@Immutable
81−@Serializable
82−@SerialName("step-finish")
83−data class StepFinishPart(
84− override val id: String = "",
85− override val messageID: String? = null,
86− override val sessionID: String? = null,
87− val cost: Double? = null,
88− val tokens: Tokens? = null,
89−) : Part
90−
91−/** Fallback for an unrecognized `type`. */
92−@Immutable
93−@Serializable
94−@SerialName("__unknown")
95−data class UnknownPart(
96− override val id: String = "",
97− override val messageID: String? = null,
98− override val sessionID: String? = null,
99−) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktdeleted−49
@@ -1,49 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.json.JsonElement
6−import kotlinx.serialization.json.JsonObject
7−import kotlinx.serialization.json.JsonArray
8−import kotlinx.serialization.json.JsonPrimitive
9−
10−/**
11− * A permission request from `permission.updated`. The tool detail lives in [type] +
12− * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
13− * it's kept as a [JsonElement] and flattened for display.
14− */
15−@Immutable
16−@Serializable
17−data class Permission(
18− val id: String,
19− val sessionID: String = "",
20− val type: String? = null,
21− val pattern: JsonElement? = null,
22− val messageID: String? = null,
23− val callID: String? = null,
24− val title: String? = null,
25− val metadata: JsonObject? = null,
26− val time: TimeInfo? = null,
27−) {
28− val patternText: String?
29− get() = when (val p = pattern) {
30− null -> null
31− is JsonPrimitive -> p.content
32− // Only a JSON array is flattened; any other shape (e.g. an object) would make
33− // the previous unguarded `p.jsonArray` throw during composition, so treat it
34− // as "no displayable pattern" instead.
35− is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36− else -> null
37− }
38−}
39−
40−/** Allowed values for the permission response body. */
41−enum class PermissionResponse(val wire: String) {
42− ONCE("once"),
43− ALWAYS("always"),
44− REJECT("reject"),
45−}
46−
47−/** Body for `POST /session/:id/permissions/:permissionID`. */
48−@Serializable
49−data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktdeleted−26
@@ -1,26 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.Serializable
4−
5−/** Body for `POST /session/:id/message`. */
6−@Serializable
7−data class PromptRequest(
8− val parts: List<PromptPart>,
9− val model: ModelRef? = null,
10− val agent: String? = null,
11−)
12−
13−/** Input parts are a small subset of [Part]; for the scaffold we send text. */
14−@Serializable
15−data class PromptPart(
16− val type: String = "text",
17− val text: String,
18−)
19−
20−/** Body for `POST /session/:id/command`. */
21−@Serializable
22−data class CommandRequest(
23− val command: String,
24− val arguments: String = "",
25− val agent: String? = null,
26−)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktdeleted−72
@@ -1,72 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Response of `GET /config/providers`. */
7−@Immutable
8−@Serializable
9−data class ProvidersResponse(
10− val providers: List<Provider> = emptyList(),
11− val default: Map<String, String> = emptyMap(),
12−)
13−
14−@Immutable
15−@Serializable
16−data class Provider(
17− val id: String,
18− val name: String? = null,
19− val models: Map<String, ModelInfo> = emptyMap(),
20−) {
21− val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
22−}
23−
24−@Immutable
25−@Serializable
26−data class ModelInfo(
27− val id: String? = null,
28− val name: String? = null,
29−) {
30− fun displayName(modelKey: String): String =
31− name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
32−}
33−
34−/** A flattened provider/model selection used by the prompt body and the model picker. */
35−@Immutable
36−@Serializable
37−data class ModelRef(
38− val providerID: String,
39− val modelID: String,
40−)
41−
42−/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
43−@Immutable
44−data class ModelOption(
45− val providerID: String,
46− val modelID: String,
47− val providerLabel: String,
48− val modelLabel: String,
49−) {
50− val ref: ModelRef get() = ModelRef(providerID, modelID)
51−}
52−
53−/** Flatten providers into a sorted list of pickable options. */
54−fun ProvidersResponse.toOptions(): List<ModelOption> =
55− providers.flatMap { provider ->
56− provider.models.map { (modelKey, info) ->
57− ModelOption(
58− providerID = provider.id,
59− modelID = info.id ?: modelKey,
60− providerLabel = provider.displayName,
61− modelLabel = info.displayName(modelKey),
62− )
63− }
64− }.sortedWith(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.ktdeleted−21
@@ -1,21 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−
5−/**
6− * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7− * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8− */
9−@Immutable
10−data class ServerProfile(
11− val id: String,
12− val label: String,
13− val baseUrl: String,
14− val username: String? = null,
15− val password: String? = null,
16− val lastUsed: Long = 0,
17−) {
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.ktdeleted−30
@@ -1,30 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** A session as returned by `GET /session` and `POST /session`. */
7−@Immutable
8−@Serializable
9−data class Session(
10− val id: String,
11− val parentID: String? = null,
12− val title: String? = null,
13− val version: String? = null,
14− val time: TimeInfo? = null,
15−) {
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.ktdeleted−53
@@ -1,53 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The state machine of a tool call, discriminated on `status`.
11− * pending -> running -> completed | error
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("status")
16−sealed interface ToolState
17−
18−@Serializable
19−@SerialName("pending")
20−data object ToolPending : ToolState
21−
22−@Immutable
23−@Serializable
24−@SerialName("running")
25−data class ToolRunning(
26− val input: JsonElement? = null,
27− val title: String? = null,
28− val time: TimeInfo? = null,
29−) : ToolState
30−
31−@Immutable
32−@Serializable
33−@SerialName("completed")
34−data class ToolCompleted(
35− val input: JsonElement? = null,
36− val output: String? = null,
37− val title: String? = null,
38− val time: TimeInfo? = null,
39−) : ToolState
40−
41−@Immutable
42−@Serializable
43−@SerialName("error")
44−data class ToolError(
45− val input: JsonElement? = null,
46− val error: String? = null,
47− val time: TimeInfo? = null,
48−) : ToolState
49−
50−/** Fallback for an unrecognized `status`. */
51−@Serializable
52−@SerialName("__unknown")
53−data object ToolUnknown : ToolState
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktdeleted−214
@@ -1,214 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.util.safeExceptionSummary
5−import android.util.Log
6−import io.ktor.client.HttpClient
7−import io.ktor.client.plugins.timeout
8−import io.ktor.client.plugins.sse.sse
9−import io.ktor.sse.ServerSentEvent
10−import kotlinx.coroutines.CancellationException
11−import kotlinx.coroutines.CoroutineScope
12−import kotlin.coroutines.EmptyCoroutineContext
13−import kotlinx.coroutines.channels.BufferOverflow
14−import kotlinx.coroutines.channels.Channel
15−import kotlinx.coroutines.channels.ChannelResult
16−import kotlinx.coroutines.flow.Flow
17−import kotlinx.coroutines.flow.MutableStateFlow
18−import kotlinx.coroutines.flow.SharedFlow
19−import kotlinx.coroutines.flow.StateFlow
20−import kotlinx.coroutines.flow.SharingStarted
21−import kotlinx.coroutines.flow.asStateFlow
22−import kotlinx.coroutines.flow.buffer
23−import kotlinx.coroutines.flow.channelFlow
24−import kotlinx.coroutines.flow.produceIn
25−import kotlinx.coroutines.flow.shareIn
26−import kotlinx.coroutines.isActive
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− private fun isSseAuthFailure(e: Throwable): Boolean =
73− e is io.ktor.client.plugins.ClientRequestException &&
74− (e.response.status.value == 401 || e.response.status.value == 403)
75−
76− /** Request an immediate reconnect, skipping any in-progress backoff. */
77− open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
78−
79− /**
80− * Read SSE events from [events], racing each read against an idle-timeout watchdog.
81− * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
82− * clean stream close or scope cancellation.
83− */
84− private suspend fun readSseEvents(
85− scope: CoroutineScope,
86− events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
87− send: suspend (BusEvent) -> Unit,
88− ) {
89− while (scope.isActive) {
90− // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
91− // read), so a socket the peer never closed would otherwise hang until OS TCP
92− // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
93− // within the idle timeout, treat the connection as half-open and drop it.
94− val result: ChannelResult<ServerSentEvent>? = select {
95− events.onReceiveCatching { it }
96− onTimeout(idleTimeoutMs) { null }
97− }
98− if (result == null) throw IOException("SSE idle timeout, reconnecting")
99− val sse = result.getOrNull() ?: break
100− val data = sse.data
101− if (data == null) {
102− // A comment or keep-alive event (no data field) resets the idle watchdog
103− // — servers send `: ping` comments to keep the connection alive through
104− // proxies. Without this, a server that only sends comments would still
105− // hit the idle timeout and reconnect unnecessarily.
106− continue
107− }
108− val event = try {
109− OpencodeJson.decodeFromString(BusEvent.serializer(), data)
110− } catch (e: kotlinx.serialization.SerializationException) {
111− Log.d("EventStream", "Skipping unparseable SSE event", e)
112− continue
113− }
114− if (_state.value != ConnectionState.Connected) {
115− _state.value = ConnectionState.Connected
116− }
117− send(event)
118− }
119− }
120−
121− private fun stream(): Flow<BusEvent> = channelFlow {
122− val scope = this
123− // Drain any stale reconnect signal from a previous collection cycle so it
124− // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
125− // stopped the upstream while triggerReconnect() queued a signal).
126− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
127− var backoffMs = initialBackoffMs
128− while (isActive) {
129− _state.value = ConnectionState.Connecting
130− try {
131− // The SSE stream is long-lived: disable the request-level and socket
132− // timeouts (the client default is 60s for REST) in the request config
133− // so the connection isn't killed mid-stream. The idle-timeout watchdog
134− // below handles half-open connections instead.
135− client.sse(
136− "event",
137− request = {
138− timeout {
139− requestTimeoutMillis = Long.MAX_VALUE
140− // Disable the socket (read) timeout entirely for the SSE stream.
141− // A finite value would kill the connection whenever the server
142− // is quiet for longer than that (idle sessions, slow agent runs),
143− // causing a reconnect storm. Half-open detection is handled by
144− // the idle-timeout watchdog below plus OkHttp's pingInterval on
145− // HTTP/2 connections.
146− socketTimeoutMillis = Long.MAX_VALUE
147− }
148− },
149− ) {
150− // The SSE block is open — the connection is established. Mark as
151− // Connected immediately so downstream re-seed logic fires even when
152− // the server only sends keep-alive comments (no data events).
153− if (_state.value != ConnectionState.Connected) {
154− _state.value = ConnectionState.Connected
155− }
156− // Clear any stale reconnect signal from a triggerReconnect() call
157− // made while the previous stream was healthy, so it doesn't suppress
158− // the next legitimate backoff.
159− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
160− // `incoming` is a cold Flow; bridge it to a ReceiveChannel so each
161− // element can be raced against an idle timeout via select.
162− val events = incoming.produceIn(scope)
163− // Reset the backoff only once real data actually arrives, not on
164− // block entry: a server that accepts the connection then immediately
165− // drops it would otherwise loop forever at the initial backoff and
166− // never escalate the exponential ramp.
167− var receivedAny = false
168− try {
169− readSseEvents(scope, events) {
170− if (!receivedAny) {
171− receivedAny = true
172− backoffMs = initialBackoffMs
173− }
174− send(it)
175− }
176− } finally {
177− events.cancel()
178− }
179− }
180− } catch (c: CancellationException) {
181− _state.value = ConnectionState.Disconnected
182− throw c
183− } catch (e: Exception) {
184− // If the scope was cancelled (e.g. connection close), the closed-client
185− // exception is expected — don't log a spurious "stream error" warning.
186− if (isSseAuthFailure(e)) {
187− // Credentials are wrong — retrying won't help. Log a scrubbed
188− // summary (class + status) instead of the full exception, whose
189− // message carries the request URL and may include auth or paths.
190− Log.w("EventStream", "SSE auth failed (401/403), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
191− _state.value = ConnectionState.Failed
192− if (!isActive) break
193− // Don't complete the flow: a subscriber may fix the profile and call
194− // triggerReconnect(). Suspend until an explicit reconnect signal
195− // arrives (no backoff delay), then re-attempt the connection.
196− reconnectSignal.receive()
197− continue
198− } else if (isActive) {
199− Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
200− }
201− }
202− _state.value = ConnectionState.Disconnected
203− if (!isActive) break
204− // Wait for the backoff, but allow a reconnect signal to cut it short.
205− var signaled = false
206− val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
207− select {
208− reconnectSignal.onReceive { signaled = true }
209− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
210− }
211− backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
212− }
213− }
214−}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktdeleted−114
@@ -1,114 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import io.ktor.client.HttpClient
5−import io.ktor.client.engine.okhttp.OkHttp
6−import io.ktor.client.plugins.HttpTimeout
7−import io.ktor.client.plugins.auth.Auth
8−import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9−import io.ktor.client.plugins.auth.providers.basic
10−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11−import io.ktor.client.plugins.defaultRequest
12−import io.ktor.client.plugins.sse.SSE
13−import io.ktor.client.request.header
14−import io.ktor.http.URLBuilder
15−import io.ktor.http.takeFrom
16−import io.ktor.serialization.kotlinx.json.json
17−import 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.ktdeleted−151
@@ -1,151 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−/**
4− * Centralized tuning parameters for the network and presentation layers. Keeping these
5− * in one place makes them easy to find and adjust without hunting through source files,
6− * and makes the intent of each value explicit rather than a bare magic number.
7− */
8−object NetworkConfig {
9−
10− // --- OkHttp engine timeouts (HttpClientFactory) ---
11−
12− /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13− const val connectTimeoutSeconds = 30L
14− /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15− const val readTimeoutSeconds = 60L
16− /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17− const val pingIntervalSeconds = 20L
18− /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19− const val restRequestTimeoutMs = 60_000L
20−
21− // --- REST retry (OpencodeApiClient.withRetry) ---
22−
23− /** Maximum number of attempts for a retriable REST call. */
24− const val retryMaxAttempts = 3
25− /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26− const val retryInitialDelayMs = 500L
27− /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28− const val retryMaxDelayMs = 30_000L
29− /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
30− const val retryJitterFactor = 0.2
31−
32− // --- SSE stream (EventStreamClient) ---
33−
34− /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35− const val sseInitialBackoffMs = 500L
36− /** Cap on the SSE reconnect backoff. */
37− const val sseMaxBackoffMs = 10_000L
38− /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
39− const val sseIdleTimeoutMs = 90_000L
40− /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41− const val sseEventBufferCapacity = 64
42−
43− // --- Catalog cache (OpencodeApiClient) ---
44−
45− /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
46− const val catalogCacheTtlMs = 60_000L
47−
48− // --- Session list (SessionListViewModel) ---
49−
50− /** Max sessions to fetch previews for in one batch. */
51− const val maxPreviewSessions = 50
52− /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
53− const val maxConcurrentPreviews = 8
54− /** Max characters of a session's last message to keep as a list preview. */
55− const val previewTextMaxLength = 200
56− /** Debounce delay before fetching a session preview after an SSE update, so a burst
57− * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
58− * instead of launching one per event (each downloading the full message history). */
59− const val previewDebounceMs = 500L
60− /** Debounce for applying SessionUpdated SSE events to the session list state. During
61− * active streaming the server emits these frequently; without debouncing each one
62− * triggers a full list filter + sort + recomposition, causing scroll jank. */
63− const val sessionUpdateDebounceMs = 300L
64−
65− /** 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.ktdeleted−302
@@ -1,302 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.Agent
4−import soy.iko.opencode.data.model.Command
5−import soy.iko.opencode.data.model.CommandRequest
6−import soy.iko.opencode.data.model.CreateSessionRequest
7−import soy.iko.opencode.data.model.FileContent
8−import soy.iko.opencode.data.model.FileNode
9−import soy.iko.opencode.data.model.FileStatusEntry
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.model.ModelRef
12−import soy.iko.opencode.data.model.PermissionReplyBody
13−import soy.iko.opencode.data.model.PermissionResponse
14−import soy.iko.opencode.data.model.PromptPart
15−import soy.iko.opencode.data.model.PromptRequest
16−import soy.iko.opencode.data.model.ProvidersResponse
17−import soy.iko.opencode.data.model.Session
18−import soy.iko.opencode.data.model.UpdateSessionRequest
19−import io.ktor.client.HttpClient
20−import io.ktor.client.call.body
21−import io.ktor.client.plugins.ClientRequestException
22−import io.ktor.client.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 = withRetry {
75− client!!.post("session") {
76− contentType(ContentType.Application.Json)
77− setBody(CreateSessionRequest(title = title))
78− }.body()
79− }
80−
81− open suspend fun updateSession(id: String, title: String): Session = withRetry {
82− client!!.patch("session/${encode(id)}") {
83− contentType(ContentType.Application.Json)
84− setBody(UpdateSessionRequest(title = title))
85− }.body()
86− }
87−
88− open suspend fun deleteSession(id: String) {
89− withRetry { client!!.delete("session/${encode(id)}").body<String>() }
90− }
91−
92− open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
93− client!!.get("session/${encode(sessionId)}/message").body()
94− }
95−
96− open suspend fun sendPrompt(
97− sessionId: String,
98− text: String,
99− model: ModelRef? = null,
100− agent: String? = null,
101− ): MessageWithParts {
102− // Generate the idempotency key *before* entering withRetry so all retry
103− // attempts share the same key. If the key were generated inside withRetry,
104− // each attempt would get a fresh UUID and the server couldn't deduplicate a
105− // request that reached it but whose response was lost (e.g. timeout).
106− val idempotencyKey = java.util.UUID.randomUUID().toString()
107− return withRetry {
108− client!!.post("session/${encode(sessionId)}/message") {
109− contentType(ContentType.Application.Json)
110− header("Idempotency-Key", idempotencyKey)
111− setBody(
112− PromptRequest(
113− parts = listOf(PromptPart(text = text)),
114− model = model,
115− agent = agent,
116− ),
117− )
118− }.body()
119− }
120− }
121−
122− open suspend fun abort(sessionId: String) {
123− withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
124− }
125−
126− /** Invoke a slash-command by name via `POST /session/:id/command`. */
127− open suspend fun runCommand(
128− sessionId: String,
129− command: String,
130− arguments: String = "",
131− agent: String? = null,
132− ): MessageWithParts {
133− // Generate the idempotency key before entering withRetry so all retry
134− // attempts share the same key, preventing duplicate command execution if
135− // the server processed the request but the response was lost.
136− val idempotencyKey = java.util.UUID.randomUUID().toString()
137− return withRetry {
138− client!!.post("session/${encode(sessionId)}/command") {
139− contentType(ContentType.Application.Json)
140− header("Idempotency-Key", idempotencyKey)
141− setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
142− }.body()
143− }
144− }
145−
146− open suspend fun providers(): ProvidersResponse = providersMutex.withLock {
147− val now = System.currentTimeMillis()
148− val cached = cachedProviders
149− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
150− return@withLock cached.value
151− }
152− val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
153− cachedProviders = CachedEntry(value, System.currentTimeMillis())
154− value
155− }
156−
157− open suspend fun agents(): List<Agent> = agentsMutex.withLock {
158− val now = System.currentTimeMillis()
159− val cached = cachedAgents
160− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
161− return@withLock cached.value
162− }
163− val value = withRetry { client!!.get("agent").body<List<Agent>>() }
164− cachedAgents = CachedEntry(value, System.currentTimeMillis())
165− value
166− }
167−
168− open suspend fun commands(): List<Command> = commandsMutex.withLock {
169− val now = System.currentTimeMillis()
170− val cached = cachedCommands
171− if (cached != null && now - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
172− return@withLock cached.value
173− }
174− val value = withRetry { client!!.get("command").body<List<Command>>() }
175− cachedCommands = CachedEntry(value, System.currentTimeMillis())
176− value
177− }
178−
179− /** Invalidate the catalog cache (e.g. on server switch). */
180− open suspend fun invalidateCache() {
181− providersMutex.withLock { cachedProviders = null }
182− agentsMutex.withLock { cachedAgents = null }
183− commandsMutex.withLock { cachedCommands = null }
184− }
185−
186− open suspend fun invalidateProvidersCache() {
187− providersMutex.withLock { cachedProviders = null }
188− }
189−
190− open suspend fun invalidateAgentsCache() {
191− agentsMutex.withLock { cachedAgents = null }
192− }
193−
194− open suspend fun invalidateCommandsCache() {
195− commandsMutex.withLock { cachedCommands = null }
196− }
197−
198− /** Respond to a permission request so a paused tool run can proceed. */
199− open suspend fun respondPermission(
200− sessionId: String,
201− permissionId: String,
202− response: PermissionResponse,
203− ) = withRetry {
204− client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
205− contentType(ContentType.Application.Json)
206− setBody(PermissionReplyBody(response.wire))
207− }.body<String>()
208− }
209−
210− // --- Files ---
211−
212− open suspend fun findFiles(query: String): List<String> = withRetry {
213− client!!.get("find/file") { parameter("query", query) }.body()
214− }
215−
216− open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
217− client!!.get("file") { parameter("path", path) }.body()
218− }
219−
220− open suspend fun readFile(path: String): FileContent = withRetry {
221− client!!.get("file/content") { parameter("path", path) }.body()
222− }
223−
224− open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
225− client!!.get("file/status").body()
226− }
227−}
228−
229−private suspend fun <T> withRetry(
230− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
231− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
232− block: suspend () -> T,
233−): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
234−
235−/**
236− * Retry a suspending block up to [maxAttempts] times with exponential backoff.
237− * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
238− * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
239− * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
240− * server errors are retried. Exposed as `internal` so the retry/skip rules are
241− * unit-testable without an HTTP server.
242− */
243−internal suspend fun <T> withRetryInternal(
244− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
245− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
246− jitterFactor: Double = NetworkConfig.retryJitterFactor,
247− block: suspend () -> T,
248−): T {
249− var lastError: Throwable? = null
250− for (attempt in 1..maxAttempts) {
251− try {
252− return block()
253− } catch (c: kotlinx.coroutines.CancellationException) {
254− throw c
255− } catch (t: ClientRequestException) {
256− // 429 (Too Many Requests) is transient — retry with backoff.
257− // All other 4xx are non-retryable.
258− if (t.response.status.value != 429) throw t
259− lastError = t
260− if (attempt < maxAttempts) {
261− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
262− }
263− } catch (t: ServerResponseException) {
264− // 5xx server errors are retryable with backoff.
265− lastError = t
266− if (attempt < maxAttempts) {
267− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
268− }
269− } catch (t: ResponseException) {
270− // 3xx (redirect limit exceeded) is non-retryable.
271− throw t
272− } catch (t: Exception) {
273− lastError = t
274− if (attempt < maxAttempts) {
275− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
276− }
277− }
278− }
279− throw lastError ?: IllegalStateException("withRetry failed without error")
280−}
281−
282−/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
283− * so the backoff formula is unit-testable without an HTTP server. */
284−internal fun jitteredBackoff(
285− attempt: Int,
286− initialDelayMs: Long,
287− jitterFactor: Double,
288−): Long {
289− // Guard against Int overflow from the shift and Long overflow from
290− // the multiplication at high attempt counts.
291− val shift = (attempt - 1).coerceAtMost(30)
292− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
293− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
294− // shorter, sometimes longer — spreads concurrent client retries and
295− // prevents thundering-herd reconnection storms.
296− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
297− return (baseDelay + jitter).coerceAtLeast(0)
298−}
299−
300−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding). */
301−private fun encode(segment: String): String =
302− URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessageInfo
5−import soy.iko.opencode.data.model.Part
6−import soy.iko.opencode.data.model.ToolState
7−import soy.iko.opencode.data.model.UnknownEvent
8−import soy.iko.opencode.data.model.UnknownMessage
9−import soy.iko.opencode.data.model.UnknownPart
10−import soy.iko.opencode.data.model.ToolUnknown
11−import kotlinx.serialization.ExperimentalSerializationApi
12−import kotlinx.serialization.json.Json
13−import kotlinx.serialization.modules.SerializersModule
14−
15−/**
16− * Single shared Json for the whole app.
17− *
18− * The polymorphic default deserializers are the resilience layer: when the server
19− * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20− * Unknown variant instead of throwing — so a new opencode release can't break the
21− * event stream or message decoding.
22− */
23−@OptIn(ExperimentalSerializationApi::class)
24−val OpencodeJson: Json = Json {
25− ignoreUnknownKeys = true
26− isLenient = true
27− coerceInputValues = true
28− encodeDefaults = true
29− explicitNulls = false
30− classDiscriminator = "type"
31− serializersModule = SerializersModule {
32− polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33− polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34− polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35− polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36− }
37−}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktdeleted−202
@@ -1,202 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.pm.PackageManager
5−import android.os.Build
6−import android.util.Log
7−import androidx.core.content.pm.PackageInfoCompat
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.Job
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−import kotlinx.coroutines.delay
14−import kotlinx.coroutines.flow.MutableStateFlow
15−import kotlinx.coroutines.flow.StateFlow
16−import kotlinx.coroutines.flow.asStateFlow
17−import kotlinx.coroutines.launch
18−import java.io.File
19−import java.io.PrintWriter
20−import java.io.StringWriter
21−import java.text.SimpleDateFormat
22−import java.util.Date
23−import java.util.Locale
24−import soy.iko.opencode.util.runCatchingCancellable
25−
26−/**
27− * A self-contained crash reporter with no network or third-party backend. An uncaught
28− * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29− * text report (timestamp, device/app metadata, and the full stack trace), then the
30− * previous handler is re-invoked so the process still dies normally.
31− *
32− * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33− * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34− * without pulling in a hosted analytics SDK.
35− */
36−class CrashLogger private constructor(private val appContext: Context) {
37−
38− data class CrashReport(
39− val fileName: String,
40− val timestamp: Long,
41− val preview: String,
42− )
43−
44− private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45− private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46−
47− private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48− val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
49−
50− fun install() {
51− val previous = Thread.getDefaultUncaughtExceptionHandler()
52− Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53− runCatching { writeReport(thread, throwable) }
54− .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
55− previous?.uncaughtException(thread, throwable)
56− }
57− // Load crash reports off the main thread so startup isn't blocked by file I/O
58− // (listFiles + reading the first line of each report). The StateFlow updates
59− // whenever the scan completes, so the Diagnostics screen reflects the result.
60− scope.launch { runCatchingCancellable { refresh() } }
61− }
62−
63− fun refresh() {
64− _reports.value = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
65− ?.sortedByDescending { it.lastModified() }
66− ?.map { f ->
67− CrashReport(
68− fileName = f.name,
69− timestamp = f.lastModified(),
70− preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
71− )
72− }
73− .orEmpty()
74− }
75−
76− fun readReport(fileName: String): String? {
77− val file = File(crashDir, fileName).canonicalFile
78− if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
79− return runCatching { file.readText() }.getOrNull()
80− }
81−
82− fun deleteReport(fileName: String) {
83− scope.launch {
84− val file = File(crashDir, fileName).canonicalFile
85− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
86− file.delete()
87− }
88− refresh()
89− }
90− }
91−
92− /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
93− private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
94−
95− /**
96− * Schedule [fileName] for deletion after [delayMs], cancellable via
97− * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
98− * Diagnostics screen's composition scope — so navigating away during the undo window
99− * still commits the delete instead of silently dropping it. A repeated schedule for
100− * the same name replaces the prior timer.
101− */
102− fun scheduleDelete(fileName: String, delayMs: Long) {
103− val job = scope.launch {
104− delay(delayMs)
105− val file = File(crashDir, fileName).canonicalFile
106− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
107− file.delete()
108− }
109− // Remove only if this job is still the registered one. A reschedule for the
110− // same name replaces the map entry with a new job; an unconditional remove
111− // here would evict that newer job's entry, leaking it from cancellation.
112− coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) }
113− refresh()
114− }
115− pendingDeletes.put(fileName, job)?.cancel()
116− }
117−
118− /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
119− * pending (undo succeeded), false if the delete had already fired. */
120− fun cancelScheduledDelete(fileName: String): Boolean {
121− val job = pendingDeletes.remove(fileName) ?: return false
122− job.cancel()
123− return true
124− }
125−
126− fun clearAll() {
127− scope.launch {
128− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
129− refresh()
130− }
131− }
132−
133− fun reportCount(): Int = _reports.value.size
134−
135− /** Cancel the internal coroutine scope so background work doesn't outlive the
136− * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
137− * intentionally left installed — a crash during shutdown still needs to be logged. */
138− fun shutdown() {
139− scope.cancel()
140− }
141−
142− private fun writeReport(thread: Thread, throwable: Throwable) {
143− val now = Date()
144− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
145− // Append a short random suffix so two crashes in the same millisecond don't
146− // overwrite each other.
147− val suffix = java.lang.Long.toHexString(System.nanoTime())
148− val file = File(crashDir, "crash-$stamp-$suffix.txt")
149− val sw = StringWriter()
150− PrintWriter(sw).use { pw ->
151− pw.println("opencode-android crash report")
152− pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
153− pw.println("Thread: ${thread.name}")
154− pw.println("App version: ${appVersion()}")
155− pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
156− pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
157− pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
158− pw.println()
159− pw.println(throwable.javaClass.name + ": " + throwable.message)
160− throwable.printStackTrace(pw)
161− }
162− // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
163− // message (and every "Caused by:" message) verbatim, so scrubbing only the
164− // top-level message above is futile — Ktor/OkHttp embed the full request URL
165− // in those messages, which may contain auth or internal paths. Scrubbing the
166− // assembled text guarantees no URL survives anywhere in the stored report.
167− file.writeText(scrubUrls(sw.toString()))
168− }
169−
170− private fun appVersion(): String = runCatching {
171− val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
172− appContext.packageManager.getPackageInfo(
173− appContext.packageName,
174− PackageManager.PackageInfoFlags.of(0),
175− )
176− } else {
177− @Suppress("DEPRECATION")
178− appContext.packageManager.getPackageInfo(appContext.packageName, 0)
179− }
180− "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
181− }.getOrDefault("unknown")
182−
183− companion object {
184− /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
185− internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
186−
187− /**
188− * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
189− * redaction (used on the whole crash report, including exception messages and
190− * stack traces produced by printStackTrace) can be unit-tested in isolation.
191− */
192− internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
193−
194− @Volatile
195− private var instance: CrashLogger? = null
196−
197− fun get(context: Context): CrashLogger =
198− instance ?: synchronized(this) {
199− instance ?: CrashLogger(context.applicationContext).also { instance = it }
200− }
201− }
202−}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktdeleted−229
@@ -1,229 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.CoroutineScope
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.flow.update
10−import android.util.Log
11−import kotlinx.coroutines.launch
12−import kotlinx.coroutines.withContext
13−import java.util.concurrent.Executors
14−
15−/**
16− * Persists the in-progress message draft for each session so it survives
17− * back-navigation and process death. Backed by SharedPreferences because the
18− * UI needs the initial value synchronously and drafts are low-stakes data.
19− *
20− * The prefs file is loaded on a background thread at construction time. Until
21− * the load completes, [drafts] reports an empty map and [get] returns "" rather
22− * than blocking the caller (previously a [CountDownLatch] could stall the main
23− * thread for up to 2 seconds on a slow disk).
24− */
25−open class DraftStore private constructor(
26− private val appContext: Context?,
27− private val scope: CoroutineScope?,
28− @Suppress("unused") private val testMode: Boolean,
29−) {
30− constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
31− protected constructor() : this(null, null, true)
32−
33− private val prefs by lazy {
34− val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
35− ?: error("No context — override methods in test subclass")
36− prefsInitialized.set(true)
37− sp
38− }
39−
40− /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
41− * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
42− * write on the main thread is safe or whether the write must be deferred to a
43− * background thread to avoid blocking on first-time disk I/O. */
44− private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
45−
46− /** Single-thread executor for flushDraft when prefs aren't initialized yet,
47− * so the main thread is never blocked on disk I/O. */
48− private val flushExecutor by lazy {
49− Executors.newSingleThreadExecutor { r ->
50− Thread(r, "DraftStore-flush").apply { isDaemon = true }
51− }
52− }
53−
54− private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
55− /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
56− open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
57−
58− /** Queued follow-up messages per session, persisted under a namespaced key so a
59− * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
60− * ChatViewModel would otherwise lose it, along with the cleared draft). */
61− private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
62−
63− private val _ready = MutableStateFlow(false)
64− /** True once the background prefs load has completed. */
65− open val ready: StateFlow<Boolean> = _ready.asStateFlow()
66−
67− init {
68− // Eagerly load the prefs file on a background thread so the first main-thread
69− // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
70− // so callers can observe readiness without blocking.
71− if (scope != null && appContext != null) {
72− scope.launch(Dispatchers.IO) {
73− val all = runCatching {
74− prefs.all.mapValues { it.value as? String ?: "" }
75− }.getOrDefault(emptyMap())
76− // Partition persisted keys: follow-up keys carry a prefix, everything else
77− // is a plain per-session draft.
78− val drafts = mutableMapOf<String, String>()
79− val followUps = mutableMapOf<String, String>()
80− all.forEach { (k, v) ->
81− if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
82− else drafts[k] = v
83− }
84− // Merge instead of overwriting: any in-memory writes that arrived before
85− // the background load completed (e.g. a share-intent draft injected via
86− // setImmediate) take priority over the persisted snapshot so they aren't
87− // clobbered.
88− _drafts.update { current -> drafts.apply { putAll(current) } }
89− _followUps.update { current -> followUps.apply { putAll(current) } }
90− _ready.value = true
91− }
92− } else {
93− _ready.value = true
94− }
95− }
96−
97− /** Returns the draft for [sessionId], or "" if not found or still loading. */
98− open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
99−
100− /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
101− open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
102−
103− /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
104− * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
105− * so a follow-up queued just before process death is flushed before the process exits.
106− * A blank [text] clears the stored follow-up. */
107− open fun flushFollowUp(sessionId: String, text: String) {
108− _followUps.update { current ->
109− val updated = current.toMutableMap()
110− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
111− updated
112− }
113− writeAsync(FOLLOWUP_PREFIX + sessionId, text)
114− }
115−
116− /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
117− * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
118− private fun writeAsync(key: String, text: String) {
119− val write = {
120− runCatching {
121− prefs.edit().apply {
122− if (text.isBlank()) remove(key) else putString(key, text)
123− }.apply()
124− }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
125− }
126− if (prefsInitialized.get()) write() else flushExecutor.execute { write() }
127− }
128−
129− /** Update the in-memory draft immediately without persisting to disk.
130− * Used when navigation must see the new value synchronously (e.g. share-intent
131− * injection before ChatScreen composes). Call [set] afterwards to persist. */
132− open fun setImmediate(sessionId: String, text: String) {
133− _drafts.update { current ->
134− val updated = current.toMutableMap()
135− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
136− updated
137− }
138− }
139−
140− open suspend fun set(sessionId: String, text: String) {
141− _drafts.update { current ->
142− val updated = current.toMutableMap()
143− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
144− updated
145− }
146− withContext(Dispatchers.IO) {
147− runCatching {
148− prefs.edit().apply {
149− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
150− }.apply()
151− }.onFailure { Log.w("DraftStore", "Failed to persist draft for $sessionId", it) }
152− }
153− }
154−
155− /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
156− * Also clears any queued follow-up so a deleted session leaves nothing behind. */
157− open suspend fun remove(sessionId: String) {
158− _drafts.update { current ->
159− val updated = current.toMutableMap()
160− updated.remove(sessionId)
161− updated
162− }
163− _followUps.update { current ->
164− val updated = current.toMutableMap()
165− updated.remove(sessionId)
166− updated
167− }
168− withContext(Dispatchers.IO) {
169− runCatching {
170− prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
171− }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
172− }
173− }
174−
175− /**
176− * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
177− * viewModelScope is already cancelled and a suspending write can't be used.
178− *
179− * If the SharedPreferences file has already been opened (the normal case — the
180− * background init load touches it shortly after construction), the write uses
181− * [apply] (asynchronous) and returns immediately without blocking the main thread.
182− *
183− * If the prefs file has NOT been opened yet (a rare race where onCleared fires
184− * before the background init), the write is dispatched to a background thread
185− * instead of blocking the main thread on a synchronous disk read. The draft
186− * may be lost if the process exits before the write completes, but this is
187− * preferable to an ANR.
188− */
189− open fun flushDraft(sessionId: String, text: String) {
190− _drafts.update { current ->
191− val updated = current.toMutableMap()
192− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
193− updated
194− }
195− if (prefsInitialized.get()) {
196− // Prefs already opened — the lazy resolves instantly and apply() is async.
197− runCatching {
198− prefs.edit().apply {
199− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
200− }.apply()
201− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
202− } else {
203− // Prefs not yet opened — dispatch to a background thread to avoid a
204− // synchronous disk read on the main thread (which would ANR). The draft
205− // may be lost if the process exits before the write completes.
206− flushExecutor.execute {
207− runCatching {
208− prefs.edit().apply {
209− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
210− }.apply()
211− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
212− }
213− }
214− }
215−
216− private companion object {
217− /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
218− * prefs file. Uses a NUL so it can't collide with a real session id. */
219− const val FOLLOWUP_PREFIX = "followup"
220− }
221−
222− /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
223− open fun shutdown() {
224− flushExecutor.shutdown()
225− // Await pending flushDraft writes so drafts aren't lost if the process exits
226− // immediately after shutdown (e.g. ANR-triggered process kill).
227− runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
228− }
229−}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktdeleted−78
@@ -1,78 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.ClientRequestException
4−import io.ktor.client.plugins.HttpRequestTimeoutException
5−import io.ktor.client.plugins.RedirectResponseException
6−import io.ktor.client.plugins.ResponseException
7−import io.ktor.client.plugins.ServerResponseException
8−import java.io.IOException
9−import java.net.ConnectException
10−import java.net.SocketTimeoutException
11−import java.net.UnknownHostException
12−
13−/**
14− * User-facing categories for a thrown error. Extracted so the classification logic is
15− * unit-testable without an Android [android.content.Context] (the mapping from a kind
16− * to a localized string lives in [friendlyError]).
17− */
18−enum class ErrorKind {
19− /** Server host could not be resolved / connection refused. */
20− NOT_REACHABLE,
21−
22− /** Connect or read timed out. */
23− TIMEOUT,
24−
25− /** Server returned a 5xx response. */
26− SERVER,
27−
28− /** Server returned a 4xx response (auth, not-found, etc.). */
29− CLIENT,
30−
31− /** Any other network-level I/O failure. */
32− NETWORK,
33−
34− /** Anything that isn't network-related. */
35− UNKNOWN,
36−}
37−
38−/**
39− * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40− * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41− * by a library still classifies correctly.
42− */
43−fun classifyError(t: Throwable): ErrorKind {
44− var current: Throwable? = t
45− // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46− // IOException) still classify by their root.
47− var hops = 0
48− while (current != null && hops < 8) {
49− when (current) {
50− is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51− is SocketTimeoutException, is HttpRequestTimeoutException ->
52− return ErrorKind.TIMEOUT
53− is ServerResponseException -> return ErrorKind.SERVER
54− is ClientRequestException -> return ErrorKind.CLIENT
55− is RedirectResponseException -> return ErrorKind.CLIENT
56− is IOException -> return ErrorKind.NETWORK
57− else -> {
58− // Any other ResponseException (unrecognized status family).
59− if (current is ResponseException) return ErrorKind.SERVER
60− }
61− }
62− current = current.cause
63− hops++
64− }
65− return ErrorKind.UNKNOWN
66−}
67−
68−/** The HTTP status code carried by a client/server error, if any. */
69−fun responseStatusCode(t: Throwable): Int? {
70− var current: Throwable? = t
71− var hops = 0
72− while (current != null && hops < 8) {
73− if (current is ResponseException) return current.response.status.value
74− current = current.cause
75− hops++
76− }
77− return null
78−}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktdeleted−209
@@ -1,209 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.SharedPreferences
5−import android.util.Log
6−import androidx.datastore.preferences.core.edit
7−import androidx.datastore.preferences.core.stringPreferencesKey
8−import androidx.datastore.preferences.preferencesDataStore
9−import androidx.security.crypto.EncryptedSharedPreferences
10−import androidx.security.crypto.MasterKey
11−import soy.iko.opencode.data.model.ServerProfile
12−import soy.iko.opencode.data.network.OpencodeJson
13−import kotlinx.coroutines.Dispatchers
14−import kotlinx.coroutines.flow.Flow
15−import kotlinx.coroutines.flow.map
16−import kotlinx.coroutines.withContext
17−import kotlinx.serialization.Serializable
18−import kotlinx.serialization.builtins.ListSerializer
19−
20−private val Context.dataStore by preferencesDataStore(name = "server_profiles")
21−
22−/**
23− * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
24− * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
25− * by profile id, and merged back in on read.
26− */
27−open class ProfileStore private constructor(
28− private val appContext: Context?,
29− @Suppress("unused") private val testMode: Boolean,
30−) {
31− constructor(context: Context) : this(context.applicationContext, false)
32− protected constructor() : this(null, true)
33−
34− private val profilesKey = stringPreferencesKey("profiles_json")
35−
36− /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every DataStore emission. */
37− private val migrationDone = java.util.concurrent.atomic.AtomicBoolean(false)
38−
39− private val securePrefs: SharedPreferences? by lazy {
40− val ctx = appContext ?: return@lazy null
41− runCatching {
42− val masterKey = MasterKey.Builder(ctx)
43− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
44− .build()
45− EncryptedSharedPreferences.create(
46− ctx,
47− "server_secrets",
48− masterKey,
49− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
50− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
51− )
52− }.getOrElse {
53− Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
54− null
55− }
56− }
57−
58− /** True when EncryptedSharedPreferences could not be initialized. */
59− open val securePrefsUnavailable: Boolean get() = securePrefs == null
60−
61− /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
62− * can still connect — but passwords won't survive an app reinstall. */
63− private val fallbackPrefs: SharedPreferences by lazy {
64− appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
65− }
66−
67− private fun prefsForPasswords(): SharedPreferences =
68− securePrefs ?: fallbackPrefs
69−
70− /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
71− * then clear the fallback. Runs at most once per process — subsequent calls
72− * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
73− private suspend fun migrateFallbackPasswords() {
74− if (!migrationDone.compareAndSet(false, true)) return
75− val secure = securePrefs ?: return
76− withContext(Dispatchers.IO) {
77− val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
78− if (fallbackKeys.isEmpty()) return@withContext
79− // Write passwords to the secure store FIRST, then remove them from the
80− // plaintext fallback. SharedPreferences.apply() persists to disk
81− // asynchronously, so removing from fallback before the secure batch lands
82− // could lose the password if the process dies between the two writes.
83− // Writing secure first makes the worst case a harmless duplicate (both
84− // stores hold it; the migration is idempotent) rather than data loss.
85− secure.edit().apply {
86− for (key in fallbackKeys) {
87− val pw = fallbackPrefs.getString(key, null) ?: continue
88− putString(key, pw)
89− }
90− }.apply()
91− fallbackPrefs.edit().apply {
92− for (key in fallbackKeys) remove(key)
93− }.apply()
94− }
95− }
96−
97− /** Stored shape on DataStore (everything except the secret password). */
98− @Serializable
99− private data class StoredProfile(
100− val id: String,
101− val label: String,
102− val baseUrl: String,
103− val username: String? = null,
104− val lastUsed: Long = 0,
105− )
106−
107− open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
108− // Migrate any orphaned plaintext passwords to secure prefs on first load.
109− migrateFallbackPasswords()
110− val json = prefs[profilesKey] ?: return@map emptyList()
111− val stored = runCatching {
112− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
113− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
114− .getOrDefault(emptyList())
115− .sortedByDescending { it.lastUsed }
116− // Batch all password lookups into a single IO dispatch instead of one
117− // withContext round-trip per profile (N dispatches → 1). DataStore already
118− // runs this map block on IO, but each passwordFor() call still pays the
119− // suspend + redispatch overhead.
120− val pwPrefs = prefsForPasswords()
121− stored.map { it.toProfile(pwPrefs) }
122− } ?: kotlinx.coroutines.flow.flowOf(emptyList())
123−
124− private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
125− val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
126− return ServerProfile(
127− id = id,
128− label = label,
129− baseUrl = baseUrl,
130− username = username,
131− password = pw,
132− lastUsed = lastUsed,
133− )
134− }
135−
136− open suspend fun resolve(profile: ServerProfile): ServerProfile {
137− val pw = if (profile.hasAuth) {
138− withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
139− } else {
140− null
141− }
142− return profile.copy(password = pw)
143− }
144−
145− open suspend fun save(profile: ServerProfile) {
146− // Write the password first so that if the DataStore write fails, we're left
147− // with an orphaned password (harmless, cleaned up on next save/delete) rather
148− // than a profile with no password (which would break authentication).
149− withContext(Dispatchers.IO) {
150− prefsForPasswords().edit().apply {
151− val pw = profile.password
152− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
153− putString(passwordKey(profile.id), pw)
154− } else {
155− remove(passwordKey(profile.id))
156− }
157− }.apply()
158− }
159− appContext!!.dataStore.edit { prefs ->
160− val current = prefs[profilesKey]?.let {
161− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
162− .getOrDefault(emptyList())
163− } ?: emptyList()
164− // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
165− // couldn't read the existing value due to a DataStore timeout), preserve
166− // the existing nonzero lastUsed so saving an edit doesn't reset the
167− // profile's sort position in the server list.
168− val preservedLastUsed = if (profile.lastUsed == 0L) {
169− current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
170− } else {
171− profile.lastUsed
172− }
173− val stored = StoredProfile(
174− id = profile.id,
175− label = profile.label,
176− baseUrl = profile.baseUrl,
177− username = profile.username?.takeIf { it.isNotBlank() },
178− lastUsed = preservedLastUsed,
179− )
180− val updated = current.filterNot { it.id == profile.id } + stored
181− prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
182− }
183− }
184−
185− open suspend fun delete(id: String) {
186− // Delete the profile from DataStore first, then the password. If the process
187− // dies between the two, an orphaned password remains (harmless, cleaned up on
188− // next save/delete) rather than a profile with no password.
189− appContext!!.dataStore.edit { prefs ->
190− val current = prefs[profilesKey]?.let {
191− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
192− .getOrDefault(emptyList())
193− } ?: emptyList()
194− prefs[profilesKey] = OpencodeJson.encodeToString(
195− ListSerializer(StoredProfile.serializer()),
196− current.filterNot { it.id == id },
197− )
198− }
199− // Clean up the password in both secure and fallback prefs so no orphaned
200− // plaintext password lingers if secure prefs were temporarily unavailable.
201− withContext(Dispatchers.IO) {
202− val key = passwordKey(id)
203− securePrefs?.edit()?.remove(key)?.apply()
204− fallbackPrefs.edit().remove(key).apply()
205− }
206− }
207−
208− private fun passwordKey(id: String) = "pw_$id"
209−}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktdeleted−366
@@ -1,366 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessagePartRemoved
5−import soy.iko.opencode.data.model.MessagePartUpdated
6−import soy.iko.opencode.data.model.MessageRemoved
7−import soy.iko.opencode.data.model.MessageUpdated
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.model.ModelRef
10−import soy.iko.opencode.data.model.Part
11−import soy.iko.opencode.data.model.SessionError
12−import soy.iko.opencode.data.model.SessionIdle
13−import soy.iko.opencode.data.model.UnknownMessage
14−import soy.iko.opencode.data.network.EventStreamClient
15−import soy.iko.opencode.data.network.NetworkConfig
16−import soy.iko.opencode.data.network.OpencodeApiClient
17−import soy.iko.opencode.util.runCatchingCancellable
18−import soy.iko.opencode.util.safeExceptionSummary
19−import android.util.Log
20−import kotlinx.coroutines.channels.Channel
21−import kotlinx.coroutines.flow.Flow
22−import kotlinx.coroutines.flow.channelFlow
23−import kotlinx.coroutines.flow.consumeAsFlow
24−import kotlinx.coroutines.flow.conflate
25−import kotlinx.coroutines.launch
26−import kotlinx.coroutines.sync.Mutex
27−import kotlinx.coroutines.sync.withLock
28−
29−/**
30− * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
31− * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
32− * each keyed by message id with parts keyed by part id (idempotent upserts).
33− */
34−open class SessionRepository(
35− private val api: OpencodeApiClient,
36− private val eventStream: EventStreamClient,
37−) {
38− open suspend fun listSessions() = api.listSessions()
39− open suspend fun createSession(title: String? = null) = api.createSession(title)
40− open suspend fun deleteSession(id: String) = api.deleteSession(id)
41− open suspend fun abort(sessionId: String) = api.abort(sessionId)
42−
43− open suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
44− api.sendPrompt(sessionId, text, model, agent)
45−
46− open suspend fun runCommand(
47− sessionId: String,
48− command: String,
49− arguments: String = "",
50− agent: String? = null,
51− ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
52−
53− /**
54− * A live, ordered view of [sessionId]'s messages. Begins collecting the event
55− * stream before the initial REST load so no streamed part is missed, then reduces
56− * events into the in-memory state. The flow is conflated so a fast burst of
57− * streaming tokens (each publishing a fresh snapshot) never back-pressures the
58− * event reducer — a slow collector simply sees the most recent snapshot.
59− */
60− open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
61− val store = MessageStore()
62− val lock = Mutex()
63− // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
64− // single snapshot+send per demand cycle. Previously every changed event called
65− // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
66− // those copies were immediately discarded by the outer .conflate() during fast
67− // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
68− // drain loop means N events in a burst allocate ~1 snapshot, not N.
69− val dirty = Channel<Unit>(Channel.CONFLATED)
70−
71− // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
72− // collapses repeated marks into a single pending signal, so a tight burst of
73− // events doesn't even allocate N marks.
74− fun publish() { dirty.trySend(Unit) }
75−
76− // Subscribe to events first so we don't miss early deltas during the initial load.
77− launch {
78− eventStream.events.collect { event ->
79− if (lock.withLock { store.reduce(sessionId, event) }) publish()
80− }
81− }
82−
83− // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
84− // is performed *outside* the lock so events arriving via SSE during the fetch are
85− // not blocked. The seed itself is under the lock; a seed generation counter ensures
86− // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
87− launch {
88− // Re-seed from REST when the SSE stream reconnects after a drop. The
89− // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
90− // the first successful connection so a subsequent reconnect triggers the
91− // re-seed. Resetting it on disconnect would make the condition always false
92− // after the first cycle, turning the re-seed into dead code.
93− //
94− // A seed generation counter guards against a stale re-seed clobbering a
95− // newer one: if two reconnects fire in quick succession, the older fetch
96− // (which returns later) is discarded because its generation no longer matches.
97− var hasConnectedBefore = false
98− var seedGeneration = 0
99− eventStream.state.collect { state ->
100− if (state == EventStreamClient.ConnectionState.Connected) {
101− if (hasConnectedBefore) {
102− val generation = ++seedGeneration
103− // Pivot before the fetch: parts already in memory predate this
104− // snapshot and must yield to it, while parts that stream in during
105− // the fetch (added to streamedSincePivot via reduce) outrank it.
106− lock.withLock { store.beginReseed() }
107− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
108− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
109− .getOrDefault(emptyList())
110− var seedChanged = false
111− lock.withLock {
112− if (generation == seedGeneration) {
113− // Merge without pruning: SSE events arriving during the
114− // REST fetch may have added messages not yet indexed by
115− // the REST endpoint. Pruning would silently delete them.
116− // Stale messages from deletions during the disconnect
117− // gap are eventually removed via MessageRemoved events
118− // or on the next app restart.
119− seedChanged = store.seed(fresh, prune = false)
120− }
121− }
122− if (seedChanged) publish()
123− }
124− hasConnectedBefore = true
125− }
126− }
127− }
128−
129− val initial = runCatchingCancellable { api.listMessages(sessionId) }
130− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
131− .getOrDefault(emptyList())
132− lock.withLock { store.seed(initial) }
133− publish()
134−
135− // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
136− // taken under the lock so a reader never sees a half-reduced state; send() runs
137− // outside the lock so a slow downstream collector can't stall the drain. This
138− // loop also keeps the flow alive until the collector cancels; the launched jobs
139− // above are children of this scope and are torn down with it.
140− dirty.consumeAsFlow().collect {
141− val snapshot = lock.withLock { store.snapshot() }
142− send(snapshot)
143− }
144− }.conflate()
145−
146− companion object {
147− /** Convenience: is this event a run-completion signal for [sessionId]?
148− * A null sessionID is NOT treated as a wildcard — an idle event with no
149− * session id must not reset the running state of every open chat. */
150− fun isIdle(event: BusEvent, sessionId: String): Boolean =
151− event is SessionIdle && event.properties.sessionID == sessionId
152−
153− fun isError(event: BusEvent, sessionId: String): Boolean =
154− event is SessionError && event.properties.sessionID == sessionId
155− }
156−}
157−
158−/**
159− * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
160− * Exposed as `internal` so the reducer logic can be unit-tested directly.
161− */
162−internal class MessageStore {
163− // messageId -> (info + parts), insertion-ordered.
164− private val messages = LinkedHashMap<String, MessageWithParts>()
165−
166− /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
167− * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
168− * so they're newer than the snapshot and must win over it; every other part is taken
169− * from the authoritative REST snapshot. Without this distinction the merge kept every
170− * overlapping in-memory part, so a part that changed server-side during the disconnect
171− * (e.g. a tool going running -> completed) stayed stale until app restart. */
172− private val streamedSincePivot = mutableSetOf<String>()
173−
174− /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
175− * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
176− * on a reconnect re-seed these are the messages whose info changed *during* the REST
177− * fetch, so the in-memory info is newer than the snapshot and must win; every other
178− * message's info is taken from the authoritative REST snapshot. Without this the merge
179− * kept the in-memory info, so a run that finished server-side during the disconnect
180− * (e.g. cost/token totals, completion time) stayed stale until app restart. */
181− private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
182−
183− /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
184− * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
185− fun beginReseed() {
186− streamedSincePivot.clear()
187− messageInfoUpdatedSincePivot.clear()
188− }
189−
190− /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
191− private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
192−
193− /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
194− internal val maxMessages = NetworkConfig.maxInMemoryMessages
195−
196− fun snapshot(): List<MessageWithParts> = messages.values.toList()
197−
198− fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
199− var changed = false
200− // On re-seed (after SSE reconnect), remove messages that are no longer in the
201− // server snapshot (e.g. deleted during the disconnection gap). This keeps the
202− // in-memory state in sync with the authoritative REST snapshot rather than
203− // accumulating stale messages forever. Skipped on the initial seed to avoid
204− // racing with just-arrived SSE events whose messages may not yet be in REST.
205− if (prune) {
206− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
207− val before = messages.size
208− messages.keys.retainAll(incomingIds)
209− if (messages.size != before) changed = true
210− }
211−
212− for (m in initial) {
213− val existing = messages[m.info.id]
214− if (existing == null) {
215− messages[m.info.id] = m
216− changed = true
217− } else {
218− // A part streamed in between subscribe and this initial load may already
219− // have populated this message (see observeMessages). Merge instead of
220− // overwriting so that newer streamed part isn't discarded: take the
221− // snapshot's part order, swap in the streamed version where ids overlap,
222− // append any streamed-only parts, and adopt the authoritative REST info.
223− val streamedById = existing.parts.associateBy { it.id }
224− val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
225− // Prefer the in-memory version of an overlapping part ONLY if it streamed
226− // in since the reseed pivot (i.e. during this fetch), so it's newer than
227− // the snapshot. Otherwise the REST snapshot is authoritative and wins,
228− // discarding an in-memory part that went stale during a disconnect.
229− val ordered = m.parts.map { p ->
230− if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
231− }.toMutableList()
232− for (p in existing.parts) {
233− if (p.id !in snapshotIds) ordered.add(p)
234− }
235− // Adopt the authoritative REST info unless the in-memory info was itself
236− // updated live since the pivot (i.e. during this fetch), in which case it's
237− // newer than the snapshot. Otherwise the REST snapshot wins, discarding
238− // in-memory info that went stale during a disconnect.
239− val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
240− val merged = MessageWithParts(info = info, parts = ordered)
241− if (merged != existing) {
242− messages[m.info.id] = merged
243− changed = true
244− }
245− }
246− }
247− if (evictOldMessages()) changed = true
248− // Reset the pivot: everything just merged is now the baseline, so only parts/info
249− // that stream in *after* this seed can outrank the next reconnect's snapshot.
250− streamedSincePivot.clear()
251− messageInfoUpdatedSincePivot.clear()
252− return changed
253− }
254−
255− /** Returns true if the state changed (and a new snapshot should be published). */
256− fun reduce(sessionId: String, event: BusEvent): Boolean {
257− return when (event) {
258− is MessageUpdated -> {
259− val info = event.properties.info
260− if (info.sessionID != sessionId) {
261− false
262− } else {
263− // An UnknownMessage with an empty id (e.g. from an unrecognized
264− // server role with no id field) would collide with other such
265− // messages in the map. Generate a unique synthetic key so each
266− // unknown message gets its own entry instead of overwriting others.
267− val key = if (info.id.isEmpty() && info is UnknownMessage) {
268− "unknown-${unknownCounter.incrementAndGet()}"
269− } else {
270− info.id
271− }
272− val existing = messages[key]
273− messages[key] = existing?.copy(info = info) ?: MessageWithParts(info)
274− // Record that this message's info streamed in live, so a re-seed after a
275− // reconnect knows it's newer than the REST snapshot and keeps it (see
276− // seed()/beginReseed()).
277− messageInfoUpdatedSincePivot.add(key)
278− if (existing == null) evictOldMessages()
279− true
280− }
281− }
282−
283− is MessagePartUpdated -> {
284− val part = event.properties.part
285− val messageId = part.messageID ?: event.properties.messageID
286− val partSession = part.sessionID ?: event.properties.sessionID
287− when {
288− messageId == null -> false
289− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
290− // No session id anywhere on the event: a null session is NOT a wildcard
291− // (mirroring isIdle/isError). Every open session's store sees the shared
292− // event stream, so accepting it unconditionally would leak the part into
293− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
294− // only if this store already holds the parent message — MessageUpdated,
295− // which always carries a session id, will have created it here first.
296− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
297− }
298− }
299−
300− is MessagePartRemoved -> handlePartRemoved(sessionId, event)
301−
302− is MessageRemoved -> handleMessageRemoved(sessionId, event)
303−
304− else -> false
305− }
306− }
307−
308− /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
309− private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
310− eventSession == null || eventSession == sessionId
311−
312− private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
313− val messageId = event.properties.messageID ?: return false
314− val partId = event.properties.partID ?: return false
315− if (!matchesSession(event.properties.sessionID, sessionId)) return false
316− return removePart(messageId, partId)
317− }
318−
319− private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
320− val id = event.properties.messageID ?: return false
321− if (!matchesSession(event.properties.sessionID, sessionId)) return false
322− return messages.remove(id) != null
323− }
324−
325− private fun upsertPart(messageId: String, part: Part): Boolean {
326− val current = messages[messageId]
327− val parts = current?.parts.orEmpty()
328− val idx = parts.indexOfFirst { it.id == part.id }
329− if (idx >= 0 && parts[idx] == part) return false
330− val newParts = if (idx >= 0) {
331− parts.toMutableList().also { it[idx] = part }
332− } else {
333− parts + part
334− }
335− messages[messageId] = current?.copy(parts = newParts)
336− ?: MessageWithParts(
337− info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
338− parts = newParts,
339− )
340− // Record that this part streamed in, so a re-seed after a reconnect knows it's
341− // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
342− streamedSincePivot.add(part.id)
343− if (current == null) evictOldMessages()
344− return true
345− }
346−
347− private fun removePart(messageId: String, partId: String): Boolean {
348− val current = messages[messageId] ?: return false
349− val newParts = current.parts.filterNot { it.id == partId }
350− if (newParts.size == current.parts.size) return false
351− messages[messageId] = current.copy(parts = newParts)
352− return true
353− }
354−
355− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
356− * Returns true if any messages were evicted. */
357− private fun evictOldMessages(): Boolean {
358− var evicted = false
359− while (messages.size > maxMessages) {
360− val oldestKey = messages.keys.iterator().next()
361− messages.remove(oldestKey)
362− evicted = true
363− }
364− return evicted
365− }
366−}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktdeleted−50
@@ -1,50 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.stringPreferencesKey
7−import androidx.datastore.preferences.preferencesDataStore
8−import kotlinx.coroutines.flow.Flow
9−import kotlinx.coroutines.flow.map
10−
11−enum class ThemeMode { SYSTEM, LIGHT, DARK }
12−
13−private val Context.settingsDataStore by preferencesDataStore(name = "settings")
14−
15−/** App-preferences store: theme mode, dynamic color (Material You), 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.ktdeleted−508
@@ -1,508 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import android.content.Context
4−import android.net.ConnectivityManager
5−import android.net.Network
6−import android.net.NetworkRequest
7−import android.util.Log
8−import soy.iko.opencode.data.model.BusEvent
9−import soy.iko.opencode.data.model.MessagePartUpdated
10−import soy.iko.opencode.data.model.MessageUpdated
11−import soy.iko.opencode.data.model.ServerProfile
12−import soy.iko.opencode.data.model.SessionIdle
13−import soy.iko.opencode.data.repo.DraftStore
14−import soy.iko.opencode.data.repo.ErrorKind
15−import soy.iko.opencode.data.repo.ProfileStore
16−import soy.iko.opencode.data.repo.SettingsStore
17−import soy.iko.opencode.data.repo.classifyError
18−import soy.iko.opencode.data.repo.responseStatusCode
19−import soy.iko.opencode.data.network.HttpClientFactory
20−import soy.iko.opencode.data.network.NetworkConfig
21−import soy.iko.opencode.data.network.OpencodeApiClient
22−import soy.iko.opencode.notification.NotificationChannels
23−import soy.iko.opencode.util.safeExceptionSummary
24−import soy.iko.opencode.notification.SessionNotifications
25−import soy.iko.opencode.R
26−import soy.iko.opencode.util.runCatchingCancellable
27−import kotlinx.coroutines.CoroutineScope
28−import kotlinx.coroutines.Dispatchers
29−import kotlinx.coroutines.ExperimentalCoroutinesApi
30−import kotlinx.coroutines.SupervisorJob
31−import kotlinx.coroutines.cancel
32−import kotlinx.coroutines.delay
33−import kotlinx.coroutines.flow.MutableStateFlow
34−import kotlinx.coroutines.flow.StateFlow
35−import kotlinx.coroutines.flow.asStateFlow
36−import kotlinx.coroutines.flow.emptyFlow
37−import kotlinx.coroutines.flow.first
38−import kotlinx.coroutines.flow.flatMapLatest
39−import kotlinx.coroutines.flow.update
40−import kotlinx.coroutines.isActive
41−import kotlinx.coroutines.launch
42−import kotlinx.coroutines.sync.Mutex
43−import kotlinx.coroutines.sync.withLock
44−import kotlinx.coroutines.withTimeoutOrNull
45−import java.util.concurrent.atomic.AtomicBoolean
46−
47−/**
48− * Result of probing a server URL to check connectivity and whether authentication
49− * is required, without persisting a profile or opening a long-lived connection.
50− */
51−sealed class ProbeResult {
52− /** Server is reachable and accepts unauthenticated requests. */
53− object Reachable : ProbeResult()
54−
55− /** Server is reachable but rejected the probe with an authentication error. */
56− object NeedsAuth : ProbeResult()
57−
58− /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
59− data class Unreachable(val error: String) : ProbeResult()
60−}
61−
62−/**
63− * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
64− * process-wide singletons and the currently active [OpencodeConnection].
65− */
66−open class AppContainer private constructor(
67− private val appContext: Context?,
68− private val skipInit: Boolean,
69− @Suppress("unused") private val testMode: Boolean,
70−) {
71− constructor(context: Context) : this(context.applicationContext, false, false)
72− protected constructor() : this(null, true, true)
73−
74− private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
75− open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
76− open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
77− open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
78−
79− private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
80− open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
81−
82− /** True while a background auto-reconnect to the most-recent server is in flight. */
83− private val _reconnecting = MutableStateFlow(false)
84− open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
85−
86− /**
87− * Set once a startup auto-reconnect succeeds. The server list collects this and
88− * (once) navigates straight to the session list so a returning user skips it.
89− * [consumeAutoConnect] guards against re-firing on screen re-entry.
90− */
91− private val _autoConnectDone = MutableStateFlow(false)
92− open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
93− private val autoConnectConsumed = AtomicBoolean(false)
94− open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
95−
96− /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
97− private val _pendingShare = MutableStateFlow<String?>(null)
98− open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
99− open fun setPendingShare(text: String) { _pendingShare.value = text }
100− open fun consumePendingShare(): String? {
101− val current = _pendingShare.value ?: return null
102− return if (_pendingShare.compareAndSet(current, null)) current else null
103− }
104−
105− /**
106− * A session id to open from an external trigger (a notification tap or a deep link).
107− * The nav host consumes it once a connection is active.
108− */
109− private val _pendingOpenSession = MutableStateFlow<String?>(null)
110− open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
111− open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
112− open fun consumePendingOpenSession(): String? {
113− val current = _pendingOpenSession.value ?: return null
114− return if (_pendingOpenSession.compareAndSet(current, null)) current else null
115− }
116−
117− /**
118− * The session the user is currently viewing (or null when not in a chat). Drives the
119− * unread tracker: messages arriving for any *other* session mark it unread, and
120− * opening a session clears its unread state.
121− */
122− private val _currentSession = MutableStateFlow<String?>(null)
123− open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
124−
125− /** Session ids that received activity while not being viewed, mapped to the count
126− * of unread message events. A count (not just a presence set) lets the session
127− * list badge show "3 unread" instead of a bare dot, so the user can tell whether
128− * one reply or a whole burst arrived. */
129− private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
130− open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
131−
132− /** True when any assistant run is actively streaming across all sessions. Drives the
133− * disconnect confirmation on the session list (disconnecting mid-run kills it). */
134− private val _anyRunActive = MutableStateFlow(false)
135− open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
136−
137− /** Whether the device currently has network connectivity. Distinct from the SSE
138− * connection state so the UI can tell "you're offline" (device) from "server
139− * unreachable" (credentials/host). Updated by the [networkCallback]. */
140− private val _isOnline = MutableStateFlow(true)
141− open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
142−
143− open fun setCurrentSession(id: String?) {
144− _currentSession.value = id
145− if (id != null) {
146− _unread.update { it - id }
147− unreadMessageIds.remove(id)
148− appContext?.let { SessionNotifications.cancel(it, id) }
149− }
150− }
151−
152− /** Restore a session's unread badge after a failed server switch reconnects.
153− * Preserves the prior [count] so a session badged with "5 unread" before the
154− * switch attempt still shows 5 (not 1) after the restore. No-op for the session
155− * the user is currently viewing, since a viewed session is never badged. */
156− open fun restoreUnread(id: String, count: Int) {
157− if (id != _currentSession.value) {
158− _unread.update { it + (id to count) }
159− }
160− }
161−
162− /** Resolve a localized string — view models reach resources through the container. */
163− open fun string(id: Int, vararg formatArgs: Any): String {
164− if (appContext == null) return ""
165− return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
166− }
167−
168− /**
169− * Convert a throwable into a user-facing message. Classifies the error by concrete
170− * type (network, timeout, HTTP status) rather than string-matching class names, so
171− * the message reflects what actually went wrong without leaking internal URLs/state.
172− */
173− open fun friendlyError(t: Throwable): String =
174− friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
175−
176− /**
177− * Same classification as [friendlyError] but accepts an explicit base URL, so callers
178− * that aren't operating on the active connection (e.g. [probeServer]) can still
179− * produce a user-facing message with the right server address.
180− */
181− open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
182− when (classifyError(t)) {
183− ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
184− string(R.string.error_not_reachable, baseUrl)
185− ErrorKind.TIMEOUT -> string(R.string.error_timeout)
186− ErrorKind.SERVER -> string(R.string.error_server)
187− // Show only the HTTP status (e.g. 401, 404) — never the request URL,
188− // which a ClientRequestException carries in its message and which we
189− // promised not to leak (it can include auth or internal paths).
190− ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
191− ?: string(R.string.error_generic)
192− ErrorKind.UNKNOWN -> string(R.string.error_generic)
193− }
194−
195− init {
196− if (!skipInit) {
197− NotificationChannels.create(appContext!!)
198− observeMessageActivity()
199− appScope.launch { autoConnect() }
200− }
201− }
202−
203− /** Release resources held for the process lifetime (network callback, app scope). */
204− open fun shutdown() {
205− // Cancel the app scope first so any coroutine holding the connection mutex
206− // (e.g. a connect() suspended on profileStore) is cancelled and releases the
207− // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
208− // that can't be cancelled until after the runBlocking completes.
209− appScope.cancel()
210− // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
211− // 2 seconds is generous: appScope.cancel() already triggered cancellation of
212− // any coroutine holding the mutex; we're just waiting for it to unwind.
213− kotlinx.coroutines.runBlocking {
214− withTimeoutOrNull(2_000) {
215− connectionMutex.withLock {
216− runCatching { _activeConnection.value?.close() }
217− _activeConnection.value = null
218− }
219− }
220− // If the timeout fired, force-clear the connection so it isn't left dangling.
221− _activeConnection.value = null
222− }
223− val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
224− networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
225− if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
226− if (!skipInit) draftStore.shutdown()
227− }
228−
229− /**
230− * Watch the SSE bus for new message activity and badge any session that isn't
231− * currently open. This powers the unread dot on the session list so the user can
232− * tell which conversations got a reply while they were elsewhere.
233− *
234− * Uses [flatMapLatest] so a server switch (which replaces the active connection)
235− * re-subscribes to the new event stream and drops the old one.
236− */
237− @OptIn(ExperimentalCoroutinesApi::class)
238− private fun observeMessageActivity() {
239− appScope.launch {
240− // Retry the observer if it fails for any reason (transient flow exception,
241− // cancellation due to scope issues). Without this, a single failure would
242− // permanently disable unread tracking and completion notifications.
243− while (isActive) {
244− runCatchingCancellable {
245− activeConnection
246− .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
247− .collect { event ->
248− // SessionIdle is not "message activity" (don't badge as unread)
249− // but signals a run finished — fire a completion notification if the
250− // session isn't currently being viewed and was actively streaming.
251− if (event is SessionIdle) {
252− val idleSid = event.properties.sessionID ?: return@collect
253− // Clear the run state unconditionally — even for the session on
254− // screen. Gating the removal on `idleSid != currentSession` (as
255− // this once did) leaks the id in activeRuns and pins anyRunActive
256− // true forever whenever a run finishes while being viewed. Only
257− // the completion *notification* is suppressed for the viewed one.
258− val wasRunning = synchronized(activeRuns) {
259− activeRuns.remove(idleSid).also { removed ->
260− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
261− }
262− }
263− if (wasRunning && idleSid != _currentSession.value) {
264− // Fire this off the collector's coroutine: notifySessionCompleted
265− // does a network listSessions() to resolve the title, and blocking
266− // the shared-event collector here would stall the SharedFlow,
267− // overflowing its DROP_OLDEST buffer and silently dropping live
268− // parts/updates for every other subscriber (e.g. the message reducer).
269− appScope.launch { notifySessionCompleted(idleSid) }
270− }
271− return@collect
272− }
273− val sid = sessionOf(event) ?: return@collect
274− // Re-read currentSession here, right before mutating the badge state:
275− // the user may have opened this session between when this event was
276− // enqueued and now (setCurrentSession clears its badge and dedup set).
277− // A stale earlier check would race a badge — and a dedup entry — back
278− // in for the session that's actually on screen.
279− if (sid != _currentSession.value) {
280− // Increment the unread count once per distinct *message*, not per
281− // event: a single reply emits many message.updated / message.part.
282− // updated events (one per streamed token, plus cost/token refreshes),
283− // which would otherwise inflate the badge into the dozens/hundreds.
284− val messageId = messageIdOfEvent(event)
285− val counted = unreadMessageIds.getOrPut(sid) {
286− java.util.Collections.synchronizedSet(mutableSetOf())
287− }
288− if (messageId == null || counted.add(messageId)) {
289− _unread.update { current ->
290− // Guard again inside the atomic update lambda so a retry
291− // (or an open that landed just now) can't reintroduce the
292− // badge for the session on screen.
293− if (sid == _currentSession.value) current
294− else current + (sid to (current[sid] ?: 0) + 1)
295− }
296− }
297− }
298− // Track sessions actively streaming so we know which idle events
299− // represent a finished run worth notifying about.
300− if (event is MessagePartUpdated || event is MessageUpdated) {
301− synchronized(activeRuns) {
302− if (activeRuns.size >= activeRunsLimit) {
303− activeRuns.remove(activeRuns.iterator().next())
304− }
305− activeRuns.add(sid)
306− }
307− _anyRunActive.value = true
308− }
309− } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
310− if (!isActive) break
311− delay(NetworkConfig.observerRetryDelayMs)
312− }
313− }
314− }
315−
316− /** Session ids currently streaming an assistant run (best-effort, in-process). */
317− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
318−
319− /** Per non-viewed session, the set of message ids already reflected in its unread
320− * count. De-duplicates the many streaming events that share one message id so the
321− * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
322− private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
323−
324− /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
325− private val activeRunsLimit = NetworkConfig.activeRunsLimit
326−
327− /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
328− private val connectionMutex = Mutex()
329−
330− /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
331− * a DataStore + encrypted-prefs write on every rapid reconnect. */
332− private companion object {
333− const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
334− }
335−
336− /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
337− private suspend fun notifySessionCompleted(sessionId: String) {
338− val conn = activeConnection.value ?: return
339− val title = runCatchingCancellable {
340− conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
341− }.getOrNull() ?: sessionId
342− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
343− }
344−
345− /** Extract the session id an event pertains to, for message-activity events. */
346− private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
347−
348− /**
349− * On cold start, transparently reconnect to the most recently used server so a
350− * returning user lands in their session list instead of the empty server screen.
351− */
352− private suspend fun autoConnect() {
353− val recent = runCatchingCancellable { profileStore.profiles.first() }
354− .getOrDefault(emptyList())
355− .firstOrNull() ?: return
356− _reconnecting.value = true
357− val ok = runCatchingCancellable {
358− val conn = connect(recent)
359− conn.api.ping()
360− }.isSuccess
361− _reconnecting.value = false
362− if (ok) _autoConnectDone.value = true else disconnect()
363− }
364−
365− /**
366− * When the device regains connectivity, nudge the active SSE stream to reconnect
367− * immediately instead of waiting out the exponential backoff.
368− */
369− private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
370−
371− private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
372− if (appContext == null) return null
373− val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
374− ?: return null
375− // Seed the online state from the current active network so the indicator is
376− // correct on cold start rather than defaulting to "online" until a callback fires.
377− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
378− val callback = object : ConnectivityManager.NetworkCallback() {
379− override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
380− override fun onLost(network: Network) { _isOnline.value = cm.activeNetwork != null }
381− }
382− runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
383− .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
384− return callback
385− }
386−
387− /** Open (or re-open) a connection to [profile], replacing any current one.
388− * Only persists the updated `lastUsed` timestamp if it's stale (older than a
389− * minute), so rapid reconnect storms don't each trigger a DataStore write. */
390− open suspend fun connect(profile: ServerProfile): OpencodeConnection =
391− connectionMutex.withLock {
392− _activeConnection.value?.close()
393− _activeConnection.value = null
394− activeRuns.clear()
395− _anyRunActive.value = false
396− _unread.value = emptyMap()
397− unreadMessageIds.clear()
398− val now = System.currentTimeMillis()
399− val resolved = profileStore.resolve(profile)
400− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
401− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
402− if (needsSave) profileStore.save(finalProfile)
403− OpencodeConnection(finalProfile).also { _activeConnection.value = it }
404− }
405−
406− open suspend fun disconnect() {
407− // Acquire the connection mutex so disconnect is serialized with connect().
408− // Previously this was fire-and-forget (launch on appScope), which raced with
409− // a subsequent connect(): connect() could acquire the mutex, create a new
410− // connection, and then the pending disconnect() would close it.
411− connectionMutex.withLock {
412− _activeConnection.value?.close()
413− _activeConnection.value = null
414− }
415− }
416−
417− /**
418− * Probe a server URL (without credentials) to check reachability and detect whether
419− * the server requires authentication. Builds a short-lived HTTP client with the same
420− * base URL normalization as [connect] but no auth, calls the health endpoint, and
421− * classifies the outcome:
422− *
423− * - 2xx → [ProbeResult.Reachable] (no auth needed)
424− * - 401/403 → [ProbeResult.NeedsAuth]
425− * - anything else → [ProbeResult.Unreachable] with a user-facing message
426− *
427− * The probe client is always closed afterwards so no resources linger. This does not
428− * touch the active connection or the profile store.
429− */
430− open suspend fun probeServer(baseUrl: String): ProbeResult {
431− return probeWithProfile(ServerProfile(
432− id = "probe",
433− label = "",
434− baseUrl = baseUrl.trim(),
435− username = null,
436− password = null,
437− ))
438− }
439−
440− /**
441− * Probe a server URL *with* credentials to validate them. Returns true if the
442− * server accepts the credentials (ping succeeds), false on 401/403, and throws
443− * on other failures (unreachable, timeout) so the caller can surface a friendly
444− * message. Mirrors [probeServer]'s short-lived-client pattern.
445− */
446− open suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
447− val probeProfile = ServerProfile(
448− id = "probe-auth",
449− label = "",
450− baseUrl = baseUrl.trim(),
451− username = username.trim().takeIf { it.isNotBlank() },
452− password = password.trim().takeIf { it.isNotEmpty() },
453− )
454− val client = HttpClientFactory.create(probeProfile)
455− return try {
456− val api = OpencodeApiClient(client)
457− api.ping()
458− true
459− } catch (e: Exception) {
460− val status = responseStatusCode(e)
461− if (status == 401 || status == 403) false else throw e
462− } finally {
463− runCatching { client.close() }
464− }
465− }
466−
467− private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
468− val client = HttpClientFactory.create(profile)
469− return try {
470− val api = OpencodeApiClient(client)
471− api.ping()
472− ProbeResult.Reachable
473− } catch (e: Exception) {
474− val status = responseStatusCode(e)
475− if (status == 401 || status == 403) {
476− ProbeResult.NeedsAuth
477− } else {
478− ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
479− }
480− } finally {
481− runCatching { client.close() }
482− }
483− }
484−}
485−
486−/**
487− * Extract the session id a message-activity event pertains to, or null for events that
488− * don't describe message activity. This drives the unread badge: any session that
489− * receives activity while it isn't the currently-viewed one gets badged. Extracted as
490− * a top-level `internal` function so the rule is unit-testable without an Android
491− * [Context] (the surrounding [AppContainer] needs one).
492− */
493−internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
494− is MessageUpdated -> event.properties.info.sessionID
495− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
496− else -> null
497−}
498−
499−/**
500− * The message id a message-activity event pertains to, used to de-duplicate the unread
501− * badge so a reply's many streaming events count once. Null for events that carry no
502− * message id (those fall back to being counted individually).
503− */
504−internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
505− is MessageUpdated -> event.properties.info.id
506− is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
507− else -> null
508−}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktdeleted−41
@@ -1,41 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import soy.iko.opencode.data.network.EventStreamClient
5−import soy.iko.opencode.data.network.HttpClientFactory
6−import soy.iko.opencode.data.network.OpencodeApiClient
7−import soy.iko.opencode.data.repo.SessionRepository
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.SupervisorJob
11−import kotlinx.coroutines.cancel
12−
13−/**
14− * An active connection to one opencode server. Bundles the Ktor client, REST API,
15− * SSE event stream, and the reducing repository, all sharing a connection-scoped
16− * coroutine scope that is cancelled on [close].
17− */
18−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.ktdeleted−34
@@ -1,34 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.NotificationChannel
4−import android.app.NotificationManager
5−import android.content.Context
6−import soy.iko.opencode.R
7−
8−/**
9− * The app's notification channels. Created once at startup (idempotent — recreating an
10− * existing channel is a no-op). Two channels keep user control granular:
11− *
12− * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13− * "agent running" indicator.
14− * - [COMPLETED]: default importance. Used when a background session finishes a run.
15− */
16−object NotificationChannels {
17− const val STATUS = "run_status"
18− const val COMPLETED = "session_completed"
19−
20− fun create(context: Context) {
21− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
22− nm.createNotificationChannel(
23− NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
24− description = context.getString(R.string.notif_channel_status_desc)
25− setShowBadge(false)
26− },
27− )
28− nm.createNotificationChannel(
29− NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
30− description = context.getString(R.string.notif_channel_completed_desc)
31− },
32− )
33− }
34−}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktdeleted−101
@@ -1,101 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.Notification
4−import android.app.PendingIntent
5−import android.app.Service
6−import android.content.Context
7−import android.content.Intent
8−import android.content.pm.ServiceInfo
9−import android.os.Build
10−import android.os.IBinder
11−import android.util.Log
12−import androidx.core.app.NotificationCompat
13−import androidx.core.app.ServiceCompat
14−import soy.iko.opencode.MainActivity
15−import soy.iko.opencode.R
16−
17−/**
18− * A foreground service kept alive while an opencode agent is actively running. Holding a
19− * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
20− * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
21− *
22− * The service is started when a run begins ([start]) and stopped when it goes idle
23− * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
24− * Started/stopped via Compose from the chat screen based on the `running` state.
25− */
26−class RunForegroundService : Service() {
27−
28− override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29− val notification = buildNotification()
30− // startForeground can throw ForegroundServiceStartNotAllowedException on
31− // Android 12+ if the app is in the background when the service starts. Wrap
32− // it so a backgrounded start (e.g. the user navigates away at the wrong
33− // moment) doesn't crash the app — the SSE stream will just continue without
34− // foreground priority and may be killed by the system sooner.
35− runCatching {
36− ServiceCompat.startForeground(
37− this,
38− NOTIF_ID,
39− notification,
40− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
41− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
42− } else {
43− 0
44− },
45− )
46− }.onFailure {
47− Log.w(TAG, "startForeground failed; running without foreground priority", it)
48− stopSelf()
49− }
50− return START_NOT_STICKY
51− }
52−
53− private fun buildNotification(): Notification {
54− // Tapping the notification opens the app so the user can see the running session.
55− val openIntent = Intent(this, MainActivity::class.java).apply {
56− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57− }
58− val pendingIntent = PendingIntent.getActivity(
59− this, 0, openIntent,
60− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61− )
62− return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63− .setSmallIcon(R.drawable.ic_launcher_foreground)
64− .setContentTitle(getString(R.string.notif_running_title))
65− .setContentText(getString(R.string.notif_running_text))
66− .setContentIntent(pendingIntent)
67− .setOngoing(true)
68− .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.ktdeleted−95
@@ -1,95 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.PendingIntent
4−import android.content.Context
5−import android.content.Intent
6−import android.os.Build
7−import android.util.Log
8−import androidx.core.app.NotificationCompat
9−import androidx.core.app.NotificationManagerCompat
10−import soy.iko.opencode.MainActivity
11−import soy.iko.opencode.R
12−import java.security.MessageDigest
13−
14−/**
15− * Builds and posts the "session completed" notification when a background session
16− * finishes a run. Tapping it opens the session via [MainActivity] with an
17− * [MainActivity.EXTRA_SESSION_ID] extra.
18− *
19− * Notification posting is guarded for Android 13+ runtime permission: if the user
20− * hasn't granted POST_NOTIFICATIONS, the post is silently skipped rather than throwing.
21− */
22−object SessionNotifications {
23−
24− private const val NOTIF_ID_PREFIX = 4000
25− private const val TAG = "SessionNotifications"
26−
27− 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+100−195
@@ -1,219 +1,124 @@
11package soy.iko.opencode.ui
22
3−import androidx.compose.animation.core.tween
4−import androidx.compose.animation.fadeIn
5−import androidx.compose.animation.fadeOut
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedContent
65import androidx.compose.animation.slideInHorizontally
76import androidx.compose.animation.slideOutHorizontally
8−import androidx.compose.foundation.layout.BoxWithConstraints
7+import androidx.compose.animation.togetherWith
8+import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
13+import androidx.compose.material3.SnackbarHost
14+import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
917import androidx.compose.runtime.Composable
1018import androidx.compose.runtime.LaunchedEffect
1119import androidx.compose.runtime.getValue
1220import androidx.compose.runtime.mutableStateOf
1321import androidx.compose.runtime.remember
14−import androidx.compose.runtime.rememberCoroutineScope
1522import androidx.compose.runtime.setValue
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
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
3630
3731@Composable
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()
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) }
4436
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 }
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)
6446 }
6547 }
6648
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
71−
72− // Open a session requested by a notification tap or deep link, once connected.
73− // In two-pane mode the detail pane consumes the request instead of navigating.
74− // Ensure SESSIONS is on the back stack below the chat destination so backing
75− // out of the chat lands on the session list (not the server list). The
76− // ServerListScreen's auto-connect also navigates to SESSIONS; without this
77− // popUpTo guard the two navigations race and can leave the stack as
78− // SERVERS -> chat (missing SESSIONS) or chat buried under SESSIONS.
79− LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
80− val id = pendingOpenSession ?: return@LaunchedEffect
81− if (connection == null || isTwoPane) return@LaunchedEffect
82− container.consumePendingOpenSession()
83− navController.navigate(Routes.chat(id)) {
84− launchSingleTop = true
85− popUpTo(Routes.SERVERS) { inclusive = false }
86− }
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
8756 }
57+ }
8858
89− NavHost(
90− navController = navController,
91− startDestination = Routes.SERVERS,
92− // Slide horizontally for forward/back navigation to feel native; fade for
93− // the root so the first frame doesn't slide in from off-screen.
94− enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
95− exitTransition = { fadeOut(tween(180)) },
96− popEnterTransition = { fadeIn(tween(180)) },
97− popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
98− ) {
99−
100− composable(Routes.SERVERS) {
101− ServerListScreen(
102− container = container,
103− onConnected = { navController.navigate(Routes.SESSIONS) },
104− onAddProfile = { navController.navigate(Routes.serverEdit()) },
105− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
106− onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
107− )
108− }
59+ if (showStopConfirm) {
60+ AlertDialog(
61+ onDismissRequest = { showStopConfirm = false },
62+ title = { Text("Stop generation?") },
63+ text = { Text("Leaving will stop the current generation.") },
64+ confirmButton = {
65+ TextButton(onClick = {
66+ showStopConfirm = false
67+ dispatch(Event.CancelGeneration)
68+ dispatch(Event.NavigateToSessions)
69+ }) {
70+ Text("Stop and leave")
71+ }
72+ },
73+ dismissButton = {
74+ TextButton(onClick = { showStopConfirm = false }) {
75+ Text("Stay")
76+ }
77+ },
78+ )
79+ }
10980
110− composable(
111− route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
112− arguments = listOf(
113− navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
114− navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
115− ),
116− ) { entry ->
117− ServerEditScreen(
118− container = container,
119− profileId = entry.arguments?.getString("id"),
120− sourceId = entry.arguments?.getString("dup"),
121− onDone = { navController.popBackStack() },
122− )
123− }
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+ }
124101
125− composable(Routes.SESSIONS) {
126− if (isTwoPane) {
127− TwoPaneSessionChat(
128− container = container,
129− onOpenFiles = { navController.navigate(Routes.FILES) },
130− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
131− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
132− onDisconnect = {
133− scope.launch { runCatchingCancellable { container.disconnect() } }
134− navController.popBackStack(Routes.SERVERS, inclusive = false)
135− },
136− onAddServer = { navController.navigate(Routes.serverEdit()) },
137− )
138− } else {
139− SessionListScreen(
140− container = container,
141− onOpenSession = { id ->
142− // Set the draft synchronously in-memory before navigating so the
143− // ChatScreen sees it on first composition. The async persistence
144− // to disk happens in the background via draftStore.set.
145− container.consumePendingShare()?.let { shareText ->
146− container.draftStore.setImmediate(id, shareText)
147− scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
148− }
149− navController.navigate(Routes.chat(id))
150− },
151− onDisconnect = {
152− scope.launch { runCatchingCancellable { container.disconnect() } }
153− navController.popBackStack(Routes.SERVERS, inclusive = false)
154− },
155− onOpenFiles = { navController.navigate(Routes.FILES) },
156− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
157− onAddServer = { navController.navigate(Routes.serverEdit()) },
158− )
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)
159121 }
160122 }
161−
162− composable(
163− route = "${Routes.CHAT}/{sessionId}",
164− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
165− ) { entry ->
166− ChatScreen(
167− container = container,
168− sessionId = entry.arguments?.getString("sessionId").orEmpty(),
169− onBack = { navController.popBackStack() },
170− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
171− )
172− }
173−
174− composable(Routes.FILES) {
175− FileBrowserScreen(
176− container = container,
177− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
178− onBack = { navController.popBackStack() },
179− )
180− }
181−
182− composable(
183− route = "${Routes.FILE_VIEW}?path={path}",
184− arguments = listOf(navArgument("path") { type = NavType.StringType; defaultValue = "" }),
185− ) { entry ->
186− FileViewScreen(
187− container = container,
188− path = entry.arguments?.getString("path").orEmpty(),
189− onBack = { navController.popBackStack() },
190− )
191− }
192−
193− composable(Routes.SETTINGS) {
194− SettingsScreen(
195− container = container,
196− onBack = { navController.popBackStack() },
197− // Navigate to the server list as a new entry on top of Sessions so the
198− // user can back out to their conversations. Previously this popped back
199− // to the root SERVERS destination, which removed Sessions from the stack
200− // and left the user unable to return to their conversations without
201− // reconnecting. Pop only Settings, then push SERVERS; back from the
202− // server list lands on Sessions (the screen the user was on before
203− // Settings), and connecting pushes a fresh SESSIONS as usual.
204− onManageServers = {
205− navController.navigate(Routes.SERVERS) {
206− popUpTo(Routes.SETTINGS) { inclusive = true }
207− launchSingleTop = true
208− }
209− },
210− onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
211− )
212− }
213−
214− composable(Routes.DIAGNOSTICS) {
215− DiagnosticsScreen(onBack = { navController.popBackStack() })
216− }
217− }
218123 }
219124}
app/src/main/java/soy/iko/opencode/ui/Routes.ktdeleted−23
@@ -1,23 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−/** String routes for the app's NavHost. Kept simple and centralized. */
4−object Routes {
5− const val SERVERS = "servers"
6− const val SESSIONS = "sessions"
7− const val SETTINGS = "settings"
8− const val DIAGNOSTICS = "diagnostics"
9− const val FILES = "files"
10−
11− const val SERVER_EDIT = "server_edit"
12− fun serverEdit(id: String? = null) =
13− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
14−
15− /** 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.ktdeleted−16
@@ -1,16 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewmodel.CreationExtras
5−import androidx.lifecycle.viewmodel.initializer
6−import androidx.lifecycle.viewmodel.viewModelFactory
7−
8−/**
9− * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10− * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11− */
12−inline fun <reified VM : ViewModel> vmFactory(
13− crossinline create: (CreationExtras) -> VM,
14−) = viewModelFactory {
15− initializer { create(this) }
16−}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktdeleted−192
@@ -1,192 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.foundation.text.KeyboardActions
13−import androidx.compose.foundation.text.KeyboardOptions
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.filled.Check
16−import androidx.compose.material.icons.filled.Search
17−import androidx.compose.material3.CircularProgressIndicator
18−import androidx.compose.material3.ExperimentalMaterial3Api
19−import androidx.compose.material3.Icon
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.TextButton
25−import androidx.compose.runtime.Composable
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateOf
28−import androidx.compose.runtime.remember
29−import androidx.compose.runtime.saveable.rememberSaveable
30−import androidx.compose.runtime.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
34−import androidx.compose.ui.res.stringResource
35−import androidx.compose.ui.semantics.Role
36−import androidx.compose.ui.semantics.contentDescription
37−import androidx.compose.ui.semantics.role
38−import androidx.compose.ui.semantics.selected
39−import androidx.compose.ui.semantics.semantics
40−import androidx.compose.ui.text.input.ImeAction
41−import androidx.compose.ui.unit.dp
42−import androidx.compose.foundation.layout.Box
43−import soy.iko.opencode.data.model.Agent
44−import soy.iko.opencode.R
45−
46−/** Bottom sheet that lists every agent and lets the user pick one. */
47−@OptIn(ExperimentalMaterial3Api::class)
48−@Composable
49−fun AgentPickerSheet(
50− agents: List<Agent>,
51− selected: String?,
52− loading: Boolean,
53− error: Boolean,
54− onSelect: (Agent?) -> Unit,
55− onRetry: () -> Unit,
56− onDismiss: () -> Unit,
57−) {
58− ModalBottomSheet(onDismissRequest = onDismiss) {
59− Text(
60− stringResource(R.string.agent),
61− style = MaterialTheme.typography.titleMedium,
62− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
63− )
64− if (loading) {
65− val loadingLabel = stringResource(R.string.loading)
66− Box(
67− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
68− contentAlignment = Alignment.Center,
69− ) {
70− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
71− }
72− } else if (error && agents.isEmpty()) {
73− Column(
74− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
75− horizontalAlignment = Alignment.CenterHorizontally,
76− ) {
77− Text(
78− stringResource(R.string.load_failed),
79− style = MaterialTheme.typography.bodyMedium,
80− color = MaterialTheme.colorScheme.error,
81− )
82− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
83− }
84− } else if (agents.isEmpty()) {
85− Text(
86− stringResource(R.string.no_agents),
87− style = MaterialTheme.typography.bodyMedium,
88− color = MaterialTheme.colorScheme.onSurfaceVariant,
89− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
90− )
91− } else {
92− var query by rememberSaveable { mutableStateOf("") }
93− val keyboardController = LocalSoftwareKeyboardController.current
94− OutlinedTextField(
95− value = query,
96− onValueChange = { query = it },
97− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
98− placeholder = { Text(stringResource(R.string.search_agents)) },
99− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
100− singleLine = true,
101− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
103− )
104− val filtered = remember(agents, query) {
105− val q = query.trim()
106− if (q.isEmpty()) agents
107− else agents.filter {
108− it.name.contains(q, ignoreCase = true) ||
109− it.displayDescription.contains(q, ignoreCase = true)
110− }
111− }
112− if (filtered.isEmpty()) {
113− Text(
114− stringResource(R.string.no_agents_match, query.trim()),
115− style = MaterialTheme.typography.bodyMedium,
116− color = MaterialTheme.colorScheme.onSurfaceVariant,
117− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
118− )
119− } else {
120− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
121− item(key = "__default") {
122− Column(
123− modifier = Modifier
124− .fillMaxWidth()
125− .semantics(mergeDescendants = true) {
126− if (selected == null) this.selected = true
127− }
128− .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
129− .padding(horizontal = 24.dp, vertical = 12.dp),
130− ) {
131− Row(verticalAlignment = Alignment.CenterVertically) {
132− Text(
133− stringResource(R.string.default_agent),
134− style = MaterialTheme.typography.bodyLarge,
135− color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
136− modifier = Modifier.weight(1f),
137− )
138− if (selected == null) {
139− Icon(
140− Icons.Filled.Check,
141− contentDescription = null,
142− tint = MaterialTheme.colorScheme.primary,
143− modifier = Modifier.size(18.dp),
144− )
145− }
146− }
147− Text(
148− stringResource(R.string.default_agent_desc),
149− style = MaterialTheme.typography.bodySmall,
150− color = MaterialTheme.colorScheme.onSurfaceVariant,
151− )
152− }
153− }
154− items(filtered, key = { it.name }) { agent ->
155− val isSelected = agent.name == selected
156− Column(
157− modifier = Modifier
158− .fillMaxWidth()
159− .semantics(mergeDescendants = true) {
160− if (isSelected) this.selected = true
161− }
162− .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
163− .padding(horizontal = 24.dp, vertical = 12.dp),
164− ) {
165− Row(verticalAlignment = Alignment.CenterVertically) {
166− Text(
167− agent.name,
168− style = MaterialTheme.typography.bodyLarge,
169− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
170− modifier = Modifier.weight(1f),
171− )
172− if (isSelected) {
173− Icon(
174− Icons.Filled.Check,
175− contentDescription = null,
176− tint = MaterialTheme.colorScheme.primary,
177− modifier = Modifier.size(18.dp),
178− )
179− }
180− }
181− Text(
182− agent.displayDescription,
183− style = MaterialTheme.typography.bodySmall,
184− color = MaterialTheme.colorScheme.onSurfaceVariant,
185− )
186− }
187− }
188− }
189− }
190− }
191− }
192−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktdeleted−900
@@ -1,900 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.activity.compose.BackHandler
4−import androidx.compose.animation.AnimatedVisibility
5−import androidx.compose.foundation.clickable
6−import androidx.compose.foundation.layout.Arrangement
7−import androidx.compose.foundation.layout.Box
8−import androidx.compose.foundation.layout.Column
9−import androidx.compose.foundation.layout.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.KeyboardArrowDown
30−import androidx.compose.material.icons.filled.Refresh
31−import androidx.compose.material.icons.filled.Share
32−import androidx.compose.material.icons.filled.SmartToy
33−import androidx.compose.material.icons.filled.Stop
34−import androidx.compose.material.icons.filled.Terminal
35−import androidx.compose.material.icons.filled.Tune
36−import androidx.compose.material3.AlertDialog
37−import androidx.compose.material3.Button
38−import androidx.compose.material3.CircularProgressIndicator
39−import androidx.compose.material3.ExperimentalMaterial3Api
40−import androidx.compose.material3.ExtendedFloatingActionButton
41−import androidx.compose.material3.FloatingActionButton
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.Surface
50−import androidx.compose.material3.Text
51−import androidx.compose.material3.TextButton
52−import androidx.compose.material3.TopAppBar
53−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
54−import androidx.compose.runtime.Composable
55−import androidx.compose.runtime.CompositionLocalProvider
56−import androidx.compose.runtime.DisposableEffect
57−import androidx.compose.runtime.LaunchedEffect
58−import androidx.compose.runtime.derivedStateOf
59−import androidx.compose.runtime.getValue
60−import androidx.compose.runtime.mutableStateOf
61−import androidx.compose.runtime.remember
62−import androidx.compose.runtime.rememberCoroutineScope
63−import androidx.compose.runtime.saveable.rememberSaveable
64−import androidx.compose.runtime.setValue
65−import androidx.compose.runtime.snapshotFlow
66−import androidx.compose.foundation.text.KeyboardActions
67−import androidx.compose.foundation.text.KeyboardOptions
68−import androidx.compose.ui.text.input.KeyboardCapitalization
69−import androidx.compose.ui.Alignment
70−import androidx.compose.ui.Modifier
71−import androidx.compose.ui.focus.focusRequester
72−import androidx.compose.ui.input.key.Key
73−import androidx.compose.ui.input.key.KeyEventType
74−import androidx.compose.ui.input.key.isCtrlPressed
75−import androidx.compose.ui.input.key.isShiftPressed
76−import androidx.compose.ui.input.key.key
77−import androidx.compose.ui.input.key.onPreviewKeyEvent
78−import androidx.compose.ui.input.key.type
79−import androidx.compose.ui.platform.LocalContext
80−import androidx.compose.ui.platform.LocalHapticFeedback
81−import androidx.compose.ui.platform.LocalView
82−import androidx.compose.ui.platform.testTag
83−import androidx.compose.ui.res.stringResource
84−import androidx.compose.ui.semantics.Role
85−import androidx.compose.ui.semantics.contentDescription
86−import androidx.compose.ui.semantics.semantics
87−import androidx.compose.ui.text.input.ImeAction
88−import androidx.compose.ui.text.style.TextOverflow
89−import androidx.compose.ui.unit.dp
90−import androidx.lifecycle.compose.collectAsStateWithLifecycle
91−import androidx.lifecycle.viewmodel.compose.viewModel
92−import kotlinx.coroutines.Dispatchers
93−import kotlinx.coroutines.launch
94−import kotlinx.coroutines.withContext
95−import soy.iko.opencode.data.model.Part
96−import soy.iko.opencode.data.model.ReasoningPart
97−import soy.iko.opencode.data.model.TextPart
98−import soy.iko.opencode.data.model.ToolCompleted
99−import soy.iko.opencode.data.model.ToolError
100−import soy.iko.opencode.data.model.ToolPart
101−import soy.iko.opencode.data.model.ToolRunning
102−import soy.iko.opencode.data.network.EventStreamClient
103−import soy.iko.opencode.data.network.NetworkConfig
104−import soy.iko.opencode.di.AppContainer
105−import soy.iko.opencode.R
106−import soy.iko.opencode.ui.components.ConnectionBanner
107−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
108−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
109−import soy.iko.opencode.ui.components.showToast
110−import soy.iko.opencode.ui.components.toImageContext
111−import soy.iko.opencode.ui.vmFactory
112−import soy.iko.opencode.util.runCatchingCancellable
113−
114−@OptIn(ExperimentalMaterial3Api::class)
115−@Composable
116−fun ChatScreen(
117− container: AppContainer,
118− sessionId: String,
119− onBack: () -> Unit,
120− onOpenFile: ((String) -> Unit)? = null,
121−) {
122− val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
123− val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
124− val running by vm.running.collectAsStateWithLifecycle()
125− val aborting by vm.aborting.collectAsStateWithLifecycle()
126− val loading by vm.loading.collectAsStateWithLifecycle()
127− val loadError by vm.loadError.collectAsStateWithLifecycle()
128− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
129− val models by vm.models.collectAsStateWithLifecycle()
130− val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
131− val modelsError by vm.modelsError.collectAsStateWithLifecycle()
132− val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
133− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
134− val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
135− val agents by vm.agents.collectAsStateWithLifecycle()
136− val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
137− val agentsError by vm.agentsError.collectAsStateWithLifecycle()
138− val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
139− val commands by vm.commands.collectAsStateWithLifecycle()
140− val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
141− val commandsError by vm.commandsError.collectAsStateWithLifecycle()
142− val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
143− val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
144− val draft by vm.draft.collectAsStateWithLifecycle()
145− val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
146− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
147− val isOnline by container.isOnline.collectAsStateWithLifecycle()
148− val haptics = LocalHapticFeedback.current
149− val scope = rememberCoroutineScope()
150− val shareContext = LocalContext.current
151− val shareLabel = stringResource(R.string.share)
152− val defaultShareSubject = stringResource(R.string.share_subject)
153− val sessionLabel = stringResource(R.string.session)
154− val defaultModelLabel = stringResource(R.string.default_model)
155− val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
156− buildString {
157− append(selectedModel?.modelLabel ?: defaultModelLabel)
158− selectedAgent?.let { append(" · $it") }
159− }
160− }
161−
162− // Connection profile (with auth) for rendering inline image attachments.
163− // Collect as state so a server switch recomposes the image context.
164− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
165− val imageContext = remember(activeConnection?.profile) {
166− activeConnection?.profile?.toImageContext()
167− }
168−
169− // Mark this session as read while the user is viewing it; clear on leave so new
170− // background activity can badge it again.
171− DisposableEffect(sessionId) {
172− container.setCurrentSession(sessionId)
173− onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
174− }
175−
176− // Navigate away when the session is deleted via SSE so the user isn't left
177− // on a zombie screen showing a conversation that no longer exists. Surface a
178− // toast (not a snackbar) because the screen is about to be popped — a snackbar
179− // would be torn down before the user could read it, while a toast survives.
180− val deletedContext = LocalContext.current
181− LaunchedEffect(sessionDeleted) {
182− if (sessionDeleted) {
183− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_msg))
184− onBack()
185− }
186− }
187−
188− val snackbar = remember { SnackbarHostState() }
189− val retryLabel = stringResource(R.string.retry)
190− val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
191− var showModelPicker by rememberSaveable { mutableStateOf(false) }
192− var showAgentPicker by rememberSaveable { mutableStateOf(false) }
193− var showCommandPicker by rememberSaveable { mutableStateOf(false) }
194− var showExitConfirm by rememberSaveable { mutableStateOf(false) }
195− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
196−
197− // Keep the screen awake and hold a foreground priority while the agent is working,
198− // so backgrounding mid-run doesn't let Doze choke the SSE stream.
199− val currentView = LocalView.current
200− val appContext = LocalContext.current.applicationContext
201− DisposableEffect(running) {
202− currentView.keepScreenOn = running
203− if (running) soy.iko.opencode.notification.RunForegroundService.start(appContext)
204− onDispose {
205− currentView.keepScreenOn = false
206− soy.iko.opencode.notification.RunForegroundService.stop(appContext)
207− }
208− }
209−
210− LaunchedEffect(Unit) {
211− vm.errorEvents.collect { msg ->
212− val result = if (vm.failedDraft.value != null) {
213− snackbar.showSnackbar(message = msg, actionLabel = retryLabel)
214− } else {
215− snackbar.showSnackbar(msg)
216− }
217− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
218− }
219− }
220−
221− fun doSend() {
222− if (vm.send(draft)) {
223− // TextHandleMove (a light tick) is semantically right for a send commit;
224− // LongPress is reserved for long-press/swipe gestures.
225− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
226− }
227− }
228−
229− BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
230−
231− Scaffold(
232− topBar = {
233− TopAppBar(
234− title = {
235− val changeLabel = stringResource(R.string.change_model_agent)
236− Row(
237− modifier = Modifier
238− .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
239− .semantics(mergeDescendants = true) { contentDescription = changeLabel },
240− verticalAlignment = Alignment.CenterVertically,
241− ) {
242− Column {
243− Text(sessionTitle ?: sessionLabel)
244− Text(
245− subtitle,
246− style = MaterialTheme.typography.labelSmall,
247− color = MaterialTheme.colorScheme.onSurfaceVariant,
248− )
249− }
250− // Visual affordance that the title opens the model picker;
251− // without it the tappable title is indistinguishable from plain
252− // text and the picker is effectively undiscoverable.
253− if (models.isNotEmpty()) {
254− Icon(
255− Icons.Filled.KeyboardArrowDown,
256− contentDescription = null,
257− tint = MaterialTheme.colorScheme.onSurfaceVariant,
258− modifier = Modifier.size(20.dp),
259− )
260− }
261− }
262− },
263− navigationIcon = {
264− IconButton(onClick = {
265− if (running) showExitConfirm = true else onBack()
266− }) {
267− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
268− }
269− },
270− actions = {
271− // At-a-glance run status in the top bar. When the user is scrolled
272− // up (jump-to-latest FAB visible), the input-bar Stop button and the
273− // trailing "working…" row are off-screen, so a top-bar spinner is the
274− // only signal the agent is still working.
275− if (running) {
276− val workingLabel = stringResource(R.string.working)
277− CircularProgressIndicator(
278− Modifier.size(18.dp).semantics { contentDescription = workingLabel },
279− strokeWidth = 2.dp,
280− )
281− }
282− // Manual refresh: forces an SSE reconnect, which re-seeds messages
283− // from REST. A recovery path when the stream silently drops and the
284− // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
285− // immediate tap feedback — the SSE reconnect may not visibly change
286− // the connection state when already Connected, so without it the tap
287− // appears to do nothing.
288− val refreshLabel = stringResource(R.string.refresh)
289− IconButton(
290− onClick = { vm.refreshMessages() },
291− enabled = activeConnection != null && !refreshing,
292− ) {
293− if (refreshing) {
294− CircularProgressIndicator(
295− Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
296− strokeWidth = 2.dp,
297− )
298− } else {
299− Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
300− }
301− }
302− IconButton(
303− onClick = {
304− scope.launch {
305− val md = withContext(Dispatchers.Default) {
306− buildConversationMarkdown(vm.messages.value, sessionTitle)
307− }
308− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
309− type = "text/markdown"
310− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
311− putExtra(android.content.Intent.EXTRA_TEXT, md)
312− }
313− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
314− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
315− }
316− },
317− enabled = hasMessages,
318− ) {
319− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share_conversation))
320− }
321− // Show a spinner while a catalog is initially loading (empty list +
322− // loading flag) so the icon doesn't look permanently disabled. Once
323− // loaded, the icon is always enabled — tapping a picker with no items
324− // shows the "no models/agents/commands" or error-with-retry state.
325− if (commandsLoading && commands.isEmpty()) {
326− val loadingLabel = stringResource(R.string.loading)
327− CircularProgressIndicator(
328− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
329− strokeWidth = 2.dp,
330− )
331− } else {
332− IconButton(onClick = { showCommandPicker = true }) {
333− Icon(Icons.Filled.Terminal, contentDescription = stringResource(R.string.commands))
334− }
335− }
336− if (agentsLoading && agents.isEmpty()) {
337− val loadingLabel = stringResource(R.string.loading)
338− CircularProgressIndicator(
339− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
340− strokeWidth = 2.dp,
341− )
342− } else {
343− IconButton(onClick = { showAgentPicker = true }) {
344− Icon(Icons.Filled.SmartToy, contentDescription = stringResource(R.string.choose_agent))
345− }
346− }
347− if (modelsLoading && models.isEmpty()) {
348− val loadingLabel = stringResource(R.string.loading)
349− CircularProgressIndicator(
350− Modifier.size(18.dp).semantics { contentDescription = loadingLabel },
351− strokeWidth = 2.dp,
352− )
353− } else {
354− IconButton(onClick = { showModelPicker = true }) {
355− Icon(Icons.Filled.Tune, contentDescription = stringResource(R.string.choose_model))
356− }
357− }
358− },
359− )
360− },
361− snackbarHost = { SnackbarHost(snackbar) },
362− bottomBar = {
363− val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
364− ChatInputBar(
365− value = draft,
366− onValueChange = vm::updateDraft,
367− running = running,
368− aborting = aborting,
369− enabled = activeConnection != null,
370− sendOnEnter = sendOnEnter,
371− onSend = ::doSend,
372− onAbort = { showStopConfirm = true },
373− queuedFollowUp = queuedFollowUp,
374− onQueueFollowUp = vm::queueFollowUp,
375− onCancelQueue = { vm.queueFollowUp("") },
376− focusRequester = inputFocusRequester,
377− )
378− },
379− ) { padding ->
380− // Collect messages inside the content lambda so streaming token updates
381− // recompose only this subtree, not the top bar / input bar / 20+ other
382− // state reads in the ChatScreen body.
383− val messages by vm.messages.collectAsStateWithLifecycle()
384− val listState = rememberLazyListState()
385− val contentScope = rememberCoroutineScope()
386− val timeTick = rememberRelativeTimeTick()
387−
388− val isPinnedToBottom by remember {
389− derivedStateOf {
390− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
391− // When a run is active, the LazyColumn has one trailing "__typing"
392− // item after the last message. Account for it so "pinned to bottom"
393− // includes the working indicator — otherwise the pin check targets
394− // messages.lastIndex, the typing row sits just below the viewport,
395− // and the user never sees the progress indicator even when pinned.
396− val effectiveLast = if (running) messages.size else messages.lastIndex
397− lastVisible >= effectiveLast || messages.isEmpty()
398− }
399− }
400−
401− LaunchedEffect(messages.isNotEmpty()) {
402− if (messages.isNotEmpty()) listState.scrollToItem(messages.lastIndex)
403− }
404−
405− // When a run starts, the LazyColumn gains a trailing "__typing" row, so
406− // effectiveLast jumps from messages.lastIndex to messages.size and the
407− // isPinnedToBottom check flips to false the instant you send — freezing
408− // auto-scroll. If the user was pinned to the last message just before the run
409− // began, bring the typing row into view so the pin (and streaming follow) is
410− // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
411− // recomputed to false by now) so we don't scroll when the user had scrolled up.
412− LaunchedEffect(running) {
413− if (running && messages.isNotEmpty()) {
414− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
415− if (lastVisible >= messages.lastIndex) {
416− runCatchingCancellable { listState.animateScrollToItem(messages.size) }
417− }
418− }
419− }
420−
421− LaunchedEffect(Unit) {
422− snapshotFlow {
423− // Track list size + the streaming length of the last part so we auto-scroll
424− // as content arrives, plus the pinned flag. A small data class with primitive
425− // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
426− // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
427− // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
428− // and tool output — not just TextPart — keeps a pinned view following a long
429− // reasoning/tool block while it streams (previously it stalled at 0).
430− val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
431− AutoScrollSignal(messages.size, lastLen, isPinnedToBottom)
432− }.collect { signal ->
433− if (signal.size > 0 && signal.pinned) {
434− // Scroll to the effective last index, including the trailing
435− // "__typing" row when a run is active so the working indicator
436− // is brought into view (not just the last message).
437− val target = if (running) messages.size else messages.lastIndex
438− listState.scrollToItem(target)
439− }
440− }
441− }
442−
443− CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
444− // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
445− // content padding accounts for the bottomBar's raised height, so the message
446− // list is already above the keyboard. Adding imePadding here would double-apply
447− // the IME inset and push messages too far up.
448− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
449− if (activeConnection == null) {
450− Column(
451− modifier = Modifier.align(Alignment.Center).padding(24.dp),
452− horizontalAlignment = Alignment.CenterHorizontally,
453− ) {
454− Text(
455− stringResource(R.string.not_connected),
456− color = MaterialTheme.colorScheme.error,
457− )
458− Spacer(Modifier.size(12.dp))
459− Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
460− if (reconnecting) {
461− CircularProgressIndicator(
462− Modifier.size(20.dp),
463− color = MaterialTheme.colorScheme.onPrimary,
464− strokeWidth = 2.dp,
465− )
466− } else {
467− Text(stringResource(R.string.reconnect))
468− }
469− }
470− }
471− } else {
472− ConnectionBanner(
473− state = connectionState,
474− modifier = Modifier.align(Alignment.TopCenter),
475− isOnline = isOnline,
476− // On a hard failure, retry by forcing an SSE reconnect (which
477− // re-seeds from REST). refreshMessages is the right recovery path
478− // when the connection is present but the stream died; reconnect()
479− // is the path when the whole connection is gone (handled by the
480− // separate "Not connected" state below).
481− onRetry = { vm.refreshMessages() },
482− )
483− if (loading && messages.isEmpty()) {
484− val loadingLabel = stringResource(R.string.loading)
485− CircularProgressIndicator(
486− Modifier
487− .align(Alignment.Center)
488− .semantics { contentDescription = loadingLabel },
489− )
490− } else if (loadError && messages.isEmpty()) {
491− // A failed load with nothing to show. Distinct from the empty
492− // conversation state below — the conversation isn't empty, it
493− // failed to load, and offering "Start a conversation" here is
494− // misleading. Offer a Retry instead, mirroring SessionListScreen.
495− Column(
496− modifier = Modifier.align(Alignment.Center).padding(24.dp),
497− horizontalAlignment = Alignment.CenterHorizontally,
498− ) {
499− Text(
500− stringResource(R.string.load_messages_failed),
501− color = MaterialTheme.colorScheme.error,
502− )
503− Spacer(Modifier.size(12.dp))
504− Button(onClick = { vm.refreshMessages() }) {
505− Text(stringResource(R.string.retry))
506− }
507− }
508− } else if (messages.isEmpty() && running) {
509− // A run just started but no parts have arrived yet — the empty
510− // list with only the trailing "working…" row looks broken, so
511− // surface a clear starting state until the first part streams in.
512− val workingText = stringResource(R.string.working)
513− Column(
514− modifier = Modifier.align(Alignment.Center).padding(24.dp),
515− horizontalAlignment = Alignment.CenterHorizontally,
516− ) {
517− CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
518− Spacer(Modifier.size(12.dp))
519− Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
520− }
521− } else if (messages.isEmpty()) {
522− EmptyConversation(
523− onSuggestion = {
524− vm.updateDraft(it)
525− // Focus the input so the user can edit the suggestion before
526− // sending, instead of having to manually tap the field.
527− runCatching { inputFocusRequester.requestFocus() }
528− },
529− modifier = Modifier.align(Alignment.Center),
530− )
531− } else {
532− // Pull-to-refresh wraps the message list so the user can force an SSE
533− // reconnect + re-seed with the same gesture they use on the session and
534− // file lists, instead of hunting for the top-bar refresh icon. The empty
535− // / loading / error states above stay outside the PTR box since there's
536− // no list content to pull against.
537− PullToRefreshBox(
538− isRefreshing = refreshing,
539− onRefresh = { vm.refreshMessages() },
540− modifier = Modifier.fillMaxSize(),
541− ) {
542− val lastMessageId = messages.lastOrNull()?.info?.id
543− LazyColumn(
544− state = listState,
545− modifier = Modifier.fillMaxSize(),
546− contentPadding = PaddingValues(16.dp),
547− verticalArrangement = Arrangement.spacedBy(16.dp),
548− ) {
549− items(messages, key = { it.info.id }, contentType = { it.info::class }) { message ->
550− // Only the last (streaming) message needs isRunning — it drives the
551− // reasoning-block spinner. Passing the live flag to every bubble
552− // makes all visible messages recompose whenever a run starts or stops.
553− val modelLabel = (message.info as? soy.iko.opencode.data.model.AssistantMessage)
554− ?.let { resolveModelLabel(it, models) }
555− MessageBubble(
556− message,
557− isRunning = running && message.info.id == lastMessageId,
558− imageContext = imageContext,
559− modelLabel = modelLabel,
560− onOpenFile = onOpenFile,
561− )
562− }
563− if (running) {
564− item(key = "__typing") {
565− val workingText = stringResource(R.string.working)
566− Row(
567− verticalAlignment = Alignment.CenterVertically,
568− modifier = Modifier.semantics {
569− contentDescription = workingText
570− },
571− ) {
572− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
573− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
574− }
575− }
576− }
577− }
578− }
579− // Jump-to-latest affordance when the user has scrolled away during a stream.
580− AnimatedVisibility(
581− visible = !isPinnedToBottom && messages.isNotEmpty(),
582− modifier = Modifier.align(Alignment.BottomEnd),
583− ) {
584− ExtendedFloatingActionButton(
585− onClick = {
586− if (messages.isNotEmpty()) {
587− // Scroll to the effective last index, including the
588− // trailing "__typing" row when a run is active.
589− val target = if (running) messages.size else messages.lastIndex
590− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
591− }
592− },
593− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
594− text = { Text(stringResource(R.string.latest)) },
595− modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
596− )
597− }
598− }
599− }
600− }
601− }
602− }
603−
604− if (showExitConfirm) {
605− AlertDialog(
606− onDismissRequest = { showExitConfirm = false },
607− title = { Text(stringResource(R.string.stop_and_exit_title)) },
608− text = { Text(stringResource(R.string.stop_and_exit_text)) },
609− confirmButton = {
610− TextButton(onClick = {
611− showExitConfirm = false
612− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
613− vm.abort()
614− onBack()
615− }) { Text(stringResource(R.string.stop_and_exit)) }
616− },
617− dismissButton = {
618− TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
619− },
620− )
621− }
622−
623− if (showStopConfirm) {
624− AlertDialog(
625− onDismissRequest = { showStopConfirm = false },
626− title = { Text(stringResource(R.string.stop_run_title)) },
627− text = { Text(stringResource(R.string.stop_run_text)) },
628− confirmButton = {
629− TextButton(onClick = {
630− showStopConfirm = false
631− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
632− vm.abort()
633− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
634− },
635− dismissButton = {
636− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
637− },
638− )
639− }
640−
641− if (showModelPicker) {
642− ModelPickerSheet(
643− options = models,
644− selected = selectedModel,
645− loading = modelsLoading,
646− error = modelsError,
647− onSelect = { vm.selectModel(it) },
648− onRetry = { vm.reloadModels() },
649− onDismiss = { showModelPicker = false },
650− )
651− }
652−
653− if (showAgentPicker) {
654− AgentPickerSheet(
655− agents = agents,
656− selected = selectedAgent,
657− loading = agentsLoading,
658− error = agentsError,
659− onSelect = { vm.selectAgent(it?.name) },
660− onRetry = { vm.reloadAgents() },
661− onDismiss = { showAgentPicker = false },
662− )
663− }
664−
665− if (showCommandPicker) {
666− CommandPickerSheet(
667− commands = commands,
668− loading = commandsLoading,
669− error = commandsError,
670− onSelect = { vm.runCommand(it) },
671− onRetry = { vm.reloadCommands() },
672− onDismiss = { showCommandPicker = false },
673− )
674− }
675−
676− pendingPermission?.let { permission ->
677− PermissionDialog(
678− permission = permission,
679− onRespond = { response -> vm.respondPermission(permission, response) },
680− )
681− }
682−}
683−
684−@Composable
685−private fun ChatInputBar(
686− value: String,
687− onValueChange: (String) -> Unit,
688− running: Boolean,
689− aborting: Boolean,
690− enabled: Boolean,
691− sendOnEnter: Boolean,
692− onSend: () -> Unit,
693− onAbort: () -> Unit,
694− queuedFollowUp: String?,
695− onQueueFollowUp: (String) -> Unit,
696− onCancelQueue: () -> Unit,
697− focusRequester: androidx.compose.ui.focus.FocusRequester,
698−) {
699− Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
700− Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
701− // A queued follow-up replaces the Stop button with a "queued" chip so the
702− // user sees their message will be sent when the run finishes, and can cancel.
703− if (queuedFollowUp != null) {
704− Row(
705− modifier = Modifier
706− .fillMaxWidth()
707− .padding(start = 12.dp, top = 6.dp, end = 4.dp),
708− verticalAlignment = Alignment.CenterVertically,
709− ) {
710− Column(modifier = Modifier.weight(1f)) {
711− Text(
712− stringResource(R.string.queued),
713− style = MaterialTheme.typography.labelSmall,
714− color = MaterialTheme.colorScheme.onSurfaceVariant,
715− )
716− Text(
717− queuedFollowUp,
718− style = MaterialTheme.typography.bodySmall,
719− color = MaterialTheme.colorScheme.onSurfaceVariant,
720− maxLines = 1,
721− overflow = TextOverflow.Ellipsis,
722− )
723− }
724− TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
725− Text(stringResource(R.string.cancel))
726− }
727− }
728− }
729− Row(
730− modifier = Modifier.fillMaxWidth().padding(8.dp),
731− verticalAlignment = Alignment.Bottom,
732− ) {
733− OutlinedTextField(
734− value = value,
735− onValueChange = { v ->
736− // Cap input length so a huge paste can't stall the UI. Shares are
737− // capped separately in MainActivity; this guards typed/pasted drafts.
738− onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
739− },
740− modifier = Modifier
741− .weight(1f)
742− .focusRequester(focusRequester)
743− .testTag("chat_input")
744− .onPreviewKeyEvent { event ->
745− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
746− // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
747− // With it off, Enter inserts a newline and Ctrl+Enter sends.
748− val send = when {
749− !enabled || value.isBlank() -> false
750− sendOnEnter -> !event.isShiftPressed
751− else -> event.isCtrlPressed
752− }
753− if (send) {
754− if (running) onQueueFollowUp(value) else onSend()
755− true
756− } else {
757− false
758− }
759− },
760− placeholder = { Text(stringResource(R.string.message_placeholder)) },
761− enabled = enabled,
762− maxLines = 6,
763− // Show a "N / max" countdown once the draft crosses a high fraction of
764− // the cap, so the user knows a paste is about to be truncated instead
765− // of being silently cut off. Hidden for normal short prompts to avoid
766− // clutter under a typical one-line message.
767− supportingText = {
768− val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
769− if (value.length >= threshold) {
770− Text(
771− stringResource(
772− R.string.draft_chars_remaining,
773− NetworkConfig.maxDraftLengthChars - value.length,
774− NetworkConfig.maxDraftLengthChars,
775− ),
776− style = MaterialTheme.typography.labelSmall,
777− color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
778− else MaterialTheme.colorScheme.onSurfaceVariant,
779− )
780− }
781− },
782− keyboardOptions = KeyboardOptions(
783− imeAction = ImeAction.Send,
784− capitalization = KeyboardCapitalization.Sentences,
785− ),
786− keyboardActions = KeyboardActions(onSend = {
787− if (enabled && value.isNotBlank()) {
788− if (running) onQueueFollowUp(value) else onSend()
789− }
790− }),
791− )
792− if (running) {
793− // Show a spinner while the abort REST call is in flight so the user
794− // sees the stop was sent, and disable to prevent a double-tap.
795− IconButton(
796− onClick = onAbort,
797− enabled = !aborting,
798− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
799− ) {
800− if (aborting) {
801− val stopLabel = stringResource(R.string.stop)
802− CircularProgressIndicator(
803− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
804− strokeWidth = 2.dp,
805− )
806− } else {
807− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
808− }
809− }
810− } else {
811− IconButton(
812− onClick = onSend,
813− enabled = enabled && value.isNotBlank(),
814− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
815− ) {
816− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
817− }
818− }
819− }
820− }
821− }
822−}
823−
824−@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
825−@Composable
826−private fun EmptyConversation(
827− onSuggestion: (String) -> Unit,
828− modifier: Modifier = Modifier,
829−) {
830− // A few example prompts so a first-time user has a one-tap path to their first
831− // message instead of staring at a blank input. Tapping a chip fills the draft
832− // (without sending) so the user can edit it before sending.
833− val suggestions = remember {
834− listOf(
835− R.string.empty_chat_suggest_1,
836− R.string.empty_chat_suggest_2,
837− R.string.empty_chat_suggest_3,
838− )
839− }
840− Column(
841− modifier = modifier.padding(32.dp),
842− horizontalAlignment = Alignment.CenterHorizontally,
843− ) {
844− Icon(
845− Icons.Filled.AutoAwesome,
846− contentDescription = null,
847− modifier = Modifier.size(48.dp),
848− tint = MaterialTheme.colorScheme.onSurfaceVariant,
849− )
850− Spacer(Modifier.size(16.dp))
851− Text(
852− stringResource(R.string.empty_chat_title),
853− style = MaterialTheme.typography.titleMedium,
854− )
855− Spacer(Modifier.size(8.dp))
856− Text(
857− stringResource(R.string.empty_chat_hint),
858− style = MaterialTheme.typography.bodyMedium,
859− color = MaterialTheme.colorScheme.onSurfaceVariant,
860− )
861− Spacer(Modifier.size(20.dp))
862− // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
863− // so a longer localized prompt doesn't push the chips off-screen on a phone.
864− FlowRow(
865− modifier = Modifier.fillMaxWidth(),
866− horizontalArrangement = Arrangement.spacedBy(8.dp),
867− verticalArrangement = Arrangement.spacedBy(8.dp),
868− ) {
869− suggestions.forEach { resId ->
870− val text = stringResource(resId)
871− androidx.compose.material3.AssistChip(
872− onClick = { onSuggestion(text) },
873− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
874− )
875− }
876− }
877− }
878−}
879−
880−/**
881− * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
882− * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
883− * equality is structural so `snapshotFlow` emits only when something relevant changes.
884− */
885−private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
886−
887−/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
888− * Covers text, reasoning, and tool output so a pinned view keeps following non-text
889− * streaming, not just plain assistant text. O(1). */
890−private fun streamingContentLength(part: Part?): Int = when (part) {
891− is TextPart -> part.text.length
892− is ReasoningPart -> part.text.length
893− is ToolPart -> when (val st = part.state) {
894− is ToolCompleted -> st.output?.length ?: 0
895− is ToolError -> st.error?.length ?: 0
896− is ToolRunning -> st.title?.length ?: 0
897− else -> 0
898− }
899− else -> 0
900−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktdeleted−688
@@ -1,688 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
5−import soy.iko.opencode.data.model.Agent
6−import soy.iko.opencode.data.model.Command
7−import soy.iko.opencode.data.model.MessageWithParts
8−import soy.iko.opencode.data.model.ModelOption
9−import soy.iko.opencode.data.model.Permission
10−import soy.iko.opencode.data.model.PermissionReplied
11−import soy.iko.opencode.data.model.PermissionResponse
12−import soy.iko.opencode.data.model.PermissionUpdated
13−import soy.iko.opencode.data.model.SessionDeleted
14−import soy.iko.opencode.data.model.SessionUpdated
15−import soy.iko.opencode.data.model.TextPart
16−import soy.iko.opencode.data.model.defaultOption
17−import soy.iko.opencode.data.model.toOptions
18−import soy.iko.opencode.data.network.EventStreamClient
19−import soy.iko.opencode.data.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.flow.MutableSharedFlow
29−import kotlinx.coroutines.flow.MutableStateFlow
30−import kotlinx.coroutines.flow.SharedFlow
31−import kotlinx.coroutines.flow.SharingStarted
32−import kotlinx.coroutines.flow.StateFlow
33−import kotlinx.coroutines.flow.asSharedFlow
34−import kotlinx.coroutines.flow.asStateFlow
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−@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
52−class ChatViewModel(
53− private val container: AppContainer,
54− private val sessionId: String,
55−) : ViewModel() {
56−
57− private val connection get() = container.activeConnection.value
58−
59− val connected: Boolean get() = connection != null
60−
61− private val _loading = MutableStateFlow(true)
62− val loading: StateFlow<Boolean> = _loading.asStateFlow()
63−
64− /** Set when the messages flow failed to load (and the list is empty), so the UI
65− * can render a distinct error state instead of masquerading as an empty
66− * conversation. Cleared on a successful non-empty emission. */
67− private val _loadError = MutableStateFlow(false)
68− val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
69−
70− /** Tracks whether the messages flow has ever emitted a non-empty list, so
71− * [messages]' retryWhen can decide whether a re-fetch failure should set
72− * [loadError] (nothing shown yet → error screen) or just emit a snackbar
73− * (already showing messages → don't replace the conversation with an error). */
74− private var hasShownMessages = false
75−
76− /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
77− * doesn't conflict with the messages flow's loading state. */
78− private val _reconnecting = MutableStateFlow(false)
79− val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
80−
81− /** When true, the debounced draft collector skips persisting an empty draft so
82− * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
83− * debounce timer firing before the send completes. */
84− private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
85−
86− /** The draft value this VM last persisted, so the drafts observer can tell its own
87− * (debounced, possibly stale) echo apart from a genuine external write. Without this
88− * a debounced persist of an older value could echo back and overwrite newer text the
89− * user has since typed. */
90− @Volatile private var lastPersistedDraft: String? = null
91−
92− /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
93− * collectLatest to cancel any in-flight fetch and start a fresh one, so a
94− * manual reload supersedes a stale observeCatalog fetch. */
95− private val _modelsReload = MutableStateFlow(0)
96− private val _agentsReload = MutableStateFlow(0)
97− private val _commandsReload = MutableStateFlow(0)
98−
99− val messages: StateFlow<List<MessageWithParts>> =
100− container.activeConnection
101− .flatMapLatest { conn ->
102− conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
103− }
104− .onEach {
105− _loading.value = false
106− if (it.isNotEmpty()) {
107− hasShownMessages = true
108− _loadError.value = false
109− }
110− }
111− .retryWhen { cause, _ ->
112− _loading.value = false
113− // Only surface the persistent error state when there's nothing to
114− // show — if we already have messages, a transient re-fetch failure
115− // is better surfaced as a snackbar (via errorEvents) than by
116− // replacing the visible conversation with an error screen.
117− if (!hasShownMessages) _loadError.value = true
118− _errorEvents.tryEmit(container.friendlyError(cause))
119− // Delay before retrying to avoid a tight loop on persistent errors.
120− delay(NetworkConfig.retryInitialDelayMs)
121− true
122− }
123− .stateIn(
124− scope = viewModelScope,
125− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
126− initialValue = emptyList(),
127− )
128−
129− /** Whether the conversation has any messages. Derived separately so the top bar
130− * (share button enabled state) can observe this cheap boolean instead of the
131− * full messages list, avoiding per-token recomposition of the app bar during
132− * streaming. */
133− val hasMessages: StateFlow<Boolean> = messages
134− .map { it.isNotEmpty() }
135− .stateIn(
136− scope = viewModelScope,
137− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
138− initialValue = false,
139− )
140−
141− private val _running = MutableStateFlow(false)
142− val running: StateFlow<Boolean> = _running.asStateFlow()
143−
144− /** True while an abort REST call is in flight, so the Stop button can show a
145− * spinner and prevent double-taps from firing a second abort. */
146− private val _aborting = MutableStateFlow(false)
147− val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
148−
149− /** A follow-up the user typed while a run was active. send() queues it here and
150− * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
151− * no way to send and no indication why the Send button is gone. */
152− private val _queuedFollowUp = MutableStateFlow<String?>(null)
153− val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
154−
155− /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
156− * emission is delivered independently — two rapid failures both get a snackbar
157− * instead of the second silently overwriting the first. */
158− private val _errorEvents = MutableSharedFlow<String>(
159− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
160− onBufferOverflow = BufferOverflow.DROP_OLDEST,
161− )
162− val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
163−
164− private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
165− val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
166−
167− private val _modelsLoading = MutableStateFlow(true)
168− val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
169−
170− private val _modelsError = MutableStateFlow(false)
171− val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
172−
173− private val _selectedModel = MutableStateFlow<ModelOption?>(null)
174− val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
175−
176− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
177− container.activeConnection
178− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
179− .stateIn(
180− scope = viewModelScope,
181− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
182− initialValue = EventStreamClient.ConnectionState.Disconnected,
183− )
184−
185− private val _pendingPermission = MutableStateFlow<Permission?>(null)
186− val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
187−
188− private val _agents = MutableStateFlow<List<Agent>>(emptyList())
189− val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
190−
191− private val _agentsLoading = MutableStateFlow(true)
192− val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
193−
194− private val _agentsError = MutableStateFlow(false)
195− val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
196−
197− private val _selectedAgent = MutableStateFlow<String?>(null)
198− val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
199−
200− private val _commands = MutableStateFlow<List<Command>>(emptyList())
201− val commands: StateFlow<List<Command>> = _commands.asStateFlow()
202−
203− private val _commandsLoading = MutableStateFlow(true)
204− val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
205−
206− private val _commandsError = MutableStateFlow(false)
207− val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
208−
209− private val _sessionTitle = MutableStateFlow<String?>(null)
210− val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
211−
212− /** Set when the current session is deleted via SSE, so the UI can navigate away. */
213− private val _sessionDeleted = MutableStateFlow(false)
214− val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
215−
216− /** The text of the last send that failed, surfaced so the UI can offer a retry. */
217− private val _failedDraft = MutableStateFlow<String?>(null)
218− val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
219−
220− /** Per-session draft, persisted so it survives navigation/process death. */
221− private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
222− val draft: StateFlow<String> = _draft.asStateFlow()
223−
224− init {
225− // DraftStore loads SharedPreferences asynchronously; on a cold start the
226− // synchronous get() above returns "" until the background load completes.
227− // Observe the ready signal and re-seed the draft so it appears in the UI.
228− viewModelScope.launch {
229− container.draftStore.ready.collect { ready ->
230− if (!ready) return@collect
231− if (_draft.value.isEmpty()) {
232− _draft.value = container.draftStore.get(sessionId)
233− }
234− // Recover a follow-up queued before process death (its in-memory StateFlow
235− // is gone). If the input is now free, drop it back there for review/send and
236− // consume the stored copy; otherwise re-queue it so it auto-sends when the
237− // current run next idles. Guarded on == null so it never clobbers a
238− // follow-up the user queued after opening this session.
239− val savedFollowUp = container.draftStore.getFollowUp(sessionId)
240− if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
241− if (_draft.value.isEmpty()) {
242− _draft.value = savedFollowUp
243− container.draftStore.flushFollowUp(sessionId, "")
244− } else {
245− _queuedFollowUp.value = savedFollowUp
246− }
247− }
248− }
249− }
250− // Observe the drafts store for external mutations to this session's draft
251− // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
252− // before navigation in single-pane mode). Without this, a share injected into
253− // an already-open session never appears in the input field — the init-time
254− // read and the ready-only re-seed miss it when the draft is non-empty.
255− // The VM's own debounced persist writes the same value _draft already holds,
256− // so those updates are no-ops here (storeValue == _draft.value). The
257− // suppressDraftPersist guard prevents a stale store value from clobbering
258− // the deliberate draft clear during a send (the store still holds the
259− // pre-send draft until the send resolves).
260− viewModelScope.launch {
261− container.draftStore.drafts.collect { drafts ->
262− // Ignore emissions until the initial disk load completes: the load itself
263− // emits the persisted snapshot, and applying it here would clobber text the
264− // user typed during the async load (the `ready` observer above seeds an
265− // empty draft). After ready, emissions are external writes.
266− if (!container.draftStore.ready.value) return@collect
267− val storeValue = drafts[sessionId].orEmpty()
268− // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
269− // genuine external write (e.g. a two-pane share injection) should overwrite
270− // the live draft.
271− if (storeValue == lastPersistedDraft) return@collect
272− if (storeValue != _draft.value && !suppressDraftPersist.get()) {
273− _draft.value = storeValue
274− }
275− }
276− }
277− }
278−
279− fun selectModel(option: ModelOption) { _selectedModel.value = option }
280−
281− fun selectAgent(name: String?) { _selectedAgent.value = name }
282−
283− fun reloadModels() {
284− val conn = connection ?: return
285− viewModelScope.launch {
286− conn.api.invalidateProvidersCache()
287− _modelsReload.value++
288− }
289− }
290−
291− fun reloadAgents() {
292− val conn = connection ?: return
293− viewModelScope.launch {
294− conn.api.invalidateAgentsCache()
295− _agentsReload.value++
296− }
297− }
298−
299− fun reloadCommands() {
300− val conn = connection ?: return
301− viewModelScope.launch {
302− conn.api.invalidateCommandsCache()
303− _commandsReload.value++
304− }
305− }
306−
307− /**
308− * Shared helper for init-block catalog observers: re-fetches whenever the active
309− * connection or the catalog's reload trigger changes, invoking [onNull] to clear
310− * state on null so stale data from the old server doesn't persist. The
311− * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
312− * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
313− * via collectLatest, preventing a stale observe result from overwriting a fresh
314− * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
315− */
316− private fun <T> observeCatalog(
317− tag: String,
318− loading: MutableStateFlow<Boolean>,
319− error: MutableStateFlow<Boolean>,
320− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
321− onSuccess: (T) -> Unit,
322− onNull: () -> Unit,
323− reloadTrigger: StateFlow<Int>,
324− ) {
325− viewModelScope.launch {
326− merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
327− val conn = container.activeConnection.value
328− if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
329− loading.value = true
330− error.value = false
331− runCatchingCancellable { fetch(conn.api) }
332− // fetch() does HTTP via withRetry, so the failure can be a
333− // ClientRequestException whose message embeds the full request URL
334− // (may contain auth/paths). Log only a scrubbed summary.
335− .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
336− .getOrNull()?.let(onSuccess)
337− loading.value = false
338− }
339− }
340− }
341−
342− fun updateDraft(text: String) {
343− _draft.value = text
344− }
345−
346− init {
347− // Debounce draft persistence so we don't write to disk on every keystroke.
348− // Skip persisting empty drafts that were set by send() (suppressed via
349− // suppressDraftPersist) — send() clears the in-memory draft immediately for
350− // UI feedback but deliberately doesn't persist the clear until the send
351− // succeeds, so a failed send can restore the draft.
352− viewModelScope.launch {
353− _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
354− if (text.isBlank() && suppressDraftPersist.get()) return@collect
355− lastPersistedDraft = text
356− runCatchingCancellable { container.draftStore.set(sessionId, text) }
357− .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
358− }
359− }
360− // Reset per-connection state when the active server changes so stale spinners,
361− // permission dialogs, errors, and agent selections from the old server don't
362− // persist into the new one.
363− viewModelScope.launch {
364− container.activeConnection.collectLatest { conn ->
365− if (conn == null) return@collectLatest
366− _running.value = false
367− _pendingPermission.value = null
368− _failedDraft.value = null
369− // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
370− // user intent that is now persisted; wiping it on every (re)connect — which
371− // this collector does, including transient SSE drops and the cold-start
372− // restore path — would drop a legitimately queued/recovered follow-up.
373− _selectedAgent.value = null
374− _sessionTitle.value = null
375− try {
376− conn.events.events.collect { event ->
377− if (SessionRepository.isIdle(event, sessionId)) {
378− _running.value = false
379− // Auto-send a queued follow-up once the previous run finishes,
380− // so the user's drafted-while-running message isn't lost.
381− val queued = _queuedFollowUp.value
382− if (queued != null) {
383− setQueuedFollowUp(null)
384− send(queued)
385− }
386− }
387− if (SessionRepository.isError(event, sessionId)) {
388− _running.value = false
389− setQueuedFollowUp(null)
390− _errorEvents.tryEmit(container.string(R.string.error_agent_reported))
391− }
392− when (event) {
393− is PermissionUpdated ->
394− if (event.properties.sessionID == sessionId) _pendingPermission.value = event.properties
395− is PermissionReplied ->
396− if (event.properties.sessionID == sessionId && event.properties.permissionID == _pendingPermission.value?.id) _pendingPermission.value = null
397− is SessionUpdated ->
398− if (event.properties.info.id == sessionId) _sessionTitle.value = event.properties.info.displayTitle
399− is SessionDeleted ->
400− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
401− _sessionDeleted.value = true
402− }
403− else -> {}
404− }
405− }
406− } catch (e: CancellationException) {
407− throw e
408− } catch (e: Exception) {
409− Log.w("ChatViewModel", "SSE event collector error", e)
410− }
411− }
412− }
413− // Load the model catalog; preselect the server default. Failure is non-fatal —
414− // sending with no model just uses the server's default agent/model.
415− observeCatalog(
416− tag = "model catalog",
417− loading = _modelsLoading,
418− error = _modelsError,
419− fetch = { it.providers() },
420− onSuccess = { resp ->
421− val options = resp.toOptions()
422− _models.value = options
423− // Only (re)apply the server default when there's no valid current
424− // selection. On re-fetch/reconnect/Retry the user's chosen model must
425− // survive — keep it as long as it's still present in the refreshed list.
426− val current = _selectedModel.value
427− if (current == null || options.none { it.ref == current.ref }) {
428− _selectedModel.value = resp.defaultOption(options)
429− }
430− },
431− onNull = { _models.value = emptyList(); _selectedModel.value = null },
432− reloadTrigger = _modelsReload,
433− )
434− // Load the agent catalog (non-fatal).
435− observeCatalog(
436− tag = "agent catalog",
437− loading = _agentsLoading,
438− error = _agentsError,
439− fetch = { it.agents() },
440− onSuccess = { _agents.value = it },
441− onNull = { _agents.value = emptyList() },
442− reloadTrigger = _agentsReload,
443− )
444− // Load the command catalog (non-fatal).
445− observeCatalog(
446− tag = "command catalog",
447− loading = _commandsLoading,
448− error = _commandsError,
449− fetch = { it.commands() },
450− onSuccess = { _commands.value = it },
451− onNull = { _commands.value = emptyList() },
452− reloadTrigger = _commandsReload,
453− )
454− // Resolve the human-readable session title for the app bar (non-fatal).
455− // Skips the REST call when the SSE event collector already delivered the title
456− // (SessionUpdated fires shortly after connect), avoiding a redundant full
457− // session-list download.
458− viewModelScope.launch {
459− container.activeConnection.collectLatest { conn ->
460− if (conn == null) return@collectLatest
461− if (_sessionTitle.value != null) return@collectLatest
462− runCatchingCancellable { conn.repository.listSessions() }
463− .getOrNull()
464− ?.firstOrNull { it.id == sessionId }
465− ?.let { _sessionTitle.value = it.displayTitle }
466− }
467− }
468− // If the SSE stream drops mid-run, the run indicator would spin forever;
469− // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
470− viewModelScope.launch {
471− container.activeConnection.collectLatest { conn ->
472− if (conn == null) return@collectLatest
473− try {
474− conn.events.state.collect { state ->
475− if ((state == EventStreamClient.ConnectionState.Disconnected ||
476− state == EventStreamClient.ConnectionState.Failed) && _running.value) {
477− _running.value = false
478− }
479− }
480− } catch (e: CancellationException) {
481− throw e
482− } catch (e: Exception) {
483− Log.w("ChatViewModel", "SSE state collector error", e)
484− }
485− }
486− }
487− }
488−
489− /** Sends [text]; returns true on success so the caller can clear the draft only then. */
490− fun send(text: String): Boolean {
491− val conn = connection ?: return false
492− val trimmed = text.trim()
493− if (trimmed.isEmpty() || !_running.compareAndSet(false, true)) return false
494− _failedDraft.value = null
495− // Clear the in-memory draft for the UI immediately, but don't persist the clear
496− // yet — if the send fails and the process dies before we restore, the draft
497− // would be lost forever. The persisted draft is cleared only on success.
498− // suppressDraftPersist prevents the debounced collector from persisting the
499− // empty draft before the send resolves.
500− //
501− // Only clear when the text being sent IS the current draft. send() is also
502− // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
503− // the user may have typed a NEW draft since — wiping it would lose that text.
504− if (_draft.value.trim() == trimmed) {
505− suppressDraftPersist.set(true)
506− _draft.value = ""
507− }
508− viewModelScope.launch {
509− runCatchingCancellable {
510− conn.repository.sendPrompt(
511− sessionId,
512− trimmed,
513− model = _selectedModel.value?.ref,
514− agent = _selectedAgent.value,
515− )
516− }.onFailure {
517− suppressDraftPersist.set(false)
518− _failedDraft.value = trimmed
519− // Only restore the draft if the user hasn't typed anything new since.
520− if (_draft.value.isBlank()) updateDraft(trimmed)
521− _errorEvents.tryEmit(container.friendlyError(it))
522− _running.value = false
523− }.onSuccess {
524− suppressDraftPersist.set(false)
525− // Send succeeded — persist the cleared draft, but only if the user hasn't
526− // typed a new one while the send was in flight. Otherwise clearing the
527− // store echoes back through the draft observer and wipes the in-progress
528− // text (data loss). Mirrors the guard on the failure path above.
529− if (_draft.value.isBlank()) {
530− lastPersistedDraft = ""
531− container.draftStore.set(sessionId, "")
532− }
533− // Don't reset _running here: the agent continues streaming via SSE.
534− // _running is cleared on SessionIdle/SessionError (see event collector)
535− // or when the SSE stream drops (see connection state watcher below).
536− }
537− }
538− return true
539− }
540−
541− /** Re-send the last draft whose send failed, if any. */
542− fun retryFailed() {
543− val draft = _failedDraft.value ?: return
544− // Don't clear _failedDraft until send() accepts the text — if _running is
545− // already true, send() returns false and the draft would be lost forever.
546− if (send(draft)) {
547− _failedDraft.value = null
548− }
549− }
550−
551− /**
552− * Queue [text] to be sent automatically when the current run finishes, or clear any
553− * queued follow-up when [text] is blank. Used when the user taps Send while a run
554− * is already active — the Send button is replaced by Stop during a run, but the
555− * input field stays enabled, so a follow-up typed mid-run would otherwise have
556− * nowhere to go.
557− */
558− fun queueFollowUp(text: String) {
559− val trimmed = text.trim()
560− setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
561− // The text now lives in the queued chip, so clear the input field just as a
562− // normal send would. No-op for the cancel case (blank text), which must leave
563− // whatever the user has since typed untouched.
564− if (trimmed.isNotEmpty()) _draft.value = ""
565− }
566−
567− /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
568− * queued mid-run survives process death (recovered on the next open of this session).
569− * Passing null / blank clears both the in-memory value and the persisted copy. */
570− private fun setQueuedFollowUp(text: String?) {
571− _queuedFollowUp.value = text
572− container.draftStore.flushFollowUp(sessionId, text.orEmpty())
573− }
574−
575− /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
576− * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
577− * refreshMessages may not visibly change [connectionState] (it's already
578− * Connected), so without this the tap appears to do nothing. Clears after a
579− * short delay or when the connection state next becomes Connected. */
580− private val _refreshing = MutableStateFlow(false)
581− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
582−
583− /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
584− * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
585− * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
586− * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
587− * existing generation-guarded merge. Also clears [loadError] optimistically and
588− * sets [refreshing] for immediate tap feedback. */
589− fun refreshMessages() {
590− val conn = connection ?: return
591− _loadError.value = false
592− _refreshing.value = true
593− conn.events.triggerReconnect()
594− viewModelScope.launch {
595− delay(NetworkConfig.refreshFeedbackMs)
596− _refreshing.value = false
597− }
598− }
599−
600− /** Invoke a slash-command by name via the server's /command endpoint. */
601− fun runCommand(command: Command) {
602− val conn = connection ?: return
603− if (!_running.compareAndSet(false, true)) return
604− viewModelScope.launch {
605− runCatchingCancellable {
606− conn.repository.runCommand(sessionId, command.name, agent = command.agent)
607− }.onFailure {
608− _errorEvents.tryEmit(container.friendlyError(it))
609− _running.value = false
610− }
611− }
612− }
613−
614− fun abort() {
615− val conn = connection ?: return
616− // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
617− // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
618− setQueuedFollowUp(null)
619− if (!_aborting.compareAndSet(false, true)) return
620− viewModelScope.launch {
621− try {
622− runCatchingCancellable { conn.repository.abort(sessionId) }
623− .onSuccess { _running.value = false }
624− .onFailure { _errorEvents.tryEmit(container.friendlyError(it)) }
625− } finally {
626− _aborting.value = false
627− }
628− }
629− }
630−
631− /** Reconnect to the most recently used server profile (used when the connection is gone). */
632− fun reconnect() {
633− if (container.activeConnection.value != null) return
634− // Guard against concurrent reconnect calls: the user can tap the button
635− // multiple times while _reconnecting is true, which would launch parallel
636− // connect() coroutines that race to set the active connection, leaking the
637− // intermediate connections.
638− if (!_reconnecting.compareAndSet(false, true)) return
639− viewModelScope.launch {
640− try {
641− val recent = runCatchingCancellable { container.profileStore.profiles.first() }
642− .getOrDefault(emptyList())
643− .firstOrNull()
644− if (recent == null) {
645− _errorEvents.tryEmit(container.string(R.string.no_servers_to_reconnect))
646− return@launch
647− }
648− runCatchingCancellable {
649− val conn = container.connect(recent)
650− conn.api.ping()
651− }.onFailure {
652− container.disconnect()
653− _errorEvents.tryEmit(container.friendlyError(it))
654− }
655− } finally {
656− _reconnecting.value = false
657− }
658− }
659− }
660−
661− fun respondPermission(permission: Permission, response: PermissionResponse) {
662− val conn = connection ?: return
663− // Clear optimistically; permission.replied will confirm. If the call fails we
664− // restore the pending permission so the user can retry instead of being stuck
665− // with a dismissed dialog and a paused tool run.
666− _pendingPermission.value = null
667− viewModelScope.launch {
668− runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
669− .onFailure {
670− _errorEvents.tryEmit(container.friendlyError(it))
671− // Only restore if no new permission has arrived in the meantime.
672− if (_pendingPermission.value == null) _pendingPermission.value = permission
673− }
674− }
675− }
676−
677− override fun onCleared() {
678− super.onCleared()
679− // Flush any pending debounced draft so it survives navigation. Uses an
680− // asynchronous apply() (not a synchronous commit) so the main thread isn't
681− // blocked on disk I/O — Android's SharedPreferences framework guarantees
682− // pending apply() writes are flushed before the process exits.
683− val pending = _draft.value
684− if (pending.isNotEmpty()) {
685− container.draftStore.flushDraft(sessionId, pending)
686− }
687− }
688−}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktdeleted−193
@@ -1,193 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.foundation.lazy.LazyColumn
9−import androidx.compose.foundation.lazy.items
10−import androidx.compose.foundation.text.KeyboardActions
11−import androidx.compose.foundation.text.KeyboardOptions
12−import androidx.compose.material.icons.Icons
13−import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
14−import androidx.compose.material.icons.filled.Search
15−import androidx.compose.material3.CircularProgressIndicator
16−import androidx.compose.material3.ExperimentalMaterial3Api
17−import androidx.compose.material3.Icon
18−import androidx.compose.material3.MaterialTheme
19−import androidx.compose.material3.ModalBottomSheet
20−import androidx.compose.material3.OutlinedTextField
21−import androidx.compose.material3.Text
22−import androidx.compose.material3.AlertDialog
23−import androidx.compose.material3.TextButton
24−import androidx.compose.runtime.Composable
25−import androidx.compose.runtime.getValue
26−import androidx.compose.runtime.mutableStateOf
27−import androidx.compose.runtime.remember
28−import androidx.compose.runtime.saveable.rememberSaveable
29−import androidx.compose.runtime.setValue
30−import androidx.compose.ui.Alignment
31−import androidx.compose.ui.Modifier
32−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
33−import androidx.compose.ui.res.stringResource
34−import androidx.compose.ui.semantics.Role
35−import androidx.compose.ui.semantics.contentDescription
36−import androidx.compose.ui.semantics.semantics
37−import androidx.compose.ui.text.input.ImeAction
38−import androidx.compose.ui.text.style.TextOverflow
39−import androidx.compose.ui.unit.dp
40−import androidx.compose.foundation.layout.Box
41−import soy.iko.opencode.data.model.Command
42−import soy.iko.opencode.R
43−
44−/** Bottom sheet that lists slash-commands; selecting one invokes it. */
45−@OptIn(ExperimentalMaterial3Api::class)
46−@Composable
47−fun CommandPickerSheet(
48− commands: List<Command>,
49− loading: Boolean,
50− error: Boolean,
51− onSelect: (Command) -> Unit,
52− onRetry: () -> Unit,
53− onDismiss: () -> Unit,
54−) {
55− // Persist only the command *name* (a savable String), not the Command itself: a
56− // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
57− // routing it through rememberSaveable crashes on rotation while the confirm dialog
58− // is open. Re-resolve the full Command from the current list for rendering.
59− var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
60− val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
61− ModalBottomSheet(onDismissRequest = onDismiss) {
62− Text(
63− stringResource(R.string.commands),
64− style = MaterialTheme.typography.titleMedium,
65− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
66− )
67− if (loading) {
68− val loadingLabel = stringResource(R.string.loading)
69− Box(
70− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
71− contentAlignment = Alignment.Center,
72− ) {
73− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
74− }
75− } else if (error && commands.isEmpty()) {
76− Column(
77− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
78− horizontalAlignment = Alignment.CenterHorizontally,
79− ) {
80− Text(
81− stringResource(R.string.load_failed),
82− style = MaterialTheme.typography.bodyMedium,
83− color = MaterialTheme.colorScheme.error,
84− )
85− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
86− }
87− } else if (commands.isEmpty()) {
88− Text(
89− stringResource(R.string.no_commands),
90− style = MaterialTheme.typography.bodyMedium,
91− color = MaterialTheme.colorScheme.onSurfaceVariant,
92− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
93− )
94− } else {
95− var query by rememberSaveable { mutableStateOf("") }
96− val keyboardController = LocalSoftwareKeyboardController.current
97− OutlinedTextField(
98− value = query,
99− onValueChange = { query = it },
100− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
101− placeholder = { Text(stringResource(R.string.search_commands)) },
102− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
103− singleLine = true,
104− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
105− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
106− )
107− val filtered = remember(commands, query) {
108− val q = query.trim()
109− if (q.isEmpty()) commands
110− else commands.filter {
111− it.name.contains(q, ignoreCase = true) ||
112− it.displayDescription.contains(q, ignoreCase = true)
113− }
114− }
115− if (filtered.isEmpty()) {
116− Text(
117− stringResource(R.string.no_commands_match, query.trim()),
118− style = MaterialTheme.typography.bodyMedium,
119− color = MaterialTheme.colorScheme.onSurfaceVariant,
120− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
121− )
122− } else {
123− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
124− items(filtered, key = { it.name }) { cmd ->
125− Column(
126− modifier = Modifier
127− .fillMaxWidth()
128− .clickable(role = Role.Button) {
129− if (isDestructiveCommand(cmd.name)) {
130− pendingCommandName = cmd.name
131− } else {
132− onSelect(cmd)
133− onDismiss()
134− }
135− }
136− .padding(horizontal = 24.dp, vertical = 12.dp),
137− ) {
138− androidx.compose.foundation.layout.Row(
139− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
140− ) {
141− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
142− Text(
143− "/${cmd.name}",
144− style = MaterialTheme.typography.bodyLarge,
145− modifier = Modifier.padding(start = 8.dp),
146− )
147− }
148− Text(
149− cmd.displayDescription,
150− style = MaterialTheme.typography.bodySmall,
151− color = MaterialTheme.colorScheme.onSurfaceVariant,
152− maxLines = 2,
153− overflow = TextOverflow.Ellipsis,
154− modifier = Modifier.padding(start = 28.dp, top = 2.dp),
155− )
156− }
157− }
158− }
159− }
160− }
161− }
162−
163− // Confirmation for commands whose name suggests they may discard work or reset state,
164− // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
165− pendingCommand?.let { cmd ->
166− AlertDialog(
167− onDismissRequest = { pendingCommandName = null },
168− title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
169− text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
170− confirmButton = {
171− TextButton(onClick = {
172− pendingCommandName = null
173− onSelect(cmd)
174− onDismiss()
175− }) { Text(stringResource(R.string.run)) }
176− },
177− dismissButton = {
178− TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
179− },
180− )
181− }
182−}
183−
184−/**
185− * Heuristic for commands that may be destructive (discard work / reset state) and so
186− * warrant a confirmation step before invocation. Matches common opencode slash-command
187− * names; non-matching commands invoke immediately.
188− */
189−private fun isDestructiveCommand(name: String): Boolean {
190− val n = name.lowercase()
191− return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
192− n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
193−}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktdeleted−100
@@ -1,100 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.MessageWithParts
5−import soy.iko.opencode.data.model.ReasoningPart
6−import soy.iko.opencode.data.model.TextPart
7−import soy.iko.opencode.data.model.ToolCompleted
8−import soy.iko.opencode.data.model.ToolError
9−import soy.iko.opencode.data.model.ToolPart
10−import soy.iko.opencode.data.model.ToolRunning
11−import soy.iko.opencode.data.model.UserMessage
12−
13−/**
14− * Render a conversation as Markdown for sharing/exporting. Each message becomes a
15− * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16− * and tool calls are summarized so the transcript shows what the agent *did*, not just
17− * what it said. Pure (no Android deps) so it can be unit-tested directly.
18− */
19−fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
20− val sb = StringBuilder(messages.size * 128)
21− if (!title.isNullOrBlank()) {
22− sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
23− }
24− for (message in messages) {
25− val heading = when (message.info) {
26− is UserMessage -> "## You"
27− is AssistantMessage -> "## opencode"
28− else -> null
29− } ?: continue
30− val body = message.parts
31− .mapNotNull { part ->
32− when (part) {
33− is TextPart -> part.text.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
34− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
35− ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
36− is ToolPart -> formatToolCall(part)
37− else -> null
38− }
39− }
40− .joinToString("\n\n")
41− .trim()
42− if (body.isEmpty()) continue
43− sb.append(heading).append("\n\n").append(body).append("\n\n")
44− }
45− return sb.toString().trimEnd()
46−}
47−
48−/** Render a tool call as a compact, readable blockquote summary: the tool name, its
49− * human-readable title (if any), and a truncated output/error. Keeps the exported
50− * transcript useful when the agent's work (edits, command runs) is the substance. */
51−private fun formatToolCall(part: ToolPart): String? {
52− val title = when (val s = part.state) {
53− is ToolRunning -> s.title
54− is ToolCompleted -> s.title
55− else -> null
56− }
57− val detail = when (val s = part.state) {
58− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
59− is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
60− else -> null
61− }
62− val status = when (part.state) {
63− is ToolCompleted -> null
64− is ToolError -> " — error"
65− is ToolRunning -> " — running"
66− else -> null
67− }
68− val head = "**${part.tool}**${status ?: ""}"
69− val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
70− return buildList {
71− add("> $head")
72− if (titleLine != null) add("> $titleLine")
73− // Prefix every detail line with "> " so the fenced code block stays inside the
74− // blockquote — otherwise lines after the first fall out of the quote.
75− if (detail != null) {
76− val quoted = detail.lines().joinToString("\n") { "> $it" }
77− add(">\n> ```\n$quoted\n> ```")
78− }
79− }.joinToString("\n").takeIf { it.isNotBlank() }
80−}
81−
82−private fun truncateOutput(output: String): String {
83− if (output.length <= TOOL_OUTPUT_LIMIT) return output
84− return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
85−}
86−
87−private const val TOOL_OUTPUT_LIMIT = 2000
88−
89−/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
90−private fun escapeMarkdown(text: String): String {
91− if (text.isEmpty()) return text
92− val sb = StringBuilder(text.length + 16)
93− for (c in text) {
94− when (c) {
95− '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
96− }
97− sb.append(c)
98− }
99− return sb.toString()
100−}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktdeleted−276
@@ -1,276 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.layout.Arrangement
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxWidth
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.material.icons.Icons
11−import androidx.compose.material.icons.automirrored.filled.HelpOutline
12−import androidx.compose.material.icons.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.rememberRelativeTime
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− for (part in message.parts) {
182− PartView(part, isRunning = isRunning, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
183− }
184− if (info is AssistantMessage) {
185− val cost = info.cost
186− val tokens = info.tokens
187− val tokenFormat = stringResource(R.string.tokens_in_out)
188− val costShort = stringResource(R.string.cost_format_short)
189− val costLong = stringResource(R.string.cost_format_long)
190− // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
191− // state-flip recomposition doesn't re-run NumberFormat + buildList +
192− // joinToString for every visible assistant bubble.
193− val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
194− if (!info.isComplete || (cost == null && tokens == null)) null
195− else buildList {
196− tokens?.let { add(formatTokens(it, tokenFormat)) }
197− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
198− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
199− }
200− Row(
201− modifier = Modifier.fillMaxWidth(),
202− horizontalArrangement = Arrangement.SpaceBetween,
203− verticalAlignment = Alignment.CenterVertically,
204− ) {
205− Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
206− costSummary?.let {
207− Text(
208− it,
209− style = MaterialTheme.typography.labelSmall,
210− color = MaterialTheme.colorScheme.onSurfaceVariant,
211− )
212− }
213− MessageTimestampText(message.info)
214− }
215− val context = LocalContext.current
216− val copyLabel = stringResource(R.string.copy)
217− // Collect text from all TextParts for copying. Memoized so a
218− // scroll-induced recomposition doesn't re-scan the parts list.
219− val textToCopy = remember(message.parts) {
220− message.parts
221− .filterIsInstance<TextPart>()
222− .joinToString("\n\n") { it.text }
223− .takeIf { it.isNotBlank() }
224− }
225− if (textToCopy != null) {
226− IconButton(
227− onClick = { copyToClipboard(context, "message", textToCopy) },
228− ) {
229− Icon(
230− Icons.Filled.ContentCopy,
231− contentDescription = copyLabel,
232− modifier = Modifier.size(18.dp),
233− tint = MaterialTheme.colorScheme.onSurfaceVariant,
234− )
235− }
236− }
237− }
238− } else {
239− MessageTimestampText(message.info)
240− }
241− }
242−}
243−
244−@Composable
245−private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
246− val t = rememberRelativeTime(info.time?.created)
247− if (t.isNotEmpty()) {
248− Text(
249− t,
250− style = MaterialTheme.typography.labelSmall,
251− color = MaterialTheme.colorScheme.onSurfaceVariant,
252− )
253− }
254−}
255−
256−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
257−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
258−// when the message list re-seeds after a reconnect and every visible assistant bubble
259−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
260−// not thread-safe.
261−private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
262− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
263−}
264−
265−private fun formatTokens(tokens: Tokens, format: String): String {
266− val nf = tokenNumberFormat.get()!!
267− return format.format(nf.format(tokens.input), nf.format(tokens.output))
268−}
269−
270−private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
271− // Locale.US so the formatting is stable regardless of device locale (avoids
272− // non-ASCII digits or comma decimal separators in a dollar amount). The format
273− // string itself is localized via strings.xml so the currency symbol can be
274− // adapted by translators.
275− if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
276− else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktdeleted−171
@@ -1,171 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.foundation.text.KeyboardActions
13−import androidx.compose.foundation.text.KeyboardOptions
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.filled.Check
16−import androidx.compose.material.icons.filled.Search
17−import androidx.compose.material3.CircularProgressIndicator
18−import androidx.compose.material3.ExperimentalMaterial3Api
19−import androidx.compose.material3.Icon
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.TextButton
25−import androidx.compose.runtime.Composable
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateOf
28−import androidx.compose.runtime.remember
29−import androidx.compose.runtime.saveable.rememberSaveable
30−import androidx.compose.runtime.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
34−import androidx.compose.ui.res.stringResource
35−import androidx.compose.ui.semantics.Role
36−import androidx.compose.ui.semantics.contentDescription
37−import androidx.compose.ui.semantics.role
38−import androidx.compose.ui.semantics.selected
39−import androidx.compose.ui.semantics.semantics
40−import androidx.compose.ui.text.input.ImeAction
41−import androidx.compose.ui.unit.dp
42−import androidx.compose.foundation.layout.Box
43−import soy.iko.opencode.data.model.ModelOption
44−import soy.iko.opencode.R
45−
46−/** Bottom sheet that lists every provider/model and lets the user pick one. */
47−@OptIn(ExperimentalMaterial3Api::class)
48−@Composable
49−fun ModelPickerSheet(
50− options: List<ModelOption>,
51− selected: ModelOption?,
52− loading: Boolean,
53− error: Boolean,
54− onSelect: (ModelOption) -> Unit,
55− onRetry: () -> Unit,
56− onDismiss: () -> Unit,
57−) {
58− ModalBottomSheet(onDismissRequest = onDismiss) {
59− Text(
60− stringResource(R.string.model),
61− style = MaterialTheme.typography.titleMedium,
62− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
63− )
64− if (loading) {
65− val loadingLabel = stringResource(R.string.loading)
66− Box(
67− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
68− contentAlignment = Alignment.Center,
69− ) {
70− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
71− }
72− } else if (error && options.isEmpty()) {
73− Column(
74− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
75− horizontalAlignment = Alignment.CenterHorizontally,
76− ) {
77− Text(
78− stringResource(R.string.load_failed),
79− style = MaterialTheme.typography.bodyMedium,
80− color = MaterialTheme.colorScheme.error,
81− )
82− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
83− }
84− } else if (options.isEmpty()) {
85− Text(
86− stringResource(R.string.no_models),
87− style = MaterialTheme.typography.bodyMedium,
88− color = MaterialTheme.colorScheme.onSurfaceVariant,
89− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
90− )
91− } else {
92− var query by rememberSaveable { mutableStateOf("") }
93− val keyboardController = LocalSoftwareKeyboardController.current
94− OutlinedTextField(
95− value = query,
96− onValueChange = { query = it },
97− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
98− placeholder = { Text(stringResource(R.string.search_models)) },
99− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
100− singleLine = true,
101− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
102− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
103− )
104− val filtered = remember(options, query) {
105− val q = query.trim()
106− if (q.isEmpty()) options
107− else options.filter {
108− it.modelLabel.contains(q, ignoreCase = true) ||
109− it.providerLabel.contains(q, ignoreCase = true)
110− }
111− }
112− if (filtered.isEmpty()) {
113− Text(
114− stringResource(R.string.no_models_match),
115− style = MaterialTheme.typography.bodyMedium,
116− color = MaterialTheme.colorScheme.onSurfaceVariant,
117− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
118− )
119− } else {
120− // Group by provider so a long catalog is scannable: a provider header
121− // followed by its models, instead of a flat list mixing providers.
122− val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
123− LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
124− grouped.forEach { (providerID, opts) ->
125− item(key = "header_$providerID") {
126− Text(
127− opts.firstOrNull()?.providerLabel ?: providerID,
128− style = MaterialTheme.typography.labelMedium,
129− color = MaterialTheme.colorScheme.primary,
130− modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
131− )
132− }
133− items(opts, key = { it.providerID to it.modelID }) { option ->
134− val isSelected = option.providerID == selected?.providerID &&
135− option.modelID == selected?.modelID
136− Column(
137− modifier = Modifier
138− .fillMaxWidth()
139− .semantics(mergeDescendants = true) {
140− if (isSelected) this.selected = true
141− }
142− .clickable(role = Role.RadioButton) {
143− onSelect(option)
144− onDismiss()
145− }
146− .padding(horizontal = 24.dp, vertical = 12.dp),
147− ) {
148− Row(verticalAlignment = Alignment.CenterVertically) {
149− Text(
150− option.modelLabel,
151− style = MaterialTheme.typography.bodyLarge,
152− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
153− modifier = Modifier.weight(1f),
154− )
155− if (isSelected) {
156− Icon(
157− Icons.Filled.Check,
158− contentDescription = null,
159− tint = MaterialTheme.colorScheme.primary,
160− modifier = Modifier.size(18.dp),
161− )
162− }
163− }
164− }
165− }
166− }
167− }
168− }
169− }
170− }
171−}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktdeleted−475
@@ -1,475 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.foundation.background
5−import androidx.compose.foundation.clickable
6−import androidx.compose.foundation.combinedClickable
7−import androidx.compose.foundation.layout.Column
8−import androidx.compose.foundation.layout.Row
9−import androidx.compose.foundation.layout.defaultMinSize
10−import androidx.compose.foundation.layout.fillMaxWidth
11−import androidx.compose.foundation.layout.padding
12−import androidx.compose.foundation.layout.size
13−import androidx.compose.foundation.text.selection.SelectionContainer
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.automirrored.filled.HelpOutline
16−import androidx.compose.material.icons.filled.Bolt
17−import androidx.compose.material.icons.filled.CheckCircle
18−import androidx.compose.material.icons.filled.ContentCopy
19−import androidx.compose.material.icons.filled.Description
20−import androidx.compose.material.icons.filled.Error
21−import androidx.compose.material.icons.filled.ExpandLess
22−import androidx.compose.material.icons.filled.ExpandMore
23−import androidx.compose.material.icons.filled.Psychology
24−import androidx.compose.material3.CircularProgressIndicator
25−import androidx.compose.material3.Icon
26−import androidx.compose.material3.IconButton
27−import androidx.compose.material3.MaterialTheme
28−import androidx.compose.material3.Text
29−import androidx.compose.material3.TextButton
30−import androidx.compose.runtime.Composable
31−import androidx.compose.runtime.getValue
32−import androidx.compose.runtime.mutableStateOf
33−import androidx.compose.runtime.remember
34−import androidx.compose.runtime.saveable.rememberSaveable
35−import androidx.compose.runtime.setValue
36−import androidx.compose.ui.Alignment
37−import androidx.compose.ui.Modifier
38−import androidx.compose.ui.draw.clip
39−import androidx.compose.ui.platform.LocalContext
40−import androidx.compose.ui.semantics.Role
41−import androidx.compose.ui.semantics.contentDescription
42−import androidx.compose.ui.semantics.semantics
43−import androidx.compose.ui.semantics.stateDescription
44−import androidx.compose.ui.text.font.FontFamily
45−import androidx.compose.ui.res.stringResource
46−import androidx.compose.ui.unit.dp
47−import kotlinx.serialization.json.Json
48−import kotlinx.serialization.json.JsonElement
49−import soy.iko.opencode.data.model.FilePart
50−import soy.iko.opencode.data.model.Part
51−import soy.iko.opencode.data.model.ReasoningPart
52−import soy.iko.opencode.data.model.StepFinishPart
53−import soy.iko.opencode.data.model.StepStartPart
54−import soy.iko.opencode.data.model.TextPart
55−import soy.iko.opencode.data.model.ToolCompleted
56−import soy.iko.opencode.data.model.ToolError
57−import soy.iko.opencode.data.model.ToolPart
58−import soy.iko.opencode.data.model.ToolPending
59−import soy.iko.opencode.data.model.ToolRunning
60−import soy.iko.opencode.data.model.ToolState
61−import soy.iko.opencode.data.model.ToolUnknown
62−import soy.iko.opencode.data.model.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− )
267− }
268− val detail = when (val s = part.state) {
269− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
270− is ToolError -> s.error ?: stringResource(R.string.error_generic)
271− else -> null
272− }
273− if (detail != null) {
274− val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
275− var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
276− val expandedState = stringResource(R.string.state_expanded)
277− val collapsedState = stringResource(R.string.state_collapsed)
278− val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
279− // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
280− // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
281− val isDiff = remember(display) { looksLikeDiff(display) }
282− CollapsibleDetail(
283− label = null,
284− detail = display,
285− isDiff = isDiff,
286− keySuffix = "output",
287− expanded = expanded || detail.length <= COLLAPSED_LIMIT,
288− onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
289− { expanded = !expanded }
290− } else null,
291− expandedState = expandedState,
292− collapsedState = collapsedState,
293− onCopy = { copyToClipboard(context, "output", detail) },
294− )
295− }
296− }
297−}
298−
299−/**
300− * A collapsible monospace detail block shared by tool input and output. [label] is an
301− * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
302− * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
303− * with an expand/collapse affordance when [onToggleExpand] is supplied.
304− */
305−@Composable
306−private fun CollapsibleDetail(
307− label: String?,
308− detail: String,
309− isDiff: Boolean,
310− keySuffix: String,
311− expanded: Boolean = false,
312− onToggleExpand: (() -> Unit)? = null,
313− expandedState: String = "",
314− collapsedState: String = "",
315− onCopy: (() -> Unit)? = null,
316−) {
317− Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
318− if (label != null) {
319− var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
320− Row(
321− modifier = Modifier
322− .fillMaxWidth()
323− .defaultMinSize(minHeight = 48.dp)
324− .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
325− .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
326− verticalAlignment = Alignment.CenterVertically,
327− ) {
328− Icon(
329− if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
330− contentDescription = null,
331− modifier = Modifier.size(16.dp),
332− tint = MaterialTheme.colorScheme.onSurfaceVariant,
333− )
334− Text(
335− label,
336− style = MaterialTheme.typography.labelSmall,
337− color = MaterialTheme.colorScheme.onSurfaceVariant,
338− modifier = Modifier.padding(start = 4.dp),
339− )
340− }
341− if (!inputExpanded) return@Column
342− }
343− if (isDiff) {
344− DiffView(detail)
345− } else {
346− // SelectionContainer so the user can select a portion of the output (e.g.
347− // a single line of stdout) instead of only copy-all via the button below.
348− SelectionContainer {
349− Text(
350− detail,
351− style = MaterialTheme.typography.bodySmall,
352− fontFamily = FontFamily.Monospace,
353− )
354− }
355− }
356− if (onToggleExpand != null) {
357− TextButton(
358− onClick = onToggleExpand,
359− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
360− modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
361− ) {
362− Icon(
363− if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
364− contentDescription = null,
365− modifier = Modifier.size(16.dp),
366− )
367− Text(
368− if (expanded) stringResource(R.string.show_less) else stringResource(R.string.show_more),
369− style = MaterialTheme.typography.labelSmall,
370− modifier = Modifier.padding(start = 4.dp),
371− )
372− }
373− }
374− if (onCopy != null) {
375− TextButton(
376− onClick = onCopy,
377− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
378− modifier = Modifier.semantics(mergeDescendants = true) {},
379− ) {
380− Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
381− Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
382− }
383− }
384− }
385−}
386−
387−@Composable
388−private fun ToolStatusIcon(state: ToolState) {
389− when (state) {
390− is ToolPending, is ToolRunning -> {
391− val label = stringResource(R.string.tool_running)
392− CircularProgressIndicator(
393− modifier = Modifier.size(16.dp).semantics { contentDescription = label },
394− strokeWidth = 2.dp,
395− )
396− }
397− is ToolCompleted ->
398− Icon(
399− Icons.Filled.CheckCircle,
400− contentDescription = stringResource(R.string.tool_completed),
401− modifier = Modifier.size(16.dp),
402− tint = MaterialTheme.colorScheme.primary,
403− )
404− is ToolError ->
405− Icon(
406− Icons.Filled.Error,
407− contentDescription = stringResource(R.string.tool_error),
408− modifier = Modifier.size(16.dp),
409− tint = MaterialTheme.colorScheme.error,
410− )
411− is ToolUnknown ->
412− Icon(
413− Icons.Filled.Bolt,
414− contentDescription = stringResource(R.string.tool_unknown),
415− modifier = Modifier.size(16.dp),
416− )
417− }
418−}
419−
420−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
421−@Composable
422−private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
423− val context = LocalContext.current
424− val path = part.source ?: part.url ?: part.filename
425− val copyLabel = stringResource(R.string.copy_path)
426− val openLabel = stringResource(R.string.open_file)
427− // When a source path is available and a navigation callback is wired, tapping the
428− // chip opens the file in the viewer (the action a user tapping a file reference
429− // most likely expects). Long-press still copies the path to the clipboard, so the
430− // copy affordance is preserved without being the default tap action. When no
431− // navigation is wired (e.g. tests), falls back to copy-path on tap.
432− val source = part.source
433− val opener = onOpenFile
434− val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
435− val canOpen = openPath != null
436− val semanticsLabel = if (canOpen) openLabel else copyLabel
437− Row(
438− modifier = modifier
439− .clip(MaterialTheme.shapes.small)
440− .background(MaterialTheme.colorScheme.surfaceVariant)
441− .combinedClickable(
442− role = Role.Button,
443− onClick = {
444− val toOpen = openPath
445− if (toOpen != null) {
446− opener?.invoke(toOpen)
447− } else if (!path.isNullOrBlank()) {
448− copyToClipboard(context, "path", path)
449− showToast(context, context.getString(R.string.path_copied))
450− }
451− },
452− onLongClick = {
453− if (!path.isNullOrBlank()) {
454− copyToClipboard(context, "path", path)
455− showToast(context, context.getString(R.string.path_copied))
456− }
457− },
458− )
459− .semantics { contentDescription = semanticsLabel }
460− .padding(horizontal = 12.dp, vertical = 6.dp),
461− verticalAlignment = Alignment.CenterVertically,
462− ) {
463− Icon(
464− Icons.Filled.Description,
465− contentDescription = null,
466− modifier = Modifier.size(16.dp),
467− tint = MaterialTheme.colorScheme.onSurfaceVariant,
468− )
469− Text(
470− part.filename ?: part.url ?: stringResource(R.string.file),
471− style = MaterialTheme.typography.bodyMedium,
472− modifier = Modifier.padding(start = 6.dp),
473− )
474− }
475−}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktdeleted−114
@@ -1,114 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.material.icons.Icons
9−import androidx.compose.material.icons.filled.Shield
10−import androidx.compose.material3.AlertDialog
11−import androidx.compose.material3.Button
12−import androidx.compose.material3.Icon
13−import androidx.compose.material3.MaterialTheme
14−import androidx.compose.material3.OutlinedButton
15−import androidx.compose.material3.Text
16−import androidx.compose.material3.TextButton
17−import androidx.compose.runtime.Composable
18−import androidx.compose.runtime.mutableStateOf
19−import androidx.compose.runtime.remember
20−import androidx.compose.runtime.saveable.rememberSaveable
21−import androidx.compose.runtime.getValue
22−import androidx.compose.runtime.setValue
23−import androidx.compose.ui.Modifier
24−import androidx.compose.ui.platform.LocalHapticFeedback
25−import androidx.compose.ui.res.stringResource
26−import androidx.compose.ui.text.font.FontFamily
27−import androidx.compose.ui.unit.dp
28−import soy.iko.opencode.data.model.Permission
29−import soy.iko.opencode.data.model.PermissionResponse
30−import soy.iko.opencode.R
31−
32−/**
33− * Modal asking the user to approve a tool the agent wants to run. The agent run is
34− * paused until one of these responses is sent. Non-dismissable by tap-outside so the
35− * decision is explicit.
36− */
37−@Composable
38−fun PermissionDialog(
39− permission: Permission,
40− onRespond: (PermissionResponse) -> Unit,
41−) {
42− val haptics = LocalHapticFeedback.current
43− // Guard against double-respond: back press + button tap, or rapid double-tap,
44− // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
45− var responded by rememberSaveable { mutableStateOf(false) }
46− val respond: (PermissionResponse) -> Unit = { response ->
47− if (!responded) {
48− responded = true
49− onRespond(response)
50− }
51− }
52− AlertDialog(
53− // Back press routes here (dismissOnBackPress defaults true) and is treated as an
54− // explicit reject — the safe default. Tap-outside is disabled below so it can't
55− // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
56− // renders in its own window whose back dispatcher never reaches the host.
57− onDismissRequest = { respond(PermissionResponse.REJECT) },
58− properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
59− icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
60− title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
61− text = {
62− Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
63− permission.type?.let {
64− Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
65− }
66− permission.patternText?.let {
67− Text(
68− it,
69− style = MaterialTheme.typography.bodyMedium,
70− fontFamily = FontFamily.Monospace,
71− color = MaterialTheme.colorScheme.onSurfaceVariant,
72− )
73− }
74− Text(
75− stringResource(R.string.permission_text),
76− style = MaterialTheme.typography.bodySmall,
77− color = MaterialTheme.colorScheme.onSurfaceVariant,
78− )
79− }
80− },
81− confirmButton = {
82− Column(
83− modifier = Modifier.fillMaxWidth(),
84− verticalArrangement = Arrangement.spacedBy(4.dp),
85− ) {
86− Button(
87− onClick = {
88− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
89− respond(PermissionResponse.ONCE)
90− },
91− modifier = Modifier.fillMaxWidth(),
92− ) { Text(stringResource(R.string.allow_once)) }
93− OutlinedButton(
94− onClick = {
95− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
96− respond(PermissionResponse.ALWAYS)
97− },
98− modifier = Modifier.fillMaxWidth(),
99− ) { Text(stringResource(R.string.always_allow)) }
100− // Reject is an OutlinedButton (not a low-prominence TextButton) in the
101− // same column so the three actions are visually balanced — the prior
102− // layout buried Reject as a small dismiss-button, nudging users toward
103− // granting. Reject uses the error color to signal its consequence.
104− OutlinedButton(
105− onClick = {
106− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
107− respond(PermissionResponse.REJECT)
108− },
109− modifier = Modifier.fillMaxWidth(),
110− ) { Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error) }
111− }
112− },
113− )
114−}
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktdeleted−262
@@ -1,262 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.material3.MaterialTheme
4−import androidx.compose.runtime.Composable
5−import androidx.compose.runtime.remember
6−import androidx.compose.ui.graphics.Color
7−import androidx.compose.ui.text.AnnotatedString
8−import androidx.compose.ui.text.SpanStyle
9−import androidx.compose.ui.text.font.FontFamily
10−import androidx.compose.ui.text.font.FontStyle
11−import androidx.compose.ui.text.font.FontWeight
12−import androidx.compose.ui.text.withStyle
13−
14−/**
15− * Lightweight, dependency-free syntax highlighting for the file viewer.
16− *
17− * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
18− * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
19− * heuristic highlighter instead colors comments, strings, numbers, and a per-language
20− * keyword set via simple character/word scanning — enough to make a code file scannable
21− * in the viewer without pulling in a parser.
22− *
23− * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
24− * comments and triple-quoted strings are not handled; that's an accepted trade-off for
25− * a viewer (not an editor) where the goal is visual orientation, not correctness.
26− */
27−
28−/** Language families recognized by [highlightLine]. The set of keywords is shared
29− * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30− * common keywords overlap heavily; python has its own set; shell has its own. */
31−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− (c == '/' && i + 1 < line.length && line[i + 1] == '/') ||
200− (c == '#' && lang != Language.MARKUP)
201−
202−private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
203−
204−private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
205− c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
206−
207−/** Scan forward to the end of a string literal starting at [start] (the opening quote
208− * is line[start]). Returns the index just past the closing quote (or line.length if
209− * the string is unterminated on this line). Honors backslash escapes. */
210−private fun findStringEnd(line: String, start: Int, quote: Char): Int {
211− var i = start + 1
212− val n = line.length
213− while (i < n && line[i] != quote) {
214− if (line[i] == '\\' && i + 1 < n) i += 2 else i++
215− }
216− return if (i < n) i + 1 else n
217−}
218−
219−/** Scan forward to the end of a number literal starting at [start].
220− *
221− * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
222− * and `123abc` must split rather than merge into one token.
223− * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
224− * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
225− * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
226− * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
227− */
228−private fun findNumberEnd(line: String, start: Int): Int {
229− val n = line.length
230− // Detect a hex prefix ("0x"/"0X") right at the number start.
231− val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
232− var i = if (hex) start + 2 else start + 1
233− var seenDot = line[start] == '.'
234− var seenExp = false
235− while (i < n) {
236− val c = line[i]
237− val prev = line[i - 1]
238− when {
239− c.isDigit() || c == '_' -> i++
240− hex && isHexDigit(c) -> i++
241− c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
242− !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
243− !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
244− else -> break
245− }
246− }
247− return i
248−}
249−
250−private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
251−
252−/** Scan forward to the end of an identifier starting at [start]. */
253−private fun findIdentifierEnd(line: String, start: Int): Int {
254− var i = start
255− if (line[i] == '@') i++
256− val n = line.length
257− while (i < n && isIdentifierPartChar(line[i])) i++
258− return i
259−}
260−
261−private fun isIdentifierPartChar(c: Char): Boolean =
262− c.isLetterOrDigit() || c == '_' || c == '$'
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktdeleted−113
@@ -1,113 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.animation.AnimatedVisibility
4−import androidx.compose.animation.fadeIn
5−import androidx.compose.animation.fadeOut
6−import androidx.compose.foundation.layout.Arrangement
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.fillMaxWidth
9−import androidx.compose.foundation.layout.padding
10−import androidx.compose.foundation.layout.size
11−import androidx.compose.material3.CircularProgressIndicator
12−import androidx.compose.material3.MaterialTheme
13−import androidx.compose.material3.Surface
14−import androidx.compose.material3.Text
15−import androidx.compose.material3.TextButton
16−import androidx.compose.runtime.Composable
17−import androidx.compose.ui.Alignment
18−import androidx.compose.ui.Modifier
19−import androidx.compose.ui.res.stringResource
20−import androidx.compose.ui.semantics.contentDescription
21−import androidx.compose.ui.semantics.liveRegion
22−import androidx.compose.ui.semantics.LiveRegionMode
23−import androidx.compose.ui.semantics.semantics
24−import androidx.compose.ui.unit.dp
25−import soy.iko.opencode.R
26−import soy.iko.opencode.data.network.EventStreamClient
27−
28−/**
29− * Banner shown when the SSE event stream is not connected. Shared by the chat and the
30− * session list so a dropped stream is visible everywhere, not just mid-conversation.
31− *
32− * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
33− * when [onRetry] is supplied, so the user can re-connect without hunting for the
34− * chat screen's reconnect button or navigating to the server list. Transient
35− * connecting/reconnecting states remain non-interactive (the system is already trying).
36− */
37−@Composable
38−fun ConnectionBanner(
39− state: EventStreamClient.ConnectionState,
40− modifier: Modifier = Modifier,
41− isOnline: Boolean = true,
42− onRetry: (() -> Unit)? = null,
43−) {
44− // When the device itself has no connectivity, surface that distinctly (instead of
45− // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
46− // credentials", neither of which reflects the real problem.
47− val text = if (!isOnline) {
48− stringResource(R.string.offline)
49− } else {
50− when (state) {
51− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
52− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
53− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
54− EventStreamClient.ConnectionState.Connected -> null
55− }
56− }
57− val isOffline = !isOnline
58− // AnimatedVisibility so the banner slides/fades in and out instead of appearing
59− // and disappearing instantly — a state change that's especially jarring when the
60− // connection flaps, and was called out as a rough edge in the UX audit.
61− AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
62− if (text == null) return@AnimatedVisibility
63− // Distinguish a hard failure (e.g. bad credentials) and an offline device from
64− // transient connecting/reconnecting states by switching to the error palette,
65− // so the banner conveys urgency without relying on text alone.
66− val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
67− val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
68− else MaterialTheme.colorScheme.tertiaryContainer
69− val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
70− else MaterialTheme.colorScheme.onTertiaryContainer
71− Surface(
72− modifier = modifier
73− .fillMaxWidth()
74− .semantics {
75− // Announce connection state changes to TalkBack users so they're
76− // aware the stream dropped/reconnecting without visual cues.
77− liveRegion = LiveRegionMode.Polite
78− contentDescription = text
79− },
80− color = container,
81− ) {
82− Row(
83− modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
84− horizontalArrangement = Arrangement.Center,
85− verticalAlignment = Alignment.CenterVertically,
86− ) {
87− if (!isFailed) {
88− CircularProgressIndicator(
89− Modifier.size(16.dp),
90− strokeWidth = 2.dp,
91− )
92− }
93− Text(
94− text,
95− style = MaterialTheme.typography.labelMedium,
96− color = onContainer,
97− modifier = Modifier
98− .padding(start = 6.dp)
99− .weight(1f, fill = false),
100− )
101− // Inline retry on the Failed banner so the user can recover without
102− // leaving the screen. Hidden during transient states (the system is
103− // already reconnecting), when offline (retry can't help), and when no
104− // callback is wired (callers that don't have a reconnect path).
105− if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
106− TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
107− Text(stringResource(R.string.retry_now), color = onContainer)
108− }
109− }
110− }
111− }
112− }
113−}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktdeleted−218
@@ -1,218 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.horizontalScroll
5−import androidx.compose.foundation.layout.Arrangement
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.Spacer
9−import androidx.compose.foundation.layout.fillMaxWidth
10−import androidx.compose.foundation.layout.height
11−import androidx.compose.foundation.layout.padding
12−import androidx.compose.foundation.layout.width
13−import androidx.compose.foundation.rememberScrollState
14−import androidx.compose.material.icons.Icons
15−import androidx.compose.material.icons.filled.ContentCopy
16−import androidx.compose.material3.Icon
17−import androidx.compose.material3.IconButton
18−import androidx.compose.material3.MaterialTheme
19−import androidx.compose.material3.Text
20−import androidx.compose.material3.TextButton
21−import androidx.compose.runtime.Composable
22−import androidx.compose.runtime.getValue
23−import androidx.compose.runtime.mutableStateOf
24−import androidx.compose.runtime.remember
25−import androidx.compose.runtime.saveable.rememberSaveable
26−import androidx.compose.runtime.setValue
27−import androidx.compose.ui.Alignment
28−import androidx.compose.ui.Modifier
29−import androidx.compose.ui.draw.clip
30−import androidx.compose.ui.graphics.Color
31−import androidx.compose.ui.platform.LocalContext
32−import androidx.compose.ui.text.font.FontFamily
33−import androidx.compose.ui.unit.dp
34−import androidx.compose.ui.res.stringResource
35−import soy.iko.opencode.R
36−
37−/** A single line of a parsed unified diff. */
38−sealed interface DiffLine {
39− val text: String
40−
41− data class Hunk(override val text: String) : DiffLine // @@ ... @@
42− data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
43− data class Context(override val text: String) : DiffLine
44− data class Add(override val text: String) : DiffLine
45− data class Remove(override val text: String) : DiffLine
46− data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
47−}
48−
49−/** Git metadata line prefixes that aren't part of the diff content. */
50−private val gitMetaPrefixes = listOf(
51− "new file mode ", "deleted file mode ", "old mode ", "new mode ",
52− "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
53− "copy from ", "copy to ", "Binary files ", "\\ No newline",
54−)
55−
56−/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
57− * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
58−private fun isDiffFileHeader(raw: String): Boolean {
59− val rest = when {
60− raw.startsWith("--- ") -> raw.substring(4)
61− raw.startsWith("+++ ") -> raw.substring(4)
62− else -> return false
63− }
64− // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
65− // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
66− // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
67− // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
68− return rest.startsWith("a/") || rest.startsWith("b/") ||
69− rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
70−}
71−
72−/** Parse a unified diff string into typed [DiffLine]s. */
73−fun parseDiff(diff: String): List<DiffLine> {
74− val result = mutableListOf<DiffLine>()
75− for (raw in diff.lineSequence()) {
76− when {
77− raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
78− // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
79− // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
80− // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
81− // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
82− // as headers; those fall through to the +/- branches below.
83− isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
84− raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
85− raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
86− raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
87− raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
88− gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
89− raw.isNotBlank() -> result.add(DiffLine.Context(raw))
90− }
91− }
92− return result
93−}
94−
95−/** Heuristic: does this string look like a unified diff? */
96−fun looksLikeDiff(text: String): Boolean {
97− val lines = text.lineSequence()
98− var hunkCount = 0
99− var addCount = 0
100− var removeCount = 0
101− for (line in lines) {
102− when {
103− line.startsWith("@@") -> hunkCount++
104− line.startsWith("+") && !line.startsWith("+++") -> addCount++
105− line.startsWith("-") && !line.startsWith("---") -> removeCount++
106− }
107− if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
108− }
109− return false
110−}
111−
112−private const val COLLAPSED_DIFF_LINES = 200
113−
114−/**
115− * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
116− * safe to embed inside another vertically scrolling container (e.g. the chat message
117− * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
118− * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
119− * [Modifier.verticalScroll] in [modifier].
120− *
121− * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
122− * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
123− * expands the view.
124− */
125−@Composable
126−fun DiffView(diff: String, modifier: Modifier = Modifier) {
127− val lines = remember(diff) { parseDiff(diff) }
128− 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))
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.ktdeleted−227
@@ -1,227 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.widget.Toast
7−import androidx.compose.foundation.background
8−import androidx.compose.foundation.horizontalScroll
9−import androidx.compose.foundation.layout.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.ktnew file+479
@@ -0,0 +1,479 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.content.Intent
7+import android.net.Uri
8+import android.widget.Toast
9+import androidx.compose.foundation.ExperimentalFoundationApi
10+import androidx.compose.foundation.background
11+import androidx.compose.foundation.clickable
12+import androidx.compose.foundation.combinedClickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Column
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.fillMaxWidth
17+import androidx.compose.foundation.layout.padding
18+import androidx.compose.foundation.layout.size
19+import androidx.compose.foundation.layout.widthIn
20+import androidx.compose.foundation.shape.RoundedCornerShape
21+import androidx.compose.foundation.text.selection.SelectionContainer
22+import androidx.compose.material.icons.Icons
23+import androidx.compose.material.icons.filled.Build
24+import androidx.compose.material.icons.filled.Check
25+import androidx.compose.material.icons.filled.ErrorOutline
26+import androidx.compose.material.icons.filled.ExpandLess
27+import androidx.compose.material.icons.filled.ExpandMore
28+import androidx.compose.material.icons.filled.Schedule
29+import androidx.compose.material.icons.filled.SmartToy
30+import androidx.compose.material3.DropdownMenu
31+import androidx.compose.material3.DropdownMenuItem
32+import androidx.compose.material3.Icon
33+import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.Text
35+import androidx.compose.runtime.Composable
36+import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
40+import androidx.compose.ui.Alignment
41+import androidx.compose.ui.Modifier
42+import androidx.compose.ui.draw.clip
43+import androidx.compose.ui.graphics.Color
44+import androidx.compose.ui.platform.LocalContext
45+import androidx.compose.ui.text.AnnotatedString
46+import androidx.compose.ui.text.LinkAnnotation
47+import androidx.compose.ui.text.SpanStyle
48+import androidx.compose.ui.text.TextLinkStyles
49+import androidx.compose.ui.text.buildAnnotatedString
50+import androidx.compose.ui.text.font.FontFamily
51+import androidx.compose.ui.text.font.FontStyle
52+import androidx.compose.ui.text.font.FontWeight
53+import androidx.compose.ui.text.withLink
54+import androidx.compose.ui.text.withStyle
55+import androidx.compose.ui.text.style.TextDecoration
56+import androidx.compose.ui.text.style.TextOverflow
57+import androidx.compose.ui.unit.dp
58+import androidx.compose.ui.unit.sp
59+import soy.iko.opencode.core.MessageStatus
60+import soy.iko.opencode.core.MessageView
61+import soy.iko.opencode.core.ToolView
62+import soy.iko.opencode.ui.theme.MonoStyle
63+
64+private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65+
66+@OptIn(ExperimentalFoundationApi::class)
67+@Composable
68+fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69+ val isUser = message.isUser
70+ val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71+ val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72+ val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73+ val shape = RoundedCornerShape(
74+ topStart = 16.dp,
75+ topEnd = 16.dp,
76+ bottomStart = if (isUser) 16.dp else 4.dp,
77+ bottomEnd = if (isUser) 4.dp else 16.dp,
78+ )
79+
80+ val context = LocalContext.current
81+ var showMenu by remember { mutableStateOf(false) }
82+
83+ Row(
84+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85+ horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86+ ) {
87+ if (!isUser) {
88+ Icon(
89+ Icons.Filled.SmartToy,
90+ contentDescription = null,
91+ tint = MaterialTheme.colorScheme.primary,
92+ modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93+ )
94+ }
95+ Column(
96+ modifier = Modifier
97+ .fillMaxWidth(0.85f)
98+ .widthIn(max = 320.dp)
99+ .clip(shape)
100+ .background(bubbleColor)
101+ .combinedClickable(
102+ onClick = {},
103+ onLongClick = {
104+ if (message.text.isNotEmpty()) {
105+ showMenu = true
106+ }
107+ },
108+ )
109+ .padding(horizontal = 14.dp, vertical = 10.dp),
110+ ) {
111+ DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112+ DropdownMenuItem(
113+ text = { Text("Copy") },
114+ onClick = {
115+ copyToClipboard(context, message.text)
116+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117+ showMenu = false
118+ },
119+ )
120+ DropdownMenuItem(
121+ text = { Text("Share") },
122+ onClick = {
123+ shareText(context, message.text)
124+ showMenu = false
125+ },
126+ )
127+ }
128+
129+ message.reasoning?.let { ReasoningBlock(it, context) }
130+
131+ // Give a quick overview of how many tools ran before listing them.
132+ if (message.tools.size > 1) {
133+ Text(
134+ text = "${message.tools.size} tool calls",
135+ style = MaterialTheme.typography.labelSmall,
136+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137+ modifier = Modifier.padding(bottom = 4.dp),
138+ )
139+ }
140+
141+ message.tools.forEach { ToolRow(it) }
142+
143+ SelectionContainer {
144+ if (message.text.isNotEmpty()) {
145+ val linkColor = MaterialTheme.colorScheme.primary
146+ // Tint the code background off the bubble's own foreground colour so it
147+ // contrasts on both bubble types (user bubbles use primaryContainer).
148+ val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149+ val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150+ buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151+ }
152+ Text(
153+ text = annotatedText,
154+ color = textColor,
155+ // Give assistant replies (often long, code-heavy) extra line
156+ // spacing for readability; user bubbles keep the default.
157+ style = if (isUser) {
158+ MaterialTheme.typography.bodyLarge
159+ } else {
160+ MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161+ },
162+ )
163+ } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164+ Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165+ }
166+ }
167+
168+ if (message.time > 0) {
169+ Text(
170+ text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171+ style = MaterialTheme.typography.labelSmall,
172+ color = mutedColor,
173+ modifier = Modifier.padding(top = 4.dp),
174+ )
175+ }
176+
177+ if (isUser && message.status != MessageStatus.Sent) {
178+ StatusLine(message.status, onRetry = onRetry)
179+ }
180+ }
181+ }
182+}
183+
184+private fun copyToClipboard(context: Context, text: String) {
185+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186+ clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187+}
188+
189+private val urlRegex = Regex("""https?://[^\s]+""")
190+
191+/**
192+ * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193+ * prose and code segments: code segments are rendered monospace on a tinted
194+ * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195+ * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196+ */
197+private fun buildLinkedText(
198+ text: String,
199+ streaming: Boolean,
200+ linkColor: Color,
201+ codeBackground: Color,
202+ context: Context,
203+): AnnotatedString = buildAnnotatedString {
204+ // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205+ // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206+ val segments = text.split("```")
207+ segments.forEachIndexed { index, segment ->
208+ if (index % 2 == 1) {
209+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210+ append(segment)
211+ }
212+ } else {
213+ appendLinkedText(segment, linkColor, codeBackground, context)
214+ }
215+ }
216+ if (streaming) append(" ▌")
217+}
218+
219+/**
220+ * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221+ * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222+ * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223+ * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224+ * inline spans reach here.
225+ */
226+private fun AnnotatedString.Builder.appendLinkedText(
227+ text: String,
228+ linkColor: Color,
229+ codeBackground: Color,
230+ context: Context,
231+) {
232+ // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233+ // inline code and links inside it. Odd-indexed segments sit between a pair of **
234+ // markers, so they're bold (markers dropped); even segments are ordinary prose.
235+ // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236+ // segment on "*" would misfire on the leftover asterisks from the ** markers.
237+ val segments = text.split("**")
238+ // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239+ // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240+ val unclosed = segments.size % 2 == 0
241+ segments.forEachIndexed { index, segment ->
242+ val isUnclosedTail = unclosed && index == segments.size - 1
243+ if (index % 2 == 1 && !isUnclosedTail) {
244+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245+ appendInlineCode(segment, linkColor, codeBackground, context)
246+ }
247+ } else {
248+ if (isUnclosedTail) append("**")
249+ appendItalicText(segment, linkColor, codeBackground, context)
250+ }
251+ }
252+}
253+
254+/**
255+ * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256+ * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257+ * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258+ */
259+private fun AnnotatedString.Builder.appendItalicText(
260+ text: String,
261+ linkColor: Color,
262+ codeBackground: Color,
263+ context: Context,
264+) {
265+ // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266+ // (markers dropped); even segments are ordinary prose.
267+ val segments = text.split("*")
268+ // An even segment count means an odd number of * markers, so the final * is unclosed:
269+ // its trailing segment isn't italic. Restore the literal * and render it as prose.
270+ val unclosed = segments.size % 2 == 0
271+ segments.forEachIndexed { index, segment ->
272+ val isUnclosedTail = unclosed && index == segments.size - 1
273+ if (index % 2 == 1 && !isUnclosedTail) {
274+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275+ appendInlineCode(segment, linkColor, codeBackground, context)
276+ }
277+ } else {
278+ if (isUnclosedTail) append("*")
279+ appendInlineCode(segment, linkColor, codeBackground, context)
280+ }
281+ }
282+}
283+
284+/**
285+ * Append [text] to the builder, rendering single-backtick pairs as inline code
286+ * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287+ * into a clickable link.
288+ */
289+private fun AnnotatedString.Builder.appendInlineCode(
290+ text: String,
291+ linkColor: Color,
292+ codeBackground: Color,
293+ context: Context,
294+) {
295+ // Odd-indexed segments sit between a pair of single backticks, so they're inline
296+ // code. Even segments are ordinary prose and get URL linking applied.
297+ val segments = text.split("`")
298+ // An even segment count means an odd number of backtick markers, so the final backtick
299+ // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300+ val unclosed = segments.size % 2 == 0
301+ segments.forEachIndexed { index, segment ->
302+ val isUnclosedTail = unclosed && index == segments.size - 1
303+ if (index % 2 == 1 && !isUnclosedTail) {
304+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305+ append(segment)
306+ }
307+ } else {
308+ if (isUnclosedTail) append("`")
309+ appendUrls(segment, linkColor, context)
310+ }
311+ }
312+}
313+
314+/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315+private fun AnnotatedString.Builder.appendUrls(
316+ text: String,
317+ linkColor: Color,
318+ context: Context,
319+) {
320+ val matches = urlRegex.findAll(text).toList()
321+ var cursor = 0
322+ for (match in matches) {
323+ append(text.substring(cursor, match.range.first))
324+ val url = match.value
325+ withLink(
326+ LinkAnnotation.Clickable(
327+ tag = url,
328+ styles = TextLinkStyles(
329+ SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330+ ),
331+ linkInteractionListener = {
332+ runCatching {
333+ context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334+ }
335+ },
336+ ),
337+ ) {
338+ append(url)
339+ }
340+ cursor = match.range.last + 1
341+ }
342+ append(text.substring(cursor))
343+}
344+
345+private fun shareText(context: Context, text: String) {
346+ val intent = Intent(Intent.ACTION_SEND).apply {
347+ type = "text/plain"
348+ putExtra(Intent.EXTRA_TEXT, text)
349+ }
350+ context.startActivity(Intent.createChooser(intent, null))
351+}
352+
353+@Composable
354+private fun ReasoningBlock(text: String, context: Context) {
355+ var expanded by remember { mutableStateOf(false) }
356+ Column(modifier = Modifier.padding(bottom = 6.dp)) {
357+ Row(
358+ verticalAlignment = Alignment.CenterVertically,
359+ modifier = Modifier.clickable { expanded = !expanded },
360+ ) {
361+ Text(
362+ text = if (expanded) "Show less" else "Reasoning",
363+ style = MaterialTheme.typography.bodySmall,
364+ color = MaterialTheme.colorScheme.onSurfaceVariant,
365+ )
366+ Icon(
367+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368+ contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
370+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371+ )
372+ }
373+ if (expanded) {
374+ Text(
375+ text = text,
376+ style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377+ color = MaterialTheme.colorScheme.onSurfaceVariant,
378+ modifier = Modifier.padding(top = 4.dp),
379+ )
380+ Text(
381+ text = "Copy",
382+ style = MaterialTheme.typography.labelSmall,
383+ color = MaterialTheme.colorScheme.primary,
384+ modifier = Modifier
385+ .padding(top = 4.dp)
386+ .clickable {
387+ copyToClipboard(context, text)
388+ Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389+ },
390+ )
391+ }
392+ }
393+}
394+
395+@Composable
396+private fun ToolRow(tool: ToolView) {
397+ var expanded by remember { mutableStateOf(false) }
398+ val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399+ val statusColor = when (tool.status) {
400+ "completed", "success" -> MaterialTheme.colorScheme.primary
401+ "error", "failed" -> MaterialTheme.colorScheme.error
402+ "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403+ else -> MaterialTheme.colorScheme.onSurfaceVariant
404+ }
405+ Row(
406+ verticalAlignment = Alignment.CenterVertically,
407+ modifier = Modifier
408+ .fillMaxWidth()
409+ .clickable { expanded = !expanded }
410+ .padding(bottom = 6.dp),
411+ ) {
412+ Icon(
413+ Icons.Filled.Build,
414+ contentDescription = null,
415+ tint = MaterialTheme.colorScheme.primary,
416+ modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417+ )
418+ Text(
419+ text = buildAnnotatedString {
420+ withStyle(SpanStyle(color = baseColor)) {
421+ append(tool.name)
422+ append(" · ")
423+ }
424+ withStyle(SpanStyle(color = statusColor)) {
425+ append(tool.status)
426+ }
427+ if (expanded) tool.title?.let {
428+ withStyle(SpanStyle(color = baseColor)) {
429+ append(" — ")
430+ append(it)
431+ }
432+ }
433+ },
434+ style = MonoStyle,
435+ color = baseColor,
436+ maxLines = if (expanded) Int.MAX_VALUE else 1,
437+ overflow = TextOverflow.Ellipsis,
438+ modifier = Modifier.weight(1f),
439+ )
440+ Icon(
441+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442+ contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
444+ modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445+ )
446+ }
447+}
448+
449+@Composable
450+private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451+ val (icon, label, tint) = when (status) {
452+ MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453+ MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454+ MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455+ }
456+ Row(
457+ verticalAlignment = Alignment.CenterVertically,
458+ horizontalArrangement = Arrangement.End,
459+ modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460+ ) {
461+ Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462+ Text(
463+ text = label,
464+ style = MaterialTheme.typography.labelSmall,
465+ color = tint,
466+ modifier = Modifier.padding(start = 4.dp),
467+ )
468+ if (status == MessageStatus.Failed) {
469+ Text(
470+ text = "Retry",
471+ style = MaterialTheme.typography.labelSmall,
472+ color = MaterialTheme.colorScheme.primary,
473+ modifier = Modifier
474+ .padding(start = 12.dp)
475+ .clickable { onRetry() },
476+ )
477+ }
478+ }
479+}
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktdeleted−143
@@ -1,143 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.util.Base64
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.material3.MaterialTheme
8−import androidx.compose.runtime.Composable
9−import androidx.compose.runtime.Immutable
10−import androidx.compose.runtime.remember
11−import androidx.compose.ui.Alignment
12−import androidx.compose.ui.Modifier
13−import androidx.compose.ui.draw.clip
14−import androidx.compose.ui.layout.ContentScale
15−import androidx.compose.ui.platform.LocalContext
16−import androidx.compose.ui.res.stringResource
17−import androidx.compose.ui.semantics.contentDescription
18−import androidx.compose.ui.semantics.semantics
19−import androidx.compose.ui.unit.dp
20−import coil.compose.SubcomposeAsyncImage
21−import coil.request.ImageRequest
22−import androidx.compose.material3.CircularProgressIndicator
23−import androidx.compose.material3.Icon
24−import androidx.compose.material.icons.Icons
25−import androidx.compose.material.icons.filled.BrokenImage
26−import soy.iko.opencode.R
27−import soy.iko.opencode.data.model.FilePart
28−import soy.iko.opencode.data.model.ServerProfile
29−import soy.iko.opencode.data.network.HttpClientFactory
30−
31−/**
32− * Carries the bits needed to load an image off the opencode server: the base URL (for
33− * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
34− */
35−@Immutable
36−data class ImageLoadContext(
37− val baseUrl: String,
38− val basicAuthHeader: String?,
39−)
40−
41−/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
42−fun ServerProfile.toImageContext(): ImageLoadContext {
43− val auth = if (hasAuth && !password.isNullOrEmpty()) {
44− val raw = "$username:$password".toByteArray()
45− "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
46− } else {
47− null
48− }
49− return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
50−}
51−
52−/** True if the part references a raster/vector image we can render. */
53−val FilePart.isImage: Boolean
54− get() = mime?.startsWith("image/") == true
55−
56−/**
57− * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
58− * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
59− */
60−private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
61− val src = source
62− if (!src.isNullOrBlank() && src.startsWith("data:")) {
63− val comma = src.indexOf(',')
64− if (comma < 0) return null
65− val payload = src.substring(comma + 1)
66− return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
67− }
68− val url = url ?: return null
69− return if (url.startsWith("http://") || url.startsWith("https://")) {
70− url
71− } else {
72− // Resolve the URL (relative or server-absolute like "/media/x.png") against the
73− // base, collapsing any ../ segments.
74− val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
75− val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
76− val resolved = runCatching {
77− java.net.URI(base).resolve(url).normalize()
78− }.getOrElse { return null }
79− // Guard on same origin (scheme + host + port), not base-path prefix: the real
80− // risk is redirecting the request (with its Basic auth) to another host. A
81− // server-absolute path resolves to a different base *path* but the same origin,
82− // so it must still be allowed — a prefix check wrongly rejected those.
83− val sameOrigin = resolved.host != null &&
84− resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
85− resolved.host.equals(baseUri.host, ignoreCase = true) &&
86− resolved.port == baseUri.port
87− if (sameOrigin) resolved.toString() else null
88− }
89−}
90−
91−/**
92− * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
93− * and server-relative URLs (fetched with Basic auth when the server requires it).
94− * Falls back to nothing when the part has no loadable source.
95− */
96−@Composable
97−fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
98− val context = LocalContext.current
99− val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
100− val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
101− ImageRequest.Builder(context)
102− .data(model)
103− .apply {
104− // Only send Basic auth over HTTPS; sending credentials over
105− // cleartext HTTP would expose them on the network.
106− val isHttps = (model as? String)?.startsWith("https://") == true
107− if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
108− }
109− .crossfade(true)
110− .build()
111− }
112− SubcomposeAsyncImage(
113− model = request,
114− contentDescription = part.filename ?: stringResource(R.string.image),
115− contentScale = ContentScale.FillWidth,
116− modifier = modifier
117− .heightIn(max = 320.dp)
118− .clip(MaterialTheme.shapes.small),
119− loading = {
120− Box(
121− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
122− contentAlignment = Alignment.Center,
123− ) {
124− val loadingLabel = stringResource(R.string.loading)
125− CircularProgressIndicator(
126− modifier = Modifier.semantics { contentDescription = loadingLabel },
127− )
128− }
129− },
130− error = {
131− Box(
132− modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
133− contentAlignment = Alignment.Center,
134− ) {
135− Icon(
136− Icons.Filled.BrokenImage,
137− contentDescription = stringResource(R.string.image_failed),
138− tint = MaterialTheme.colorScheme.onSurfaceVariant,
139− )
140− }
141− },
142− )
143−}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktdeleted−159
@@ -1,159 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.text.format.DateUtils
4−import androidx.compose.runtime.Composable
5−import androidx.compose.runtime.compositionLocalOf
6−import androidx.compose.runtime.derivedStateOf
7−import androidx.compose.runtime.getValue
8−import androidx.compose.runtime.mutableLongStateOf
9−import androidx.compose.runtime.remember
10−import androidx.compose.runtime.setValue
11−import androidx.lifecycle.Lifecycle
12−import androidx.lifecycle.compose.LocalLifecycleOwner
13−import androidx.lifecycle.repeatOnLifecycle
14−import kotlinx.coroutines.delay
15−import java.util.concurrent.TimeUnit
16−
17−/**
18− * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
19− *
20− * For coarse units (minutes/hours/days) prefers Android's locale-aware
21− * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
22− * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
23− * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
24− * `isReturnDefaultValues = true` stubs the framework call to null). For
25− * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
26− * uses the compact numeric form with a locale-stable unit suffix. Always returns a
27− * non-empty string for a valid timestamp.
28− */
29−fun relativeTime(epochMillis: Long?): String {
30− if (epochMillis == null || epochMillis <= 0) return ""
31− val now = System.currentTimeMillis()
32− val diff = now - epochMillis
33− if (diff < 0) return "now"
34− val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
35− if (minutes < 1) return "now"
36− if (minutes < 60) {
37− val locale = DateUtils.getRelativeTimeSpanString(
38− epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
39− )?.toString()
40− return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
41− }
42− val hours = TimeUnit.MILLISECONDS.toHours(diff)
43− if (hours < 24) {
44− val locale = DateUtils.getRelativeTimeSpanString(
45− epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
46− )?.toString()
47− return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
48− }
49− val days = TimeUnit.MILLISECONDS.toDays(diff)
50− if (days < 7) {
51− val locale = DateUtils.getRelativeTimeSpanString(
52− epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
53− )?.toString()
54− return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
55− }
56− // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
57− // numeric form. The suffix is locale-stable (English letter) but these are
58− // rare timestamps (older than a week) and the numeric value is the primary signal.
59− val weeks = days / 7
60− if (weeks < 5) return "${weeks}w"
61− val months = days / 30
62− if (months < 12) return "${months}mo"
63− val years = (days / 365).coerceAtLeast(1)
64− return "${years}y"
65−}
66−
67−/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
68− * enough for the smallest unit we show (minutes). */
69−private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
70−
71−/**
72− * A shared, screen-level "tick" that advances periodically so every relative-time label
73− * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
74− *
75− * val tick = rememberRelativeTimeTick()
76− * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
77− *
78− * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
79− * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
80− * timer churn as items enter and leave the viewport.
81− *
82− * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
83− * then, so it keeps auto-refreshing even without a provider.
84− */
85−val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
86−
87−/**
88− * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
89− * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
90− * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
91− */
92−@Composable
93−fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
94− var tick by remember { mutableLongStateOf(0L) }
95− val lifecycleOwner = LocalLifecycleOwner.current
96−
97− androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
98− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
99− // Refresh immediately on resume (so a stale label doesn't wait an interval),
100− // then periodically so labels stay current.
101− tick++
102− while (true) {
103− delay(intervalMs)
104− tick++
105− }
106− }
107− }
108− return tick
109−}
110−
111−/**
112− * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
113− * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
114− * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
115− * need a provider to get auto-refreshing labels.
116− */
117−@Composable
118−fun rememberRelativeTime(epochMillis: Long?): String {
119− val sharedTick = LocalRelativeTimeTick.current
120− return if (sharedTick != 0L) {
121− // Reading sharedTick above registers a recomposition dependency on the shared
122− // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
123− relativeTime(epochMillis)
124− } else {
125− rememberRelativeTimeStandalone(epochMillis)
126− }
127−}
128−
129−/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
130−@Composable
131−private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
132− var tick by remember { mutableLongStateOf(0L) }
133− val lifecycleOwner = LocalLifecycleOwner.current
134−
135− androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
136− if (epochMillis == null) return@LaunchedEffect
137− lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
138− // Tick immediately on resume so a stale label refreshes without waiting for
139− // the first interval, then periodically.
140− tick++
141− while (true) {
142− delay(RELATIVE_TIME_INTERVAL_MS)
143− tick++
144− }
145− }
146− }
147−
148− // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
149− // the relative-time string from the current wall clock on each tick. Keying remember
150− // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
151− val formatted by remember(epochMillis) {
152− derivedStateOf {
153− // Read tick to create a recomposition dependency on the timer.
154− tick
155− relativeTime(epochMillis)
156− }
157− }
158− return formatted
159−}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktdeleted−362
@@ -1,362 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.horizontalScroll
5−import androidx.compose.foundation.layout.Box
6−import androidx.compose.foundation.layout.Column
7−import androidx.compose.foundation.layout.Row
8−import androidx.compose.foundation.layout.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.Description
23−import androidx.compose.material.icons.filled.Folder
24−import androidx.compose.material.icons.filled.Home
25−import androidx.compose.material.icons.filled.Search
26−import androidx.compose.material3.CircularProgressIndicator
27−import androidx.compose.material3.ExperimentalMaterial3Api
28−import androidx.compose.material3.HorizontalDivider
29−import androidx.compose.material3.Icon
30−import androidx.compose.material3.IconButton
31−import androidx.compose.material3.MaterialTheme
32−import androidx.compose.material3.OutlinedTextField
33−import androidx.compose.material3.Scaffold
34−import androidx.compose.material3.SnackbarHost
35−import androidx.compose.material3.SnackbarHostState
36−import androidx.compose.material3.Text
37−import androidx.compose.material3.TopAppBar
38−import androidx.compose.runtime.Composable
39−import androidx.compose.runtime.LaunchedEffect
40−import androidx.compose.runtime.getValue
41−import androidx.compose.runtime.remember
42−import androidx.compose.ui.Alignment
43−import androidx.compose.ui.Modifier
44−import androidx.compose.ui.graphics.Color
45−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
46−import androidx.compose.ui.platform.testTag
47−import androidx.compose.ui.semantics.Role
48−import androidx.compose.ui.semantics.contentDescription
49−import androidx.compose.ui.semantics.role
50−import androidx.compose.ui.semantics.semantics
51−import androidx.compose.ui.text.font.FontFamily
52−import androidx.compose.ui.text.input.ImeAction
53−import androidx.compose.ui.text.style.TextOverflow
54−import androidx.compose.ui.unit.dp
55−import androidx.compose.ui.res.stringResource
56−import androidx.lifecycle.compose.collectAsStateWithLifecycle
57−import androidx.lifecycle.viewmodel.compose.viewModel
58−import soy.iko.opencode.data.model.FileStatusEntry
59−import soy.iko.opencode.di.AppContainer
60−import soy.iko.opencode.R
61−import soy.iko.opencode.ui.vmFactory
62−
63−@OptIn(ExperimentalMaterial3Api::class)
64−@Composable
65−fun FileBrowserScreen(
66− container: AppContainer,
67− onOpenFile: (String) -> Unit,
68− onBack: () -> Unit,
69−) {
70− val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
71− val state by vm.state.collectAsStateWithLifecycle()
72− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
73− val snackbar = remember { SnackbarHostState() }
74−
75− LaunchedEffect(Unit) {
76− vm.transientErrors.collect { msg ->
77− snackbar.showSnackbar(msg)
78− }
79− }
80−
81− Scaffold(
82− topBar = {
83− TopAppBar(
84− title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
85− navigationIcon = {
86− IconButton(onClick = onBack) {
87− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
88− }
89− },
90− )
91− },
92− snackbarHost = { SnackbarHost(snackbar) },
93− ) { padding ->
94− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
95− // Tappable breadcrumb trail so deep paths are navigable.
96− Breadcrumbs(
97− path = state.path,
98− onNavigate = vm::open,
99− modifier = Modifier.fillMaxWidth(),
100− )
101− val keyboardController = LocalSoftwareKeyboardController.current
102− OutlinedTextField(
103− value = state.query,
104− onValueChange = vm::setQuery,
105− modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("file_search"),
106− label = { Text(stringResource(R.string.search_files)) },
107− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
108− singleLine = true,
109− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
110− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
111− )
112−
113− androidx.compose.material3.pulltorefresh.PullToRefreshBox(
114− isRefreshing = refreshing,
115− onRefresh = { vm.refresh() },
116− modifier = Modifier.fillMaxSize(),
117− ) {
118− Box(modifier = Modifier.fillMaxSize()) {
119− when {
120− state.loading || state.searching -> {
121− val loadingLabel = stringResource(R.string.loading)
122− CircularProgressIndicator(
123− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
124− )
125− }
126− state.error != null -> Column(
127− modifier = Modifier.align(Alignment.Center).padding(24.dp),
128− horizontalAlignment = Alignment.CenterHorizontally,
129− ) {
130− Text(
131− state.error ?: "",
132− color = MaterialTheme.colorScheme.error,
133− )
134− androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
135− androidx.compose.material3.TextButton(onClick = { vm.open(state.path) }) {
136− Text(stringResource(R.string.retry))
137− }
138− }
139− state.isSearching -> SearchResults(state.results, onOpenFile)
140− else -> DirectoryListing(
141− state = state,
142− onOpenDir = vm::open,
143− onUp = vm::up,
144− onOpenFile = onOpenFile,
145− )
146− }
147− }
148− }
149− }
150− }
151−}
152−
153−@Composable
154−private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
155− val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
156− Row(
157− modifier = modifier
158− .horizontalScroll(rememberScrollState())
159− .padding(horizontal = 16.dp, vertical = 8.dp),
160− verticalAlignment = Alignment.CenterVertically,
161− ) {
162− IconButton(onClick = { onNavigate("") }) {
163− Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
164− }
165− var acc = ""
166− segments.forEachIndexed { index, segment ->
167− acc = if (acc.isEmpty()) segment else "$acc/$segment"
168− Icon(
169− Icons.Filled.ChevronRight,
170− contentDescription = null,
171− modifier = Modifier.size(16.dp),
172− tint = MaterialTheme.colorScheme.onSurfaceVariant,
173− )
174− val target = acc
175− Text(
176− segment,
177− style = MaterialTheme.typography.bodyMedium,
178− color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
179− modifier = Modifier
180− .clickable(role = Role.Button) { onNavigate(target) }
181− .defaultMinSize(minHeight = 48.dp)
182− .padding(horizontal = 6.dp, vertical = 14.dp),
183− )
184− }
185− }
186−}
187−
188−@Composable
189−private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
190− if (results.isEmpty()) {
191− EmptyFileState(
192− icon = Icons.Filled.Search,
193− message = stringResource(R.string.no_matches),
194− modifier = Modifier.fillMaxWidth().padding(24.dp),
195− )
196− return
197− }
198− LazyColumn(modifier = Modifier.fillMaxSize()) {
199− items(results, key = { it }) { path ->
200− // Split the path into directory (muted) + filename (emphasized) so results
201− // are scannable instead of a wall of identical-looking full paths.
202− val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
203− val name = path.substringAfterLast('/')
204− FileRow(
205− icon = false,
206− label = name,
207− sublabel = dir.takeIf { it.isNotEmpty() },
208− onClick = { onOpenFile(path) },
209− modifier = Modifier.animateItem(),
210− )
211− HorizontalDivider()
212− }
213− }
214−}
215−
216−@Composable
217−private fun DirectoryListing(
218− state: FileBrowserState,
219− onOpenDir: (String) -> Unit,
220− onUp: () -> Unit,
221− onOpenFile: (String) -> Unit,
222−) {
223− if (state.entries.isEmpty()) {
224− EmptyFileState(
225− icon = Icons.Filled.Folder,
226− message = stringResource(R.string.empty_folder),
227− modifier = Modifier.fillMaxWidth().padding(24.dp),
228− )
229− return
230− }
231− LazyColumn(modifier = Modifier.fillMaxSize()) {
232− if (state.path.isNotBlank()) {
233− item(key = "__up") {
234− FileRow(icon = true, label = "..", onClick = onUp)
235− HorizontalDivider()
236− }
237− }
238− items(state.entries, key = { it.path + "_" + it.name }) { node ->
239− FileRow(
240− icon = node.isDirectory,
241− label = node.name,
242− onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
243− status = state.statusMap[node.path],
244− modifier = Modifier.animateItem(),
245− )
246− HorizontalDivider()
247− }
248− }
249−}
250−
251−/** Shared empty-state for the file browser: icon + message, matching the icon+text
252− * pattern used by the session and server lists (the prior versions were bare Text
253− * lines that read as status messages rather than intentional empty states). */
254−@Composable
255−private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
256− Column(
257− modifier = modifier,
258− horizontalAlignment = Alignment.CenterHorizontally,
259− ) {
260− Icon(
261− icon,
262− contentDescription = null,
263− modifier = Modifier.size(48.dp),
264− tint = MaterialTheme.colorScheme.onSurfaceVariant,
265− )
266− Spacer(Modifier.size(12.dp))
267− Text(
268− message,
269− style = MaterialTheme.typography.bodyMedium,
270− color = MaterialTheme.colorScheme.onSurfaceVariant,
271− )
272− }
273−}
274−
275−@Composable
276−private fun FileRow(
277− icon: Boolean,
278− label: String,
279− onClick: () -> Unit,
280− status: FileStatusEntry? = null,
281− sublabel: String? = null,
282− modifier: Modifier = Modifier,
283−) {
284− val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
285− else if (icon) stringResource(R.string.folder, label)
286− else stringResource(R.string.file_label, label)
287− // Build a combined description so TalkBack announces both the file name and its git
288− // status. The parent sets an explicit contentDescription (mergeDescendants), which
289− // would otherwise suppress the StatusBadge's own description.
290− val statusDesc = status?.let {
291− when (it.status) {
292− "added" -> stringResource(R.string.git_added)
293− "modified" -> stringResource(R.string.git_modified)
294− "deleted" -> stringResource(R.string.git_deleted)
295− else -> ""
296− }
297− }.orEmpty()
298− val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
299− Row(
300− modifier = modifier
301− .fillMaxWidth()
302− .defaultMinSize(minHeight = 48.dp)
303− .clickable(role = Role.Button, onClick = onClick)
304− .semantics(mergeDescendants = true) { contentDescription = fullDesc }
305− .padding(horizontal = 16.dp, vertical = 14.dp),
306− verticalAlignment = Alignment.CenterVertically,
307− ) {
308− Icon(
309− if (icon) Icons.Filled.Folder else Icons.Filled.Description,
310− contentDescription = null,
311− modifier = Modifier.size(20.dp),
312− tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
313− )
314− Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
315− Text(
316− label,
317− style = MaterialTheme.typography.bodyLarge,
318− maxLines = 1,
319− overflow = TextOverflow.Ellipsis,
320− )
321− if (sublabel != null) {
322− Text(
323− sublabel,
324− style = MaterialTheme.typography.labelSmall,
325− color = MaterialTheme.colorScheme.onSurfaceVariant,
326− maxLines = 1,
327− overflow = TextOverflow.Ellipsis,
328− )
329− }
330− }
331− if (status != null) StatusBadge(status)
332− }
333−}
334−
335−@Composable
336−private fun StatusBadge(status: FileStatusEntry) {
337− val (letter, color) = when (status.status) {
338− "added" -> "A" to MaterialTheme.colorScheme.primary
339− "modified" -> "M" to MaterialTheme.colorScheme.tertiary
340− "deleted" -> "D" to MaterialTheme.colorScheme.error
341− else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
342− }
343− Row(
344− verticalAlignment = Alignment.CenterVertically,
345− ) {
346− Text(
347− letter,
348− style = MaterialTheme.typography.labelMedium,
349− fontFamily = FontFamily.Monospace,
350− color = color,
351− )
352− if (status.added > 0 || status.removed > 0) {
353− Text(
354− "+${status.added} −${status.removed}",
355− style = MaterialTheme.typography.labelSmall,
356− fontFamily = FontFamily.Monospace,
357− color = MaterialTheme.colorScheme.onSurfaceVariant,
358− modifier = Modifier.padding(start = 4.dp),
359− )
360− }
361− }
362−}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktdeleted−188
@@ -1,188 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import androidx.compose.runtime.Immutable
4−import androidx.lifecycle.ViewModel
5−import androidx.lifecycle.viewModelScope
6−import soy.iko.opencode.data.model.FileNode
7−import soy.iko.opencode.data.model.FileStatusEntry
8−import soy.iko.opencode.data.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.
76− open(_state.value.path)
77− }
78− }
79− }
80−
81− /** Tracks the in-flight VCS status load so a server switch can cancel it. */
82− private var statusJob: Job? = null
83−
84− /** Fetch the repo-wide VCS status once so file rows can show git badges. */
85− private fun loadStatus() {
86− val client = api ?: return
87− statusJob?.cancel()
88− statusJob = viewModelScope.launch {
89− runCatchingCancellable { client.fileStatus() }
90− .onSuccess { entries ->
91− _state.update { it.copy(statusMap = entries.associateBy { it.path }) }
92− }
93− .onFailure {
94− // VCS status is best-effort: don't block the file browser, but surface
95− // a transient error so the user knows why badges are missing.
96− _transientErrors.tryEmit(container.friendlyError(it))
97− }
98− }
99− }
100−
101− /** Re-run the current view (manual refresh). Used by pull-to-refresh. */
102− fun refresh() {
103− _refreshing.value = true
104− val searching = _state.value.isSearching
105− if (searching) {
106− // Re-run the active search rather than open(), which would reset query/results
107− // and drop the user back into the directory listing — discarding their search.
108− setQuery(_state.value.query)
109− } else {
110− open(_state.value.path)
111− }
112− viewModelScope.launch {
113− // Clear the spinner once the in-flight load settles by joining whichever job
114− // the refresh kicked off (search or directory open).
115− (if (searching) searchJob else openJob)?.join()
116− _refreshing.value = false
117− }
118− }
119−
120− fun open(path: String) {
121− val client = api ?: run {
122− _state.update { it.copy(
123− path = path,
124− loading = false,
125− error = container.string(R.string.not_connected),
126− ) }
127− return
128− }
129− _state.update { it.copy(
130− path = path,
131− query = "",
132− results = emptyList(),
133− searching = false,
134− loading = true,
135− error = null,
136− ) }
137− openJob?.cancel()
138− searchJob?.cancel()
139− openJob = viewModelScope.launch {
140− runCatchingCancellable { client.listDirectory(path) }
141− .onSuccess { entries ->
142− _state.update { it.copy(
143− entries = entries.sortedWith(compareByDescending<FileNode> { it.isDirectory }.thenBy { it.name.lowercase() }),
144− loading = false,
145− ) }
146− }
147− .onFailure { e -> _state.update { it.copy(loading = false, error = container.friendlyError(e)) } }
148− }
149− }
150−
151− /** Navigate up one path segment. */
152− fun up() {
153− val current = _state.value.path
154− if (current.isBlank()) return
155− val parent = current.trimEnd('/').substringBeforeLast('/', missingDelimiterValue = "")
156− open(parent)
157− }
158−
159− fun setQuery(query: String) {
160− _state.update { it.copy(query = query, error = null) }
161− searchJob?.cancel()
162− openJob?.cancel()
163− if (query.isBlank()) {
164− _state.update { it.copy(results = emptyList(), searching = false) }
165− // Cancelling openJob above may have interrupted an in-flight directory load,
166− // leaving loading=true stuck. Re-open the current directory so the listing
167− // re-populates instead of showing a perpetual spinner after clearing search.
168− open(_state.value.path)
169− return
170− }
171− // The search view replaces the directory listing; clear loading so the spinner
172− // from a cancelled open() doesn't persist into the search results view.
173− // Set searching=true immediately (not after the debounce) so the spinner shows
174− // during the debounce delay — otherwise the UI briefly renders SearchResults
175− // with an empty list, flashing "no matches" before the search even starts.
176− _state.update { it.copy(loading = false, searching = true) }
177− val client = api ?: run {
178− _state.update { it.copy(searching = false, error = container.string(R.string.not_connected)) }
179− return
180− }
181− searchJob = viewModelScope.launch {
182− delay(NetworkConfig.fileSearchDebounceMs) // debounce
183− runCatchingCancellable { client.findFiles(query) }
184− .onSuccess { results -> _state.update { it.copy(results = results, searching = false) } }
185− .onFailure { e -> _state.update { it.copy(searching = false, error = container.friendlyError(e)) } }
186− }
187− }
188−}
app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktdeleted−59
@@ -1,59 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import androidx.compose.runtime.Immutable
4−import androidx.lifecycle.ViewModel
5−import androidx.lifecycle.viewModelScope
6−import soy.iko.opencode.data.model.FileContent
7−import soy.iko.opencode.di.AppContainer
8−import soy.iko.opencode.R
9−import kotlinx.coroutines.ExperimentalCoroutinesApi
10−import kotlinx.coroutines.flow.MutableStateFlow
11−import kotlinx.coroutines.flow.StateFlow
12−import kotlinx.coroutines.flow.asStateFlow
13−import kotlinx.coroutines.flow.collectLatest
14−import kotlinx.coroutines.flow.merge
15−import kotlinx.coroutines.launch
16−import soy.iko.opencode.util.runCatchingCancellable
17−
18−@Immutable
19−data class FileViewState(
20− val loading: Boolean = true,
21− val content: FileContent? = null,
22− val error: String? = null,
23−)
24−
25−@OptIn(ExperimentalCoroutinesApi::class)
26−class FileViewModel(
27− private val container: AppContainer,
28− private val path: String,
29−) : ViewModel() {
30−
31− private val _state = MutableStateFlow(FileViewState())
32− val state: StateFlow<FileViewState> = _state.asStateFlow()
33−
34− /** Incremented by [reload] to trigger a fresh fetch without waiting for a connection change. */
35− private val _reload = MutableStateFlow(0)
36−
37− fun reload() { _reload.value++ }
38−
39− init {
40− // Observe the active connection so the file loads (or reloads) when a connection
41− // becomes available — including when the view opens during a reconnect window
42− // where activeConnection.value was momentarily null. collectLatest cancels the
43− // in-flight load if the connection is replaced mid-read. The reload trigger is
44− // merged in so a manual retry (e.g. after a transient error) re-fetches.
45− viewModelScope.launch {
46− merge(container.activeConnection, _reload).collectLatest {
47− val conn = container.activeConnection.value
48− if (conn == null) {
49− _state.value = FileViewState(loading = false, error = container.string(R.string.not_connected))
50− return@collectLatest
51− }
52− _state.value = FileViewState(loading = true)
53− runCatchingCancellable { conn.api.readFile(path) }
54− .onSuccess { _state.value = FileViewState(loading = false, content = it) }
55− .onFailure { _state.value = FileViewState(loading = false, error = container.friendlyError(it)) }
56− }
57− }
58− }
59−}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktdeleted−511
@@ -1,511 +0,0 @@
1−package soy.iko.opencode.ui.file
2−
3−import android.content.Intent
4−import soy.iko.opencode.ui.components.showToast
5−import androidx.compose.animation.AnimatedContent
6−import androidx.compose.animation.core.tween
7−import androidx.compose.animation.fadeIn
8−import androidx.compose.animation.fadeOut
9−import androidx.compose.animation.togetherWith
10−import androidx.compose.foundation.background
11−import androidx.compose.foundation.horizontalScroll
12−import androidx.compose.foundation.layout.Box
13−import androidx.compose.foundation.layout.BoxScope
14−import androidx.compose.foundation.layout.Column
15−import androidx.compose.foundation.layout.Arrangement
16−import androidx.compose.foundation.layout.Row
17−import androidx.compose.foundation.layout.Spacer
18−import androidx.compose.foundation.layout.fillMaxSize
19−import androidx.compose.foundation.layout.fillMaxWidth
20−import androidx.compose.foundation.layout.padding
21−import androidx.compose.foundation.layout.size
22−import androidx.compose.foundation.layout.width
23−import androidx.compose.foundation.lazy.LazyColumn
24−import androidx.compose.foundation.lazy.LazyListState
25−import androidx.compose.foundation.lazy.itemsIndexed
26−import androidx.compose.foundation.lazy.rememberLazyListState
27−import androidx.compose.foundation.rememberScrollState
28−import androidx.compose.foundation.text.KeyboardActions
29−import androidx.compose.foundation.text.KeyboardOptions
30−import androidx.compose.foundation.verticalScroll
31−import androidx.compose.material.icons.Icons
32−import androidx.compose.material.icons.automirrored.filled.ArrowBack
33−import androidx.compose.material.icons.automirrored.filled.WrapText
34−import androidx.compose.material.icons.filled.Close
35−import androidx.compose.material.icons.filled.ContentCopy
36−import androidx.compose.material.icons.filled.KeyboardArrowDown
37−import androidx.compose.material.icons.filled.KeyboardArrowUp
38−import androidx.compose.material.icons.filled.Refresh
39−import androidx.compose.material.icons.filled.Search
40−import androidx.compose.material.icons.filled.Share
41−import androidx.compose.material3.CircularProgressIndicator
42−import androidx.compose.material3.ExperimentalMaterial3Api
43−import androidx.compose.material3.FilterChip
44−import androidx.compose.material3.Icon
45−import androidx.compose.material3.IconButton
46−import androidx.compose.material3.MaterialTheme
47−import androidx.compose.material3.OutlinedTextField
48−import androidx.compose.material3.Scaffold
49−import androidx.compose.material3.Text
50−import androidx.compose.material3.TextButton
51−import androidx.compose.material3.TopAppBar
52−import androidx.compose.runtime.Composable
53−import androidx.compose.runtime.LaunchedEffect
54−import androidx.compose.runtime.getValue
55−import androidx.compose.runtime.mutableIntStateOf
56−import androidx.compose.runtime.mutableStateOf
57−import androidx.compose.runtime.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− showDiff = 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− FileViewStateContent(
163− state = s,
164− filename = filename,
165− showToggle = showToggle,
166− showDiff = showDiff,
167− onSetShowDiff = {
168− showDiff = it
169− // Close any open find bar when entering diff mode: find searches
170− // the raw content and scrolls the line LazyColumn, neither of
171− // which is visible in diff mode, so leaving it open would be a
172− // confusing no-op.
173− if (it) { findActive = false; findQuery = "" }
174− },
175− findActive = findActive,
176− findQuery = findQuery,
177− onQueryChange = { findQuery = it; matchPos = 0 },
178− matchIndices = matchIndices,
179− matchPos = matchPos,
180− onPrev = { if (matchIndices.isNotEmpty()) matchPos = (matchPos - 1 + matchIndices.size) % matchIndices.size },
181− onNext = { if (matchIndices.isNotEmpty()) matchPos = (matchPos + 1) % matchIndices.size },
182− onCloseFind = { findActive = false; findQuery = "" },
183− wrap = wrap,
184− listState = listState,
185− onRetry = { vm.reload() },
186− )
187− }
188− }
189− }
190−}
191−
192−@OptIn(ExperimentalMaterial3Api::class)
193−@Composable
194−private fun FileViewTopBar(
195− filename: String,
196− loading: Boolean,
197− content: String,
198− wrap: Boolean,
199− showDiff: Boolean,
200− onBack: () -> Unit,
201− onReload: () -> Unit,
202− onToggleFind: () -> Unit,
203− onToggleWrap: () -> Unit,
204− onCopy: () -> Unit,
205− onShare: () -> Unit,
206−) {
207− TopAppBar(
208− title = { Text(filename, maxLines = 1, overflow = TextOverflow.Ellipsis) },
209− navigationIcon = {
210− IconButton(onClick = onBack) {
211− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
212− }
213− },
214− actions = {
215− // In-place refresh: a file's content can change on the server (the agent may
216− // be editing it), so reload without requiring a back-out.
217− IconButton(onClick = onReload, enabled = !loading) {
218− Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
219− }
220− if (content.isNotEmpty()) {
221− // Find-in-file: toggles a search bar over the raw content. Disabled in
222− // diff mode — the find bar searches the raw text and scrolls the line
223− // LazyColumn, neither of which is visible when the DiffView is shown, so
224− // find would silently no-op. The user can switch to Raw to find.
225− IconButton(onClick = onToggleFind, enabled = !showDiff) {
226− Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.find_in_file))
227− }
228− // Wrap long lines instead of horizontal-scrolling, useful for prose.
229− IconButton(onClick = onToggleWrap) {
230− Icon(
231− Icons.AutoMirrored.Filled.WrapText,
232− contentDescription = stringResource(R.string.wrap_lines),
233− tint = if (wrap) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
234− )
235− }
236− IconButton(onClick = onCopy) {
237− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy))
238− }
239− IconButton(onClick = onShare) {
240− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
241− }
242− }
243− },
244− )
245−}
246−
247−@Composable
248−private fun BoxScope.FileViewStateContent(
249− state: FileViewState,
250− filename: String,
251− showToggle: Boolean,
252− showDiff: Boolean,
253− onSetShowDiff: (Boolean) -> Unit,
254− findActive: Boolean,
255− findQuery: String,
256− onQueryChange: (String) -> Unit,
257− matchIndices: List<Int>,
258− matchPos: Int,
259− onPrev: () -> Unit,
260− onNext: () -> Unit,
261− onCloseFind: () -> Unit,
262− wrap: Boolean,
263− listState: LazyListState,
264− onRetry: () -> Unit,
265−) {
266− when {
267− state.loading -> {
268− val loadingLabel = stringResource(R.string.loading)
269− CircularProgressIndicator(
270− Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
271− )
272− }
273− state.error != null -> Column(
274− modifier = Modifier.align(Alignment.Center).padding(24.dp),
275− horizontalAlignment = Alignment.CenterHorizontally,
276− ) {
277− Text(state.error ?: "", color = MaterialTheme.colorScheme.error)
278− Spacer(Modifier.size(12.dp))
279− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
280− }
281− state.content?.isBinary == true -> Text(
282− stringResource(R.string.binary_file),
283− modifier = Modifier.align(Alignment.Center).padding(24.dp),
284− color = MaterialTheme.colorScheme.onSurfaceVariant,
285− )
286− else -> FileViewContentBody(
287− content = state.content,
288− filename = filename,
289− showToggle = showToggle,
290− showDiff = showDiff,
291− onSetShowDiff = onSetShowDiff,
292− findActive = findActive,
293− findQuery = findQuery,
294− onQueryChange = onQueryChange,
295− matchIndices = matchIndices,
296− matchPos = matchPos,
297− onPrev = onPrev,
298− onNext = onNext,
299− onCloseFind = onCloseFind,
300− wrap = wrap,
301− listState = listState,
302− )
303− }
304−}
305−
306−@Composable
307−private fun BoxScope.FileViewContentBody(
308− content: soy.iko.opencode.data.model.FileContent?,
309− filename: String,
310− showToggle: Boolean,
311− showDiff: Boolean,
312− onSetShowDiff: (Boolean) -> Unit,
313− findActive: Boolean,
314− findQuery: String,
315− onQueryChange: (String) -> Unit,
316− matchIndices: List<Int>,
317− matchPos: Int,
318− onPrev: () -> Unit,
319− onNext: () -> Unit,
320− onCloseFind: () -> Unit,
321− wrap: Boolean,
322− listState: LazyListState,
323−) {
324− // Overlay bar: stacks the diff/raw chips and the find bar at the top so both stay
325− // reachable while scrolling. Each contributes a top inset so the content below
326− // isn't hidden behind the overlay. The inset is measured from the actual overlay
327− // height (not a hardcoded 52dp/row) so accessibility font scaling — which can
328− // make the FilterChip labels and FindBar text field taller than 52dp — doesn't
329− // cause the first lines of content to hide behind the overlay.
330− val overlayRows = (if (showToggle) 1 else 0) + (if (findActive && !showDiff) 1 else 0)
331− var overlayHeightPx by remember { mutableIntStateOf(0) }
332− val topInset = with(androidx.compose.ui.platform.LocalDensity.current) { overlayHeightPx.toDp() }
333− if (overlayRows > 0) {
334− Column(
335− modifier = Modifier
336− .align(Alignment.TopCenter)
337− .fillMaxWidth()
338− .onSizeChanged { overlayHeightPx = it.height }
339− .padding(top = 4.dp),
340− verticalArrangement = Arrangement.spacedBy(4.dp),
341− ) {
342− if (showToggle) {
343− Row(
344− modifier = Modifier.fillMaxWidth(),
345− horizontalArrangement = Arrangement.Center,
346− ) {
347− FilterChip(
348− selected = showDiff,
349− onClick = { onSetShowDiff(true) },
350− label = { Text(stringResource(R.string.show_diff)) },
351− )
352− Spacer(Modifier.size(8.dp))
353− FilterChip(
354− selected = !showDiff,
355− onClick = { onSetShowDiff(false) },
356− label = { Text(stringResource(R.string.show_raw)) },
357− )
358− }
359− }
360− if (findActive && !showDiff) {
361− FindBar(
362− query = findQuery,
363− onQueryChange = onQueryChange,
364− matchCount = matchIndices.size,
365− matchPos = matchPos,
366− onPrev = onPrev,
367− onNext = onNext,
368− onClose = onCloseFind,
369− )
370− }
371− }
372− }
373− if (showDiff && content?.diff != null && content.diff.isNotBlank()) {
374− DiffView(
375− diff = content.diff,
376− modifier = Modifier
377− .fillMaxSize()
378− .padding(top = topInset)
379− .verticalScroll(rememberScrollState())
380− .padding(8.dp),
381− )
382− } else {
383− val text = content?.content.orEmpty()
384− if (text.isEmpty()) {
385− Text(
386− stringResource(R.string.empty_file),
387− modifier = Modifier.align(Alignment.Center).padding(24.dp),
388− color = MaterialTheme.colorScheme.onSurfaceVariant,
389− )
390− } else {
391− FileTextContent(
392− text = text,
393− filename = filename,
394− topInset = topInset,
395− wrap = wrap,
396− findQuery = findQuery,
397− matchIndices = matchIndices,
398− listState = listState,
399− )
400− }
401− }
402−}
403−
404−@Composable
405−private fun FindBar(
406− query: String,
407− onQueryChange: (String) -> Unit,
408− matchCount: Int,
409− matchPos: Int,
410− onPrev: () -> Unit,
411− onNext: () -> Unit,
412− onClose: () -> Unit,
413−) {
414− val keyboardController = LocalSoftwareKeyboardController.current
415− Row(
416− modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
417− verticalAlignment = Alignment.CenterVertically,
418− ) {
419− OutlinedTextField(
420− value = query,
421− onValueChange = onQueryChange,
422− modifier = Modifier.weight(1f),
423− placeholder = { Text(stringResource(R.string.find_hint)) },
424− singleLine = true,
425− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
426− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
427− trailingIcon = {
428− val countText = if (query.isBlank()) ""
429− else if (matchCount == 0) stringResource(R.string.no_matches_in_file)
430− else stringResource(R.string.match_count, matchPos + 1, matchCount)
431− if (countText.isNotEmpty()) {
432− // Use error color for "no matches" so the zero-result state is
433− // distinguishable from a positive match count at a glance.
434− val color = if (query.isNotBlank() && matchCount == 0) MaterialTheme.colorScheme.error
435− else MaterialTheme.colorScheme.onSurfaceVariant
436− Text(
437− countText,
438− style = MaterialTheme.typography.labelSmall,
439− color = color,
440− )
441− }
442− },
443− )
444− IconButton(onClick = onPrev, enabled = matchCount > 0) {
445− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.find_previous))
446− }
447− IconButton(onClick = onNext, enabled = matchCount > 0) {
448− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.find_next))
449− }
450− IconButton(onClick = onClose) {
451− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
452− }
453− }
454−}
455−
456−@Composable
457−private fun FileTextContent(
458− text: String,
459− filename: String,
460− topInset: androidx.compose.ui.unit.Dp,
461− wrap: Boolean,
462− findQuery: String,
463− matchIndices: List<Int>,
464− listState: LazyListState,
465−) {
466− val lines = remember(text) { text.split("\n") }
467− // Gutter width scales with the digit count and the font scale so accessibility
468− // text scaling (e.g. 1.3x) doesn't make line numbers overflow the gutter and
469− // collide with the code text. 10dp/digit covers bodySmall at 1.0x; scale up
470− // proportionally for larger font scales.
471− val fontScale = androidx.compose.ui.platform.LocalDensity.current.fontScale
472− val gutterWidth = remember(lines.size, fontScale) {
473− ((lines.size.toString().length.coerceAtLeast(3) * 10) * fontScale.coerceAtLeast(1f)).dp
474− }
475− val hScrollState = rememberScrollState()
476− val palette = rememberHighlightPalette()
477− val q = findQuery.trim()
478− val matchSet = remember(matchIndices) { matchIndices.toHashSet() }
479− LazyColumn(
480− state = listState,
481− modifier = Modifier
482− .fillMaxSize()
483− .padding(top = topInset)
484− .padding(8.dp),
485− ) {
486− itemsIndexed(lines, key = { index, _ -> index }) { index, line ->
487− val isMatch = q.isNotEmpty() && index in matchSet
488− Row(
489− modifier = Modifier
490− .fillMaxWidth()
491− .then(if (wrap) Modifier else Modifier.horizontalScroll(hScrollState))
492− .then(if (isMatch) Modifier.background(MaterialTheme.colorScheme.secondaryContainer) else Modifier),
493− ) {
494− Text(
495− "${index + 1}",
496− modifier = Modifier.width(gutterWidth),
497− style = MaterialTheme.typography.bodySmall,
498− fontFamily = FontFamily.Monospace,
499− color = MaterialTheme.colorScheme.onSurfaceVariant,
500− )
501− // Heuristic syntax highlighting per line. Falls back to plain text for
502− // unknown extensions, so non-code files render unchanged.
503− Text(
504− highlightLine(line, filename, palette),
505− modifier = Modifier.padding(start = 8.dp),
506− style = MaterialTheme.typography.bodySmall,
507− )
508− }
509− }
510− }
511−}
app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+590
@@ -0,0 +1,590 @@
1+package soy.iko.opencode.ui.screens
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.animation.AnimatedVisibility
8+import androidx.compose.animation.core.RepeatMode
9+import androidx.compose.animation.core.animateFloat
10+import androidx.compose.animation.core.infiniteRepeatable
11+import androidx.compose.animation.core.rememberInfiniteTransition
12+import androidx.compose.animation.core.tween
13+import androidx.compose.animation.fadeIn
14+import androidx.compose.animation.fadeOut
15+import androidx.compose.animation.scaleIn
16+import androidx.compose.animation.scaleOut
17+import androidx.compose.foundation.ExperimentalFoundationApi
18+import androidx.compose.foundation.background
19+import androidx.compose.foundation.clickable
20+import androidx.compose.foundation.combinedClickable
21+import androidx.compose.foundation.layout.Arrangement
22+import androidx.compose.foundation.layout.Box
23+import androidx.compose.foundation.layout.BoxScope
24+import androidx.compose.foundation.layout.Column
25+import androidx.compose.foundation.layout.Row
26+import androidx.compose.foundation.layout.Spacer
27+import androidx.compose.foundation.layout.fillMaxSize
28+import androidx.compose.foundation.layout.fillMaxWidth
29+import androidx.compose.foundation.layout.heightIn
30+import androidx.compose.foundation.layout.PaddingValues
31+import androidx.compose.foundation.layout.WindowInsets
32+import androidx.compose.foundation.layout.ime
33+import androidx.compose.foundation.layout.imePadding
34+import androidx.compose.foundation.layout.padding
35+import androidx.compose.foundation.layout.size
36+import androidx.compose.foundation.lazy.LazyColumn
37+import androidx.compose.foundation.lazy.itemsIndexed
38+import androidx.compose.foundation.lazy.rememberLazyListState
39+import androidx.compose.foundation.shape.CircleShape
40+import androidx.compose.foundation.shape.RoundedCornerShape
41+import androidx.compose.foundation.text.KeyboardActions
42+import androidx.compose.foundation.text.KeyboardOptions
43+import androidx.compose.material.icons.Icons
44+import androidx.compose.material.icons.automirrored.filled.ArrowBack
45+import androidx.compose.material.icons.automirrored.filled.Send
46+import androidx.compose.material.icons.filled.ChatBubbleOutline
47+import androidx.compose.material.icons.filled.Close
48+import androidx.compose.material.icons.filled.KeyboardArrowDown
49+import androidx.compose.material.icons.filled.KeyboardArrowUp
50+import androidx.compose.material.icons.filled.Stop
51+import androidx.compose.material3.CircularProgressIndicator
52+import androidx.compose.material3.ExperimentalMaterial3Api
53+import androidx.compose.material3.FilledIconButton
54+import androidx.compose.material3.FilledTonalButton
55+import androidx.compose.material3.FloatingActionButton
56+import androidx.compose.material3.Icon
57+import androidx.compose.material3.IconButton
58+import androidx.compose.material3.LinearProgressIndicator
59+import androidx.compose.material3.MaterialTheme
60+import androidx.compose.material3.OutlinedTextField
61+import androidx.compose.material3.Scaffold
62+import androidx.compose.material3.Surface
63+import androidx.compose.material3.Text
64+import androidx.compose.material3.TextButton
65+import androidx.compose.material3.TopAppBar
66+import androidx.compose.runtime.Composable
67+import androidx.compose.runtime.LaunchedEffect
68+import androidx.compose.runtime.derivedStateOf
69+import androidx.compose.runtime.getValue
70+import androidx.compose.runtime.mutableStateOf
71+import androidx.compose.runtime.remember
72+import androidx.compose.runtime.rememberCoroutineScope
73+import androidx.compose.runtime.setValue
74+import androidx.compose.ui.Alignment
75+import androidx.compose.ui.Modifier
76+import androidx.compose.ui.draw.clip
77+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
78+import androidx.compose.ui.platform.LocalClipboardManager
79+import androidx.compose.ui.platform.LocalContext
80+import androidx.compose.ui.platform.LocalDensity
81+import androidx.compose.ui.platform.LocalHapticFeedback
82+import androidx.compose.ui.text.font.FontStyle
83+import androidx.compose.ui.text.input.ImeAction
84+import androidx.compose.ui.text.style.TextAlign
85+import androidx.compose.ui.text.style.TextOverflow
86+import androidx.compose.ui.unit.dp
87+import kotlinx.coroutines.launch
88+import soy.iko.opencode.core.Event
89+import soy.iko.opencode.core.UiState
90+import soy.iko.opencode.ui.components.MessageBubble
91+
92+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
93+@Composable
94+fun ChatScreen(state: UiState, dispatch: (Event) -> Unit) {
95+ val listState = rememberLazyListState()
96+ val coroutineScope = rememberCoroutineScope()
97+ val context = LocalContext.current
98+
99+ // Hide the jump-to-bottom FAB while the keyboard is up: it would otherwise
100+ // sit over the top of the IME. Reading the ime inset bottom during composition
101+ // observes snapshot state, so this flips as the keyboard opens/closes.
102+ val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
103+
104+ // Show a jump-to-bottom button once the user scrolls up away from the latest
105+ // message. derivedStateOf keeps this from recomposing on every pixel of scroll —
106+ // it only flips when the near-bottom threshold is actually crossed.
107+ val showScrollButton by remember {
108+ derivedStateOf {
109+ val layoutInfo = listState.layoutInfo
110+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
111+ val totalItems = layoutInfo.totalItemsCount
112+ totalItems > 0 && lastVisibleIndex < totalItems - 3
113+ }
114+ }
115+
116+ // Offer a jump-to-top shortcut once the conversation is long and the user has
117+ // scrolled a few messages down — saves flick-scrolling back through history.
118+ val showScrollTop by remember {
119+ derivedStateOf { state.messages.size > 10 && listState.firstVisibleItemIndex > 3 }
120+ }
121+
122+ // Track how many messages the user has "seen" (i.e. was at the bottom for). While
123+ // they're scrolled up, any messages beyond that count are unseen — flag the FAB.
124+ var lastSeenCount by remember { mutableStateOf(0) }
125+ LaunchedEffect(showScrollButton, state.messages.size) {
126+ if (!showScrollButton) {
127+ lastSeenCount = state.messages.size
128+ }
129+ }
130+ val hasUnseenMessages = showScrollButton && state.messages.size > lastSeenCount
131+
132+ // Keep the newest content in view as it streams in, but only when the user is
133+ // already near the bottom. If they've scrolled up to read history, leave it alone.
134+ val lastLen = state.messages.lastOrNull()?.text?.length ?: 0
135+ LaunchedEffect(state.messages.size, lastLen, state.generating) {
136+ if (state.messages.isNotEmpty()) {
137+ val layoutInfo = listState.layoutInfo
138+ val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
139+ val totalItems = layoutInfo.totalItemsCount
140+ val nearBottom = totalItems == 0 || lastVisibleIndex >= totalItems - 3
141+ if (nearBottom) {
142+ listState.animateScrollToItem(state.messages.size - 1)
143+ }
144+ }
145+ }
146+
147+ Scaffold(
148+ topBar = {
149+ TopAppBar(
150+ title = {
151+ val title = state.currentSessionTitle.ifEmpty { "Chat" }
152+ Text(
153+ text = if (state.messages.isNotEmpty()) "$title (${state.messages.size})" else title,
154+ maxLines = 1,
155+ overflow = TextOverflow.Ellipsis,
156+ modifier = Modifier.combinedClickable(
157+ onClick = { dispatch(Event.NavigateToSessions) },
158+ onLongClick = {
159+ state.currentSessionId?.let { id ->
160+ copyToClipboard(context, id)
161+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
162+ }
163+ },
164+ ),
165+ )
166+ },
167+ navigationIcon = {
168+ IconButton(onClick = { dispatch(Event.NavigateToSessions) }) {
169+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to sessions")
170+ }
171+ },
172+ actions = {
173+ if (state.generating) {
174+ CircularProgressIndicator(
175+ modifier = Modifier.padding(end = 16.dp).size(22.dp),
176+ strokeWidth = 2.dp,
177+ )
178+ }
179+ },
180+ )
181+ },
182+ bottomBar = {
183+ ChatInputBar(
184+ draft = state.draft,
185+ generating = state.generating,
186+ loading = state.loading && state.messages.isEmpty(),
187+ onDraftChange = { dispatch(Event.DraftChanged(it)) },
188+ onSend = { dispatch(Event.SendMessage(state.draft)) },
189+ onStop = { dispatch(Event.CancelGeneration) },
190+ )
191+ },
192+ ) { padding ->
193+ Column(Modifier.fillMaxSize().padding(padding)) {
194+ if (!state.sseConnected && !state.generating) {
195+ ReconnectingBanner()
196+ }
197+ if (state.loading && state.messages.isEmpty()) {
198+ LinearProgressIndicator(Modifier.fillMaxWidth())
199+ }
200+ if (state.error != null && state.messages.isNotEmpty()) {
201+ ErrorBanner(
202+ message = state.error,
203+ onDismiss = { dispatch(Event.DismissError) },
204+ )
205+ }
206+ Box(Modifier.fillMaxWidth().weight(1f)) {
207+ if (state.messages.isEmpty() && !state.loading) {
208+ Column(
209+ modifier = Modifier.align(Alignment.Center).padding(32.dp),
210+ horizontalAlignment = Alignment.CenterHorizontally,
211+ ) {
212+ Icon(
213+ Icons.Filled.ChatBubbleOutline,
214+ contentDescription = null,
215+ modifier = Modifier.size(48.dp),
216+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
217+ )
218+ Spacer(Modifier.size(16.dp))
219+ Text(
220+ text = "Send a message to start the conversation.",
221+ style = MaterialTheme.typography.bodyMedium,
222+ color = MaterialTheme.colorScheme.onSurfaceVariant,
223+ textAlign = TextAlign.Center,
224+ )
225+ }
226+ }
227+ LazyColumn(
228+ state = listState,
229+ modifier = Modifier.fillMaxSize(),
230+ contentPadding = PaddingValues(vertical = 8.dp),
231+ ) {
232+ itemsIndexed(state.messages, key = { _, message -> message.id }) { index, message ->
233+ // Insert a date separator whenever a message falls on a different
234+ // calendar day than the one before it (and above the first message).
235+ val previous = state.messages.getOrNull(index - 1)
236+ if (shouldShowDateSeparator(previous, message)) {
237+ DateSeparator(message.time)
238+ }
239+ val sameSenderAsPrevious = index > 0 && state.messages[index - 1].isUser == message.isUser
240+ MessageBubble(
241+ message,
242+ onRetry = { dispatch(Event.SendMessage(message.text)) },
243+ compactSpacing = sameSenderAsPrevious,
244+ )
245+ }
246+ // While the assistant is spinning up but hasn't emitted a streaming
247+ // message yet (only the user's message is present), show a typing
248+ // indicator so the wait doesn't look like a stall.
249+ if (state.generating && state.messages.none { it.streaming }) {
250+ item(key = "typing-indicator") {
251+ TypingIndicator()
252+ }
253+ }
254+ }
255+ ScrollToBottomButton(
256+ visible = showScrollButton && !isImeVisible,
257+ showBadge = hasUnseenMessages,
258+ onClick = {
259+ coroutineScope.launch {
260+ if (state.messages.isNotEmpty()) {
261+ // When the typing indicator is showing it occupies the item
262+ // just past the last message, so scroll one further to reach it.
263+ val target = if (state.generating && state.messages.none { it.streaming })
264+ state.messages.size
265+ else
266+ state.messages.size - 1
267+ listState.animateScrollToItem(target)
268+ }
269+ }
270+ },
271+ )
272+ ScrollToTopButton(
273+ visible = showScrollTop,
274+ onClick = {
275+ coroutineScope.launch {
276+ listState.animateScrollToItem(0)
277+ }
278+ },
279+ )
280+ }
281+ }
282+ }
283+}
284+
285+// Extracted into a BoxScope extension so the top-level AnimatedVisibility overload
286+// resolves cleanly. Called inline inside a Column, the enclosing ColumnScope receiver
287+// would otherwise capture the ColumnScope.AnimatedVisibility overload and fail to compile.
288+@Composable
289+private fun BoxScope.ScrollToBottomButton(visible: Boolean, showBadge: Boolean, onClick: () -> Unit) {
290+ AnimatedVisibility(
291+ visible = visible,
292+ modifier = Modifier.align(Alignment.BottomEnd),
293+ enter = scaleIn() + fadeIn(),
294+ exit = scaleOut() + fadeOut(),
295+ ) {
296+ Box(Modifier.padding(16.dp)) {
297+ FloatingActionButton(
298+ onClick = onClick,
299+ modifier = Modifier.size(44.dp),
300+ ) {
301+ Icon(
302+ Icons.Filled.KeyboardArrowDown,
303+ contentDescription = "Scroll to latest message",
304+ )
305+ }
306+ if (showBadge) {
307+ Box(
308+ modifier = Modifier
309+ .align(Alignment.TopEnd)
310+ .size(8.dp)
311+ .clip(CircleShape)
312+ .background(MaterialTheme.colorScheme.error),
313+ )
314+ }
315+ }
316+ }
317+}
318+
319+// Mirror of ScrollToBottomButton, pinned to the top-start corner. Same BoxScope
320+// extension trick so the top-level AnimatedVisibility overload resolves cleanly.
321+@Composable
322+private fun BoxScope.ScrollToTopButton(visible: Boolean, onClick: () -> Unit) {
323+ AnimatedVisibility(
324+ visible = visible,
325+ modifier = Modifier.align(Alignment.TopStart),
326+ enter = scaleIn() + fadeIn(),
327+ exit = scaleOut() + fadeOut(),
328+ ) {
329+ Box(Modifier.padding(16.dp)) {
330+ FloatingActionButton(
331+ onClick = onClick,
332+ modifier = Modifier.size(44.dp),
333+ ) {
334+ Icon(
335+ Icons.Filled.KeyboardArrowUp,
336+ contentDescription = "Scroll to top",
337+ )
338+ }
339+ }
340+ }
341+}
342+
343+private fun copyToClipboard(context: Context, text: String) {
344+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
345+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
346+}
347+
348+private val dateSeparatorFormat = java.time.format.DateTimeFormatter.ofPattern("MMM d")
349+
350+/** Convert an epoch-millis timestamp to the local calendar day it falls on. */
351+private fun localDayOf(epochMillis: Long): java.time.LocalDate =
352+ java.time.Instant.ofEpochMilli(epochMillis)
353+ .atZone(java.time.ZoneId.systemDefault())
354+ .toLocalDate()
355+
356+/**
357+ * A separator is shown above a message when it starts a new calendar day — i.e. the
358+ * first timestamped message, or one whose day differs from the previous message's.
359+ * Messages without a valid time (time <= 0) never get one.
360+ */
361+private fun shouldShowDateSeparator(previous: soy.iko.opencode.core.MessageView?, current: soy.iko.opencode.core.MessageView): Boolean {
362+ if (current.time <= 0) return false
363+ if (previous == null || previous.time <= 0) return true
364+ return localDayOf(previous.time) != localDayOf(current.time)
365+}
366+
367+/** Human-friendly day label: "Today", "Yesterday", or "MMM d" (e.g. "Jul 8"). */
368+private fun dateSeparatorLabel(day: java.time.LocalDate): String {
369+ val today = java.time.LocalDate.now()
370+ return when (day) {
371+ today -> "Today"
372+ today.minusDays(1) -> "Yesterday"
373+ else -> day.format(dateSeparatorFormat)
374+ }
375+}
376+
377+@Composable
378+private fun DateSeparator(epochMillis: Long) {
379+ Text(
380+ text = dateSeparatorLabel(localDayOf(epochMillis)),
381+ style = MaterialTheme.typography.labelSmall,
382+ color = MaterialTheme.colorScheme.onSurfaceVariant,
383+ textAlign = TextAlign.Center,
384+ modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
385+ )
386+}
387+
388+// Assistant-style bubble with three sequentially-pulsing dots, shown as a stand-in
389+// until the real streaming reply starts. Mirrors MessageBubble's left-aligned,
390+// surfaceVariant look so it reads as an incoming message.
391+@Composable
392+private fun TypingIndicator() {
393+ val shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp, bottomStart = 4.dp, bottomEnd = 16.dp)
394+ val transition = rememberInfiniteTransition(label = "typing")
395+ Row(
396+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
397+ horizontalArrangement = Arrangement.Start,
398+ ) {
399+ Row(
400+ modifier = Modifier
401+ .clip(shape)
402+ .background(MaterialTheme.colorScheme.surfaceVariant)
403+ .padding(horizontal = 14.dp, vertical = 12.dp),
404+ verticalAlignment = Alignment.CenterVertically,
405+ horizontalArrangement = Arrangement.spacedBy(6.dp),
406+ ) {
407+ Text(
408+ text = "Thinking",
409+ style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
410+ color = MaterialTheme.colorScheme.onSurfaceVariant,
411+ )
412+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
413+ // Stagger the three dots so they fade in and out in sequence.
414+ listOf(0, 150, 300).forEach { staggerMs ->
415+ val alpha by transition.animateFloat(
416+ initialValue = 0.2f,
417+ targetValue = 1f,
418+ animationSpec = infiniteRepeatable(
419+ animation = tween(durationMillis = 600, delayMillis = staggerMs),
420+ repeatMode = RepeatMode.Reverse,
421+ ),
422+ label = "dot",
423+ )
424+ Box(
425+ modifier = Modifier
426+ .size(6.dp)
427+ .clip(CircleShape)
428+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha)),
429+ )
430+ }
431+ }
432+ }
433+ }
434+}
435+
436+@Composable
437+private fun ReconnectingBanner() {
438+ Surface(
439+ modifier = Modifier.fillMaxWidth(),
440+ color = MaterialTheme.colorScheme.tertiaryContainer,
441+ ) {
442+ Row(
443+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
444+ verticalAlignment = Alignment.CenterVertically,
445+ horizontalArrangement = Arrangement.spacedBy(8.dp),
446+ ) {
447+ CircularProgressIndicator(
448+ modifier = Modifier.size(14.dp),
449+ strokeWidth = 2.dp,
450+ color = MaterialTheme.colorScheme.onTertiaryContainer,
451+ )
452+ Text(
453+ text = "Reconnecting…",
454+ style = MaterialTheme.typography.bodySmall,
455+ color = MaterialTheme.colorScheme.onTertiaryContainer,
456+ )
457+ }
458+ }
459+}
460+
461+@Composable
462+private fun ErrorBanner(message: String, onDismiss: () -> Unit) {
463+ Surface(
464+ modifier = Modifier.fillMaxWidth(),
465+ color = MaterialTheme.colorScheme.errorContainer,
466+ ) {
467+ Row(
468+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, top = 6.dp, bottom = 6.dp, end = 4.dp),
469+ verticalAlignment = Alignment.CenterVertically,
470+ horizontalArrangement = Arrangement.spacedBy(8.dp),
471+ ) {
472+ Text(
473+ text = message,
474+ style = MaterialTheme.typography.bodySmall,
475+ color = MaterialTheme.colorScheme.onErrorContainer,
476+ modifier = Modifier.weight(1f),
477+ )
478+ IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
479+ Icon(
480+ Icons.Filled.Close,
481+ contentDescription = "Dismiss error",
482+ tint = MaterialTheme.colorScheme.onErrorContainer,
483+ modifier = Modifier.size(18.dp),
484+ )
485+ }
486+ }
487+ }
488+}
489+
490+@Composable
491+private fun ChatInputBar(
492+ draft: String,
493+ generating: Boolean,
494+ loading: Boolean,
495+ onDraftChange: (String) -> Unit,
496+ onSend: () -> Unit,
497+ onStop: () -> Unit,
498+) {
499+ val haptic = LocalHapticFeedback.current
500+ val clipboardManager = LocalClipboardManager.current
501+ Surface(tonalElevation = 3.dp) {
502+ Column(
503+ modifier = Modifier.fillMaxWidth().imePadding().padding(horizontal = 12.dp, vertical = 8.dp),
504+ ) {
505+ Row(
506+ modifier = Modifier.fillMaxWidth(),
507+ verticalAlignment = Alignment.Bottom,
508+ horizontalArrangement = Arrangement.spacedBy(8.dp),
509+ ) {
510+ OutlinedTextField(
511+ value = draft,
512+ onValueChange = onDraftChange,
513+ modifier = Modifier.weight(1f).heightIn(min = 48.dp),
514+ enabled = !loading,
515+ placeholder = { Text("Message opencode…") },
516+ maxLines = 5,
517+ // Offer a quick way to wipe the draft once there's something to clear
518+ // and we're not mid-generation (where the field is a poor place to fuss).
519+ trailingIcon = if (draft.isNotEmpty() && !generating) {
520+ {
521+ IconButton(
522+ onClick = { onDraftChange("") },
523+ modifier = Modifier.size(20.dp),
524+ ) {
525+ Icon(
526+ Icons.Filled.Close,
527+ contentDescription = "Clear draft",
528+ modifier = Modifier.size(16.dp),
529+ )
530+ }
531+ }
532+ } else {
533+ null
534+ },
535+ // Surface a running count once the draft gets long, so the user has a
536+ // sense of scale before sending a wall of text.
537+ supportingText = if (draft.length > 500) {
538+ {
539+ Text(
540+ text = "${draft.length} characters",
541+ style = MaterialTheme.typography.labelSmall,
542+ color = MaterialTheme.colorScheme.onSurfaceVariant,
543+ textAlign = TextAlign.End,
544+ modifier = Modifier.fillMaxWidth(),
545+ )
546+ }
547+ } else {
548+ null
549+ },
550+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
551+ keyboardActions = KeyboardActions(onSend = {
552+ if (!generating && !loading && draft.isNotBlank()) onSend()
553+ }),
554+ )
555+ if (generating) {
556+ FilledTonalButton(onClick = onStop) {
557+ Icon(
558+ Icons.Filled.Stop,
559+ contentDescription = null,
560+ modifier = Modifier.size(18.dp),
561+ )
562+ Spacer(Modifier.size(6.dp))
563+ Text("Stop")
564+ }
565+ } else {
566+ FilledIconButton(
567+ onClick = {
568+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
569+ onSend()
570+ },
571+ enabled = draft.isNotBlank() && !loading,
572+ ) {
573+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
574+ }
575+ }
576+ }
577+ // With nothing typed yet, offer a one-tap way to drop in clipboard contents.
578+ if (draft.isEmpty() && !generating && !loading) {
579+ TextButton(
580+ onClick = {
581+ clipboardManager.getText()?.text?.let { onDraftChange(it) }
582+ },
583+ contentPadding = PaddingValues(0.dp),
584+ ) {
585+ Text("Paste")
586+ }
587+ }
588+ }
589+ }
590+}
app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktnew file+222
@@ -0,0 +1,222 @@
1+package soy.iko.opencode.ui.screens
2+
3+import androidx.compose.foundation.layout.Arrangement
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Spacer
6+import androidx.compose.foundation.layout.fillMaxSize
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.imePadding
10+import androidx.compose.foundation.layout.padding
11+import androidx.compose.foundation.layout.size
12+import androidx.compose.foundation.rememberScrollState
13+import androidx.compose.foundation.text.KeyboardActions
14+import androidx.compose.foundation.text.KeyboardOptions
15+import androidx.compose.foundation.verticalScroll
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Lock
18+import androidx.compose.material.icons.filled.Person
19+import androidx.compose.material.icons.filled.Terminal
20+import androidx.compose.material.icons.filled.Visibility
21+import androidx.compose.material.icons.filled.VisibilityOff
22+import androidx.compose.material3.Button
23+import androidx.compose.material3.CircularProgressIndicator
24+import androidx.compose.material3.Icon
25+import androidx.compose.material3.IconButton
26+import androidx.compose.material3.MaterialTheme
27+import androidx.compose.material3.OutlinedTextField
28+import androidx.compose.material3.Text
29+import androidx.compose.runtime.Composable
30+import androidx.compose.runtime.LaunchedEffect
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.setValue
35+import androidx.compose.ui.Alignment
36+import androidx.compose.ui.Modifier
37+import androidx.compose.ui.focus.FocusRequester
38+import androidx.compose.ui.focus.focusRequester
39+import androidx.compose.ui.text.input.ImeAction
40+import androidx.compose.ui.text.input.KeyboardType
41+import androidx.compose.ui.text.input.PasswordVisualTransformation
42+import androidx.compose.ui.text.input.VisualTransformation
43+import androidx.compose.ui.text.style.TextAlign
44+import androidx.compose.ui.unit.dp
45+import soy.iko.opencode.core.Event
46+import soy.iko.opencode.core.UiState
47+
48+@Composable
49+fun ConnectScreen(state: UiState, dispatch: (Event) -> Unit) {
50+ val focusRequester = remember { FocusRequester() }
51+ var passwordVisible by remember { mutableStateOf(false) }
52+
53+ // Focus the server URL field when the screen opens, but only when it's empty
54+ // so we don't steal focus (and pop the keyboard) on a return visit.
55+ LaunchedEffect(Unit) {
56+ if (state.serverUrl.isEmpty()) focusRequester.requestFocus()
57+ }
58+
59+ Column(
60+ modifier = Modifier
61+ .fillMaxSize()
62+ .verticalScroll(rememberScrollState())
63+ .imePadding()
64+ .padding(horizontal = 28.dp),
65+ verticalArrangement = Arrangement.Center,
66+ horizontalAlignment = Alignment.CenterHorizontally,
67+ ) {
68+ Icon(
69+ Icons.Filled.Terminal,
70+ contentDescription = null,
71+ tint = MaterialTheme.colorScheme.primary,
72+ modifier = Modifier.size(72.dp),
73+ )
74+ Spacer(Modifier.height(16.dp))
75+ Text(
76+ text = "opencode",
77+ style = MaterialTheme.typography.displaySmall,
78+ color = MaterialTheme.colorScheme.primary,
79+ )
80+ Text(
81+ text = "Connect to your opencode server",
82+ style = MaterialTheme.typography.bodyMedium,
83+ color = MaterialTheme.colorScheme.onSurfaceVariant,
84+ textAlign = TextAlign.Center,
85+ )
86+
87+ Spacer(Modifier.height(32.dp))
88+
89+ OutlinedTextField(
90+ value = state.serverUrl,
91+ onValueChange = { dispatch(Event.ServerUrlChanged(it)) },
92+ label = { Text("Server URL") },
93+ placeholder = { Text("http://192.168.1.10:4096") },
94+ singleLine = true,
95+ modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
96+ keyboardOptions = KeyboardOptions(
97+ keyboardType = KeyboardType.Uri,
98+ imeAction = if (state.authRequired) ImeAction.Next else ImeAction.Go,
99+ ),
100+ keyboardActions = KeyboardActions(onGo = { dispatch(Event.Connect) }),
101+ enabled = !state.loading,
102+ )
103+
104+ // Point first-time users at the expected address format. Hidden once they
105+ // start typing so it doesn't compete with the field's own contents.
106+ if (state.serverUrl.isEmpty()) {
107+ Spacer(Modifier.height(6.dp))
108+ Text(
109+ text = "Enter the URL of your opencode server (e.g. http://192.168.1.10:4096)",
110+ style = MaterialTheme.typography.bodySmall,
111+ color = MaterialTheme.colorScheme.onSurfaceVariant,
112+ textAlign = TextAlign.Center,
113+ )
114+ }
115+
116+ // Nudge the user if the URL looks like it's missing a scheme — a bare host:port
117+ // won't connect. Informational (tertiary), not an error, since it's a heuristic.
118+ if (state.serverUrl.isNotBlank() && "://" !in state.serverUrl && !state.serverUrl.startsWith("http")) {
119+ Spacer(Modifier.height(6.dp))
120+ Text(
121+ text = "Tip: Add http:// if connecting to a local server",
122+ style = MaterialTheme.typography.bodySmall,
123+ color = MaterialTheme.colorScheme.tertiary,
124+ textAlign = TextAlign.Center,
125+ )
126+ }
127+
128+ if (state.authRequired) {
129+ Spacer(Modifier.height(12.dp))
130+ OutlinedTextField(
131+ value = state.username,
132+ onValueChange = { dispatch(Event.UsernameChanged(it)) },
133+ label = { Text("Username") },
134+ singleLine = true,
135+ leadingIcon = { Icon(Icons.Filled.Person, contentDescription = null) },
136+ modifier = Modifier.fillMaxWidth(),
137+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
138+ enabled = !state.loading,
139+ )
140+ Spacer(Modifier.height(12.dp))
141+ OutlinedTextField(
142+ value = state.password,
143+ onValueChange = { dispatch(Event.PasswordChanged(it)) },
144+ label = { Text("Password") },
145+ singleLine = true,
146+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
147+ leadingIcon = { Icon(Icons.Filled.Lock, contentDescription = null) },
148+ trailingIcon = {
149+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
150+ Icon(
151+ imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
152+ contentDescription = if (passwordVisible) "Hide password" else "Show password",
153+ )
154+ }
155+ },
156+ modifier = Modifier.fillMaxWidth(),
157+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
158+ keyboardActions = KeyboardActions(onGo = {
159+ // Mirror the connect button's enabled condition so the keyboard's
160+ // Go action can't fire a request the form isn't ready for.
161+ if (!state.loading && state.serverUrl.isNotBlank() &&
162+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank())
163+ ) {
164+ dispatch(Event.Connect)
165+ }
166+ }),
167+ enabled = !state.loading,
168+ )
169+ Spacer(Modifier.height(4.dp))
170+ Text(
171+ text = "This server requires authentication.",
172+ style = MaterialTheme.typography.bodySmall,
173+ color = MaterialTheme.colorScheme.onSurfaceVariant,
174+ )
175+ }
176+
177+ Spacer(Modifier.height(24.dp))
178+
179+ Button(
180+ onClick = { dispatch(Event.Connect) },
181+ // When auth is required, keep the button disabled until at least one
182+ // credential is entered — otherwise it just triggers a validation error.
183+ enabled = !state.loading && state.serverUrl.isNotBlank() &&
184+ (!state.authRequired || state.username.isNotBlank() || state.password.isNotBlank()),
185+ modifier = Modifier.fillMaxWidth(),
186+ ) {
187+ if (state.loading) {
188+ CircularProgressIndicator(
189+ modifier = Modifier.height(20.dp),
190+ strokeWidth = 2.dp,
191+ color = MaterialTheme.colorScheme.onPrimary,
192+ )
193+ } else {
194+ Text(if (state.authRequired) "Sign in" else "Connect")
195+ }
196+ }
197+
198+ // While connecting (including auto-connect on launch), echo the target URL
199+ // so the user knows what the spinner is waiting on.
200+ if (state.loading) {
201+ Spacer(Modifier.height(8.dp))
202+ Text(
203+ text = "Connecting to ${state.serverUrl}...",
204+ style = MaterialTheme.typography.bodySmall,
205+ color = MaterialTheme.colorScheme.onSurfaceVariant,
206+ textAlign = TextAlign.Center,
207+ )
208+ }
209+
210+ // Surface the error inline as well, so it stays visible while the user
211+ // corrects the form (the snackbar is transient).
212+ if (state.error != null && !state.loading) {
213+ Spacer(Modifier.height(8.dp))
214+ Text(
215+ text = state.error,
216+ style = MaterialTheme.typography.bodySmall,
217+ color = MaterialTheme.colorScheme.error,
218+ textAlign = TextAlign.Center,
219+ )
220+ }
221+ }
222+}
app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktnew file+402
@@ -0,0 +1,402 @@
1+package soy.iko.opencode.ui.screens
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
9+import androidx.compose.foundation.clickable
10+import androidx.compose.foundation.combinedClickable
11+import androidx.compose.foundation.layout.Arrangement
12+import androidx.compose.foundation.layout.Box
13+import androidx.compose.foundation.layout.Column
14+import androidx.compose.foundation.layout.PaddingValues
15+import androidx.compose.foundation.layout.Row
16+import androidx.compose.foundation.layout.Spacer
17+import androidx.compose.foundation.layout.fillMaxSize
18+import androidx.compose.foundation.layout.fillMaxWidth
19+import androidx.compose.foundation.layout.height
20+import androidx.compose.foundation.layout.padding
21+import androidx.compose.foundation.layout.size
22+import androidx.compose.foundation.lazy.LazyColumn
23+import androidx.compose.foundation.lazy.items
24+import androidx.compose.foundation.lazy.rememberLazyListState
25+import androidx.compose.foundation.shape.CircleShape
26+import androidx.compose.material.icons.Icons
27+import androidx.compose.material.icons.automirrored.filled.ArrowBack
28+import androidx.compose.material.icons.filled.Add
29+import androidx.compose.material.icons.filled.ChatBubbleOutline
30+import androidx.compose.material.icons.filled.ChevronRight
31+import androidx.compose.material.icons.filled.DeleteOutline
32+import androidx.compose.material.icons.filled.Refresh
33+import androidx.compose.material3.AlertDialog
34+import androidx.compose.material3.CircularProgressIndicator
35+import androidx.compose.material3.ExperimentalMaterial3Api
36+import androidx.compose.material3.ExtendedFloatingActionButton
37+import androidx.compose.material3.HorizontalDivider
38+import androidx.compose.material3.Icon
39+import androidx.compose.material3.IconButton
40+import androidx.compose.material3.LinearProgressIndicator
41+import androidx.compose.material3.MaterialTheme
42+import androidx.compose.material3.OutlinedTextField
43+import androidx.compose.material3.Scaffold
44+import androidx.compose.material3.Surface
45+import androidx.compose.material3.SwipeToDismissBox
46+import androidx.compose.material3.SwipeToDismissBoxValue
47+import androidx.compose.material3.Text
48+import androidx.compose.material3.TextButton
49+import androidx.compose.material3.TopAppBar
50+import androidx.compose.material3.rememberSwipeToDismissBoxState
51+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
52+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
53+import androidx.compose.runtime.Composable
54+import androidx.compose.runtime.LaunchedEffect
55+import androidx.compose.runtime.getValue
56+import androidx.compose.runtime.mutableStateOf
57+import androidx.compose.runtime.remember
58+import androidx.compose.runtime.setValue
59+import androidx.compose.ui.Alignment
60+import androidx.compose.ui.Modifier
61+import androidx.compose.ui.draw.clip
62+import androidx.compose.ui.graphics.Color
63+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
64+import androidx.compose.ui.platform.LocalContext
65+import androidx.compose.ui.platform.LocalHapticFeedback
66+import androidx.compose.ui.text.style.TextAlign
67+import androidx.compose.ui.text.style.TextOverflow
68+import androidx.compose.ui.unit.dp
69+import soy.iko.opencode.core.Event
70+import soy.iko.opencode.core.SessionView
71+import soy.iko.opencode.core.UiState
72+
73+@OptIn(ExperimentalMaterial3Api::class)
74+@Composable
75+fun SessionsScreen(state: UiState, dispatch: (Event) -> Unit) {
76+ val haptic = LocalHapticFeedback.current
77+ var pendingDeleteId by remember { mutableStateOf<String?>(null) }
78+ var isPullRefreshing by remember { mutableStateOf(false) }
79+ var searchQuery by remember { mutableStateOf("") }
80+ val sessionsListState = rememberLazyListState()
81+
82+ // Bring a newly created session (which lands at the top of the list) into view.
83+ // Only scroll when the list actually grows, not on every reload of the same sessions.
84+ var prevSize by remember { mutableStateOf(state.sessions.size) }
85+ LaunchedEffect(state.sessions.size) {
86+ if (state.sessions.size > prevSize) sessionsListState.animateScrollToItem(0)
87+ prevSize = state.sessions.size
88+ }
89+
90+ // The pull-to-refresh spinner should only reflect a user-initiated pull, not
91+ // every loading operation. Clear it once the underlying load completes.
92+ LaunchedEffect(state.loading) {
93+ if (!state.loading) isPullRefreshing = false
94+ }
95+
96+ pendingDeleteId?.let { deleteId ->
97+ val pendingSession = state.sessions.firstOrNull { it.id == deleteId }
98+ AlertDialog(
99+ onDismissRequest = { pendingDeleteId = null },
100+ title = { Text("Delete session?") },
101+ text = { Text(pendingSession?.title ?: deleteId) },
102+ confirmButton = {
103+ TextButton(onClick = {
104+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
105+ dispatch(Event.DeleteSession(deleteId))
106+ pendingDeleteId = null
107+ }) {
108+ Text("Delete")
109+ }
110+ },
111+ dismissButton = {
112+ TextButton(onClick = { pendingDeleteId = null }) {
113+ Text("Cancel")
114+ }
115+ },
116+ )
117+ }
118+
119+ Scaffold(
120+ topBar = {
121+ TopAppBar(
122+ title = {
123+ Row(verticalAlignment = Alignment.CenterVertically) {
124+ // A quick at-a-glance health dot for the SSE stream: green when
125+ // connected, error-tinted when the stream is down (updates stall).
126+ Box(
127+ modifier = Modifier
128+ .size(8.dp)
129+ .clip(CircleShape)
130+ .background(
131+ if (state.sseConnected) Color(0xFF4CAF50)
132+ else MaterialTheme.colorScheme.error,
133+ ),
134+ )
135+ Spacer(Modifier.size(8.dp))
136+ Text(if (state.sessions.isNotEmpty()) "Sessions (${state.sessions.size})" else "Sessions")
137+ }
138+ },
139+ navigationIcon = {
140+ IconButton(onClick = { dispatch(Event.NavigateToConnect) }) {
141+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Disconnect")
142+ }
143+ },
144+ actions = {
145+ IconButton(onClick = { dispatch(Event.LoadSessions) }) {
146+ Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
147+ }
148+ },
149+ )
150+ },
151+ floatingActionButton = {
152+ // Give the search results the full width while searching — the FAB would
153+ // otherwise cover the bottom rows and isn't relevant mid-search anyway.
154+ if (searchQuery.isEmpty()) {
155+ ExtendedFloatingActionButton(
156+ onClick = { dispatch(Event.CreateSession) },
157+ icon = { Icon(Icons.Filled.Add, contentDescription = null) },
158+ text = { Text("New session") },
159+ )
160+ }
161+ },
162+ ) { padding ->
163+ Column(Modifier.fillMaxSize().padding(padding)) {
164+ if (!state.sseConnected) {
165+ ReconnectingBanner()
166+ }
167+ if (state.loading && !isPullRefreshing) {
168+ LinearProgressIndicator(Modifier.fillMaxWidth())
169+ }
170+ if (state.sessions.isNotEmpty()) {
171+ OutlinedTextField(
172+ value = searchQuery,
173+ onValueChange = { searchQuery = it },
174+ placeholder = { Text("Search sessions") },
175+ singleLine = true,
176+ modifier = Modifier
177+ .fillMaxWidth()
178+ .padding(horizontal = 16.dp, vertical = 8.dp),
179+ )
180+ }
181+ val filteredSessions = if (searchQuery.isEmpty()) {
182+ state.sessions
183+ } else {
184+ state.sessions.filter {
185+ it.title.contains(searchQuery, ignoreCase = true) ||
186+ it.id.contains(searchQuery, ignoreCase = true)
187+ }
188+ }
189+ when {
190+ state.sessions.isEmpty() && !state.loading -> EmptySessions()
191+ filteredSessions.isEmpty() && searchQuery.isNotEmpty() -> NoSearchResults(searchQuery)
192+ else -> PullToRefreshBox(
193+ isRefreshing = isPullRefreshing,
194+ onRefresh = {
195+ isPullRefreshing = true
196+ dispatch(Event.LoadSessions)
197+ },
198+ modifier = Modifier.fillMaxSize(),
199+ state = rememberPullToRefreshState(),
200+ ) {
201+ LazyColumn(
202+ state = sessionsListState,
203+ modifier = Modifier.fillMaxSize(),
204+ contentPadding = PaddingValues(bottom = 88.dp),
205+ ) {
206+ items(filteredSessions, key = { it.id }) { session ->
207+ // animateItem() slides the remaining rows into place when one is
208+ // deleted (and fades new rows in) instead of snapping instantly.
209+ Column(modifier = Modifier.animateItem()) {
210+ val dismissState = rememberSwipeToDismissBoxState(
211+ // Only a swipe from the end (right-to-left) triggers deletion;
212+ // return false so the row snaps back and the confirm dialog decides.
213+ confirmValueChange = { value ->
214+ if (value == SwipeToDismissBoxValue.EndToStart) {
215+ pendingDeleteId = session.id
216+ }
217+ false
218+ },
219+ )
220+ SwipeToDismissBox(
221+ state = dismissState,
222+ enableDismissFromStartToEnd = false,
223+ backgroundContent = { SwipeDeleteBackground() },
224+ ) {
225+ SessionRow(
226+ session = session,
227+ onOpen = { dispatch(Event.SelectSession(session.id)) },
228+ onDelete = { pendingDeleteId = session.id },
229+ )
230+ }
231+ HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.25f))
232+ }
233+ }
234+ }
235+ }
236+ }
237+ }
238+ }
239+}
240+
241+@OptIn(ExperimentalFoundationApi::class)
242+@Composable
243+private fun SessionRow(session: SessionView, onOpen: () -> Unit, onDelete: () -> Unit) {
244+ val context = LocalContext.current
245+ Row(
246+ modifier = Modifier
247+ .fillMaxWidth()
248+ .background(MaterialTheme.colorScheme.surface)
249+ .combinedClickable(
250+ onClick = onOpen,
251+ onLongClick = {
252+ copyToClipboard(context, session.id)
253+ Toast.makeText(context, "Session ID copied", Toast.LENGTH_SHORT).show()
254+ },
255+ )
256+ .padding(start = 20.dp, end = 8.dp, top = 14.dp, bottom = 14.dp),
257+ verticalAlignment = Alignment.CenterVertically,
258+ ) {
259+ Icon(
260+ Icons.Filled.ChatBubbleOutline,
261+ contentDescription = null,
262+ tint = MaterialTheme.colorScheme.primary,
263+ modifier = Modifier.padding(end = 16.dp),
264+ )
265+ Column(Modifier.weight(1f)) {
266+ Text(
267+ text = session.title,
268+ style = MaterialTheme.typography.bodyLarge,
269+ maxLines = 1,
270+ overflow = TextOverflow.Ellipsis,
271+ )
272+ Text(
273+ text = shortSessionId(session.id),
274+ style = MaterialTheme.typography.bodySmall,
275+ color = MaterialTheme.colorScheme.onSurfaceVariant,
276+ maxLines = 1,
277+ overflow = TextOverflow.Ellipsis,
278+ )
279+ }
280+ Icon(
281+ Icons.Filled.ChevronRight,
282+ contentDescription = null,
283+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
284+ modifier = Modifier.padding(horizontal = 4.dp),
285+ )
286+ IconButton(onClick = onDelete) {
287+ Icon(
288+ Icons.Filled.DeleteOutline,
289+ contentDescription = "Delete session",
290+ tint = MaterialTheme.colorScheme.error,
291+ )
292+ }
293+ }
294+}
295+
296+/** Red background with a trailing delete icon and label, revealed while swiping a session row away. */
297+@Composable
298+private fun SwipeDeleteBackground() {
299+ Box(
300+ modifier = Modifier
301+ .fillMaxSize()
302+ .background(MaterialTheme.colorScheme.error)
303+ .padding(horizontal = 24.dp),
304+ contentAlignment = Alignment.CenterEnd,
305+ ) {
306+ Row(verticalAlignment = Alignment.CenterVertically) {
307+ Icon(
308+ Icons.Filled.DeleteOutline,
309+ contentDescription = null,
310+ tint = MaterialTheme.colorScheme.onError,
311+ )
312+ Text(
313+ text = "Delete",
314+ style = MaterialTheme.typography.labelLarge,
315+ color = MaterialTheme.colorScheme.onError,
316+ modifier = Modifier.padding(start = 4.dp),
317+ )
318+ }
319+ }
320+}
321+
322+/** Shown at the top of the list while the SSE stream is down, since session updates stall too. */
323+@Composable
324+private fun ReconnectingBanner() {
325+ Surface(
326+ modifier = Modifier.fillMaxWidth(),
327+ color = MaterialTheme.colorScheme.tertiaryContainer,
328+ ) {
329+ Row(
330+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
331+ verticalAlignment = Alignment.CenterVertically,
332+ horizontalArrangement = Arrangement.spacedBy(8.dp),
333+ ) {
334+ CircularProgressIndicator(
335+ modifier = Modifier.size(14.dp),
336+ strokeWidth = 2.dp,
337+ color = MaterialTheme.colorScheme.onTertiaryContainer,
338+ )
339+ Text(
340+ text = "Reconnecting…",
341+ style = MaterialTheme.typography.bodySmall,
342+ color = MaterialTheme.colorScheme.onTertiaryContainer,
343+ )
344+ }
345+ }
346+}
347+
348+private fun copyToClipboard(context: Context, text: String) {
349+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
350+ clipboard.setPrimaryClip(ClipData.newPlainText("session", text))
351+}
352+
353+/** Shorten a session ID for display: "abc12345…" instead of the full UUID. */
354+private fun shortSessionId(id: String): String =
355+ if (id.length > 12) id.take(8) + "…" else id
356+
357+@Composable
358+private fun NoSearchResults(query: String) {
359+ Box(
360+ modifier = Modifier.fillMaxSize().padding(32.dp),
361+ contentAlignment = Alignment.Center,
362+ ) {
363+ Text(
364+ text = "No results for “$query”",
365+ style = MaterialTheme.typography.bodyMedium,
366+ color = MaterialTheme.colorScheme.onSurfaceVariant,
367+ )
368+ }
369+}
370+
371+@Composable
372+private fun EmptySessions() {
373+ Column(
374+ modifier = Modifier.fillMaxSize().padding(32.dp),
375+ verticalArrangement = Arrangement.Center,
376+ horizontalAlignment = Alignment.CenterHorizontally,
377+ ) {
378+ Icon(
379+ Icons.Filled.ChatBubbleOutline,
380+ contentDescription = null,
381+ modifier = Modifier.size(64.dp),
382+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
383+ )
384+ Spacer(Modifier.height(16.dp))
385+ Text(
386+ text = "No sessions yet",
387+ style = MaterialTheme.typography.titleMedium,
388+ )
389+ Text(
390+ text = "Tap “New session” to start chatting.",
391+ style = MaterialTheme.typography.bodyMedium,
392+ color = MaterialTheme.colorScheme.onSurfaceVariant,
393+ )
394+ Spacer(Modifier.height(8.dp))
395+ Text(
396+ text = "Sessions you create on this server will appear here.",
397+ style = MaterialTheme.typography.bodySmall,
398+ color = MaterialTheme.colorScheme.onSurfaceVariant,
399+ textAlign = TextAlign.Center,
400+ )
401+ }
402+}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktdeleted−343
@@ -1,343 +0,0 @@
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− if (state.isDirty && !state.saving) {
75− showDiscardConfirm = true
76− } else {
77− onDone()
78− }
79− }
80−
81− // Intercept the system back gesture when the form has unsaved changes so the user
82− // gets a chance to keep editing instead of losing their work silently.
83− BackHandler(enabled = state.isDirty && !state.saving && !showDiscardConfirm) {
84− showDiscardConfirm = true
85− }
86−
87− Scaffold(
88− topBar = {
89− TopAppBar(
90− title = { Text(if (state.isNew) stringResource(R.string.add_server) else stringResource(R.string.edit_server)) },
91− navigationIcon = {
92− IconButton(onClick = ::safeExit) {
93− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
94− }
95− },
96− )
97− },
98− ) { padding ->
99− if (!state.loaded) {
100− val loadingLabel = stringResource(R.string.loading)
101− Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
102− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
103− }
104− } else {
105− ServerEditForm(
106− state = state,
107− passwordVisible = passwordVisible,
108− onTogglePassword = { passwordVisible = !passwordVisible },
109− padding = padding,
110− vm = vm,
111− onDone = onDone,
112− )
113− }
114− }
115−
116− if (showDiscardConfirm) {
117− AlertDialog(
118− onDismissRequest = { showDiscardConfirm = false },
119− title = { Text(stringResource(R.string.discard_title)) },
120− text = { Text(stringResource(R.string.discard_text)) },
121− confirmButton = {
122− TextButton(onClick = {
123− showDiscardConfirm = false
124− onDone()
125− }) { Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) }
126− },
127− dismissButton = {
128− TextButton(onClick = { showDiscardConfirm = false }) { Text(stringResource(R.string.keep_editing)) }
129− },
130− )
131− }
132−}
133−
134−@Composable
135−private fun ServerEditForm(
136− state: ServerEditState,
137− passwordVisible: Boolean,
138− onTogglePassword: () -> Unit,
139− padding: androidx.compose.foundation.layout.PaddingValues,
140− vm: ServerEditViewModel,
141− onDone: () -> Unit,
142−) {
143− val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current
144− Column(
145− modifier = Modifier
146− .padding(padding)
147− .imePadding()
148− .widthIn(max = 600.dp)
149− .padding(16.dp)
150− .verticalScroll(rememberScrollState()),
151− verticalArrangement = Arrangement.spacedBy(12.dp),
152− ) {
153− OutlinedTextField(
154− value = state.label,
155− onValueChange = { v -> vm.update { it.copy(label = v) } },
156− label = { Text(stringResource(R.string.label_optional)) },
157− singleLine = true,
158− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
159− modifier = Modifier.fillMaxWidth().testTag("server_label"),
160− )
161− OutlinedTextField(
162− value = state.baseUrl,
163− onValueChange = { v -> vm.update { it.copy(baseUrl = v, error = null, probeReachable = null) } },
164− label = { Text(stringResource(R.string.base_url)) },
165− placeholder = { Text(stringResource(R.string.base_url_hint)) },
166− singleLine = true,
167− isError = state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl),
168− supportingText = {
169− if (state.baseUrl.isNotBlank() && !isValidUrl(state.baseUrl)) {
170− // Offer a one-tap "http://" fix for bare host:port input instead of a
171− // generic error, so the user doesn't have to know the URL needs a scheme.
172− val suggestion = suggestUrlScheme(state.baseUrl)
173− if (suggestion != null) {
174− TextButton(
175− onClick = { vm.update { it.copy(baseUrl = suggestion, probeReachable = null) } },
176− contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
177− ) {
178− Text(stringResource(R.string.suggest_scheme))
179− }
180− } else {
181− Text(stringResource(R.string.invalid_url))
182− }
183− }
184− },
185− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
186− modifier = Modifier.fillMaxWidth().testTag("server_url"),
187− )
188− OutlinedButton(
189− onClick = { vm.probe() },
190− enabled = state.canSave && !state.probing && !state.saving,
191− modifier = Modifier.fillMaxWidth().testTag("server_probe"),
192− ) {
193− if (state.probing) {
194− CircularProgressIndicator(
195− modifier = Modifier.size(20.dp),
196− strokeWidth = 2.dp,
197− )
198− Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
199− } else {
200− Text(stringResource(R.string.check_connectivity))
201− }
202− }
203− // Positive feedback when the probe found the server reachable without auth, so
204− // the user knows the connection works before saving (closes the loop that the
205− // prior "silently does nothing" Reachable path left open).
206− if (state.probeReachable == true && !state.authFieldsVisible) {
207− Text(
208− stringResource(R.string.server_reachable),
209− style = MaterialTheme.typography.bodySmall,
210− color = MaterialTheme.colorScheme.primary,
211− modifier = Modifier.fillMaxWidth(),
212− )
213− }
214− if (!state.authFieldsVisible) {
215− Text(
216− stringResource(R.string.auth_help),
217− style = MaterialTheme.typography.bodySmall,
218− color = MaterialTheme.colorScheme.onSurfaceVariant,
219− modifier = Modifier.fillMaxWidth(),
220− )
221− }
222− AnimatedVisibility(visible = state.authFieldsVisible) {
223− AuthFields(
224− state = state,
225− passwordVisible = passwordVisible,
226− onTogglePassword = onTogglePassword,
227− onUpdate = vm::update,
228− onTestCredentials = vm::testCredentials,
229− onImeDone = { if (state.canSave && !state.saving) vm.saveAndConnect(onDone) },
230− )
231− }
232− Row(
233− modifier = Modifier.fillMaxWidth(),
234− horizontalArrangement = Arrangement.spacedBy(12.dp),
235− ) {
236− OutlinedButton(
237− onClick = {
238− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
239− vm.save(onDone)
240− },
241− enabled = state.canSave && !state.saving,
242− modifier = Modifier.weight(1f),
243− ) {
244− if (state.saving) {
245− CircularProgressIndicator(
246− modifier = Modifier.size(20.dp),
247− color = MaterialTheme.colorScheme.primary,
248− strokeWidth = 2.dp,
249− )
250− Text(stringResource(R.string.saving), modifier = Modifier.padding(start = 8.dp))
251− } else {
252− Text(stringResource(R.string.save))
253− }
254− }
255− Button(
256− onClick = {
257− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
258− vm.saveAndConnect(onDone)
259− },
260− enabled = state.canSave && !state.saving,
261− modifier = Modifier.weight(1f),
262− ) {
263− Text(stringResource(R.string.save_and_connect))
264− }
265− }
266− state.error?.let { message ->
267− Text(
268− message,
269− color = MaterialTheme.colorScheme.error,
270− style = MaterialTheme.typography.bodySmall,
271− modifier = Modifier.fillMaxWidth(),
272− )
273− }
274− }
275−}
276−
277−@Composable
278−private fun AuthFields(
279− state: ServerEditState,
280− passwordVisible: Boolean,
281− onTogglePassword: () -> Unit,
282− onUpdate: ((ServerEditState) -> ServerEditState) -> Unit,
283− onTestCredentials: () -> Unit,
284− onImeDone: () -> Unit,
285−) {
286− val showPasswordLabel = stringResource(R.string.show_password)
287− val hidePasswordLabel = stringResource(R.string.hide_password)
288− Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
289− Text(
290− stringResource(R.string.auth_required),
291− style = MaterialTheme.typography.bodySmall,
292− color = MaterialTheme.colorScheme.error,
293− )
294− OutlinedTextField(
295− value = state.username,
296− onValueChange = { v -> onUpdate { it.copy(username = v) } },
297− label = { Text(stringResource(R.string.username_optional)) },
298− singleLine = true,
299− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
300− modifier = Modifier.fillMaxWidth().testTag("server_username"),
301− )
302− OutlinedTextField(
303− value = state.password,
304− onValueChange = { v -> onUpdate { it.copy(password = v) } },
305− label = { Text(stringResource(R.string.password_optional)) },
306− singleLine = true,
307− visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
308− trailingIcon = {
309− IconButton(onClick = onTogglePassword) {
310− Icon(
311− if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
312− contentDescription = if (passwordVisible) hidePasswordLabel else showPasswordLabel,
313− )
314− }
315− },
316− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
317− keyboardActions = KeyboardActions(onDone = { onImeDone() }),
318− modifier = Modifier.fillMaxWidth().testTag("server_password"),
319− )
320− OutlinedButton(
321− onClick = onTestCredentials,
322− enabled = state.canSave && !state.testingCredentials && !state.saving,
323− modifier = Modifier.fillMaxWidth().testTag("server_test_creds"),
324− ) {
325− if (state.testingCredentials) {
326− CircularProgressIndicator(
327− modifier = Modifier.size(20.dp),
328− strokeWidth = 2.dp,
329− )
330− Text(stringResource(R.string.testing_credentials), modifier = Modifier.padding(start = 8.dp))
331− } else {
332− Text(stringResource(R.string.test_credentials))
333− }
334− }
335− state.credentialsResult?.let { ok ->
336− Text(
337− stringResource(if (ok) R.string.credentials_ok else R.string.credentials_rejected),
338− style = MaterialTheme.typography.bodySmall,
339− color = if (ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
340− )
341− }
342− }
343−}
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktdeleted−275
@@ -1,275 +0,0 @@
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− initial = InitialProfile("", "", "", ""),
148− )
149− return@launch
150− }
151− // Source not found — fall through to a blank new-profile form.
152− }
153− // New-profile form: seed an initial snapshot of empty values so isDirty
154− // becomes true the moment the user types anything.
155− _state.update {
156− it.copy(
157− loaded = true,
158− initial = InitialProfile("", "", "", ""),
159− )
160− }
161− }
162− }
163−
164− fun update(transform: (ServerEditState) -> ServerEditState) {
165− _state.update { transform(it).copy(credentialsResult = null) }
166− }
167−
168− fun probe() {
169− val s = _state.value
170− if (!s.canSave || s.probing) return
171− _state.update { it.copy(probing = true, error = null, credentialsResult = null, probeReachable = null) }
172− viewModelScope.launch {
173− val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
174− result.onSuccess { pr ->
175− _state.update {
176− when (pr) {
177− is ProbeResult.Reachable -> it.copy(
178− probing = false,
179− authFieldsVisible = false,
180− error = null,
181− probeReachable = true,
182− )
183− is ProbeResult.NeedsAuth -> it.copy(
184− probing = false,
185− authFieldsVisible = true,
186− error = null,
187− probeReachable = false,
188− )
189− is ProbeResult.Unreachable -> it.copy(
190− probing = false,
191− authFieldsVisible = false,
192− error = pr.error,
193− probeReachable = false,
194− )
195− }
196− }
197− }.onFailure { e ->
198− _state.update { it.copy(probing = false, error = container.friendlyError(e), probeReachable = false) }
199− }
200− }
201− }
202−
203− /**
204− * Probe the server *with* the entered credentials to validate them before saving.
205− * Only meaningful once auth fields are visible (the server requires auth); calling
206− * it when no auth is needed is a no-op. Surfaces [ServerEditState.credentialsResult]
207− * so the UI can show success/failure without dismissing the form.
208− */
209− fun testCredentials() {
210− val s = _state.value
211− if (!s.canSave || s.testingCredentials) return
212− if (s.username.isBlank() && s.password.isBlank()) return
213− _state.update { it.copy(testingCredentials = true, credentialsResult = null, error = null) }
214− viewModelScope.launch {
215− val result = runCatchingCancellable { container.probeWithCredentials(s.baseUrl, s.username, s.password) }
216− result.onSuccess { ok ->
217− _state.update {
218− it.copy(
219− testingCredentials = false,
220− credentialsResult = ok,
221− error = if (ok) null else container.string(R.string.credentials_rejected),
222− )
223− }
224− }.onFailure { e ->
225− _state.update {
226− it.copy(
227− testingCredentials = false,
228− credentialsResult = false,
229− error = container.friendlyError(e),
230− )
231− }
232− }
233− }
234− }
235−
236− fun save(onDone: () -> Unit) {
237− saveInternal(connectAfter = false, onDone = onDone)
238− }
239−
240− /** Save and then connect to the saved profile in one step. */
241− fun saveAndConnect(onDone: () -> Unit) {
242− saveInternal(connectAfter = true, onDone = onDone)
243− }
244−
245− private fun saveInternal(connectAfter: Boolean, onDone: () -> Unit) {
246− val s = _state.value
247− if (!s.canSave || s.saving) return
248− _state.update { it.copy(saving = true) }
249− viewModelScope.launch {
250− val result = runCatchingCancellable {
251− val existingLastUsed = if (s.id != null) {
252− withTimeoutOrNull(NetworkConfig.profileLoadTimeoutMs) {
253− container.profileStore.profiles.first()
254− .firstOrNull { it.id == s.id }?.lastUsed
255− } ?: 0L
256− } else 0L
257− val saved = ServerProfile(
258− id = s.id ?: UUID.randomUUID().toString(),
259− label = s.label.trim(),
260− baseUrl = s.baseUrl.trim(),
261− username = s.username.trim().takeIf { it.isNotBlank() },
262− password = s.password.trim().takeIf { it.isNotEmpty() },
263− lastUsed = existingLastUsed,
264− )
265− container.profileStore.save(saved)
266− if (connectAfter) {
267− container.connect(saved)
268− container.activeConnection.value?.api?.ping()
269− }
270− }
271− result.onSuccess { onDone() }
272− .onFailure { e -> _state.update { it.copy(error = container.friendlyError(e), saving = false) } }
273− }
274− }
275−}
app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktdeleted−413
@@ -1,413 +0,0 @@
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.ContentCopy
20−import androidx.compose.material.icons.filled.Delete
21−import androidx.compose.material.icons.filled.Dns
22−import androidx.compose.material.icons.filled.Edit
23−import androidx.compose.material3.AlertDialog
24−import androidx.compose.material3.Card
25−import androidx.compose.material3.CircularProgressIndicator
26−import androidx.compose.material3.ExperimentalMaterial3Api
27−import androidx.compose.material3.FloatingActionButton
28−import androidx.compose.material3.Icon
29−import androidx.compose.material3.IconButton
30−import androidx.compose.material3.LinearProgressIndicator
31−import androidx.compose.material3.MaterialTheme
32−import androidx.compose.material3.Scaffold
33−import androidx.compose.material3.SnackbarDuration
34−import androidx.compose.material3.SnackbarHost
35−import androidx.compose.material3.SnackbarHostState
36−import androidx.compose.material3.SnackbarResult
37−import androidx.compose.material3.SwipeToDismissBox
38−import androidx.compose.material3.SwipeToDismissBoxValue
39−import androidx.compose.material3.Text
40−import androidx.compose.material3.TextButton
41−import androidx.compose.material3.TopAppBar
42−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
43−import androidx.compose.material3.rememberSwipeToDismissBoxState
44−import androidx.compose.runtime.Composable
45−import androidx.compose.runtime.LaunchedEffect
46−import androidx.compose.runtime.getValue
47−import androidx.compose.runtime.mutableStateOf
48−import androidx.compose.runtime.remember
49−import androidx.compose.runtime.saveable.rememberSaveable
50−import androidx.compose.runtime.setValue
51−import androidx.compose.ui.Alignment
52−import androidx.compose.ui.Modifier
53−import androidx.compose.ui.draw.clip
54−import androidx.compose.ui.platform.LocalHapticFeedback
55−import androidx.compose.ui.platform.testTag
56−import androidx.compose.ui.semantics.Role
57−import androidx.compose.ui.semantics.contentDescription
58−import androidx.compose.ui.semantics.semantics
59−import androidx.compose.ui.text.style.TextOverflow
60−import androidx.compose.ui.unit.dp
61−import androidx.compose.ui.res.stringResource
62−import androidx.lifecycle.compose.collectAsStateWithLifecycle
63−import androidx.lifecycle.viewmodel.compose.viewModel
64−import kotlinx.coroutines.coroutineScope
65−import kotlinx.coroutines.flow.collectLatest
66−import kotlinx.coroutines.delay
67−import kotlinx.coroutines.launch
68−import soy.iko.opencode.data.model.ServerProfile
69−import soy.iko.opencode.data.network.NetworkConfig
70−import soy.iko.opencode.di.AppContainer
71−import soy.iko.opencode.R
72−import soy.iko.opencode.ui.components.rememberRelativeTime
73−import soy.iko.opencode.ui.vmFactory
74−
75−@OptIn(ExperimentalMaterial3Api::class)
76−@Composable
77−fun ServerListScreen(
78− container: AppContainer,
79− onConnected: () -> Unit,
80− onAddProfile: () -> Unit,
81− onEditProfile: (String) -> Unit,
82− onDuplicateProfile: (String) -> Unit,
83−) {
84− val vm: ServerListViewModel = viewModel(factory = vmFactory { ServerListViewModel(container) })
85− val profiles by vm.profiles.collectAsStateWithLifecycle()
86− val connectingId by vm.connectingId.collectAsStateWithLifecycle()
87− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
88− val reconnecting by container.reconnecting.collectAsStateWithLifecycle()
89− val haptics = LocalHapticFeedback.current
90− val snackbar = remember { SnackbarHostState() }
91− val retryLabel = stringResource(R.string.retry)
92− val undoLabel = stringResource(R.string.undo)
93− val serverRemovedLabel = stringResource(R.string.server_removed)
94− var pendingDeleteId by rememberSaveable { mutableStateOf<String?>(null) }
95− val connectedId = activeConnection?.profile?.id
96−
97− LaunchedEffect(Unit) {
98− vm.errorEvents.collect { event ->
99− val result = if (event.profile != null) {
100− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
101− } else {
102− snackbar.showSnackbar(event.message)
103− }
104− if (result == SnackbarResult.ActionPerformed && event.profile != null) {
105− vm.connect(event.profile, onConnected)
106− }
107− }
108− }
109−
110− // Undo snackbar: when a server is marked for deferred deletion, offer Undo. If the
111− // action is taken before the delay expires, the profile is kept and the delete
112− // never fires — mirrors the session list's undo UX for consistency. Indefinite + a
113− // matching timed dismiss keeps the Undo button visible for exactly the
114− // undoServerDeleteDelayMs window (5s) and no longer: a fixed SnackbarDuration.Long
115− // (~10s) outlasted the window, leaving the button on screen but dead for its
116− // second half. Mirrors DiagnosticsScreen's undo pattern.
117− LaunchedEffect(Unit) {
118− // collectLatest (not collect): a serialized collect queues each snackbar behind
119− // the previous one's full window, so under rapid deletes a later profile's VM
120− // delete timer (started at emit time) fires before its snackbar is ever shown —
121− // a dead Undo button. collectLatest instead cancels the current snackbar and
122− // shows the newest immediately, keeping its Undo window aligned with its timer.
123− vm.undoEvents.collectLatest { profileId ->
124− coroutineScope {
125− val dismisser = launch {
126− delay(NetworkConfig.undoServerDeleteDelayMs)
127− snackbar.currentSnackbarData?.dismiss()
128− }
129− val result = snackbar.showSnackbar(
130− message = serverRemovedLabel,
131− actionLabel = undoLabel,
132− duration = SnackbarDuration.Indefinite,
133− )
134− dismisser.cancel()
135− if (result == SnackbarResult.ActionPerformed) {
136− vm.undoDelete(profileId)
137− }
138− }
139− }
140− }
141−
142− // On cold start the container auto-reconnects to the last server; once it lands,
143− // skip this screen and go straight to the session list. Consumed once so a later
144− // manual visit to this screen doesn't bounce the user back out.
145− LaunchedEffect(Unit) {
146− container.autoConnectDone.collect { succeeded ->
147− if (succeeded && container.consumeAutoConnect()) onConnected()
148− }
149− }
150−
151− Scaffold(
152− topBar = { TopAppBar(title = { Text(stringResource(R.string.servers_title)) }) },
153− snackbarHost = { SnackbarHost(snackbar) },
154− floatingActionButton = {
155− FloatingActionButton(onClick = onAddProfile) {
156− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_server))
157− }
158− },
159− ) { padding ->
160− Column(modifier = Modifier.fillMaxSize()) {
161− if (reconnecting && profiles.isNotEmpty()) {
162− LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
163− }
164− if (profiles.isEmpty()) {
165− EmptyServers(
166− onAdd = onAddProfile,
167− modifier = Modifier.fillMaxSize().padding(padding),
168− )
169− } else {
170− PullToRefreshBox(
171− isRefreshing = connectingId != null,
172− onRefresh = { vm.refresh(onConnected) },
173− modifier = Modifier.fillMaxSize(),
174− ) {
175− LazyColumn(
176− modifier = Modifier.fillMaxSize().padding(padding),
177− contentPadding = androidx.compose.foundation.layout.PaddingValues(
178− start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
179− ),
180− verticalArrangement = Arrangement.spacedBy(12.dp),
181− ) {
182− items(profiles, key = { it.id }) { profile ->
183− val isActive = profile.id == connectedId
184− if (isActive) {
185− // The active server can't be swipe-deleted (deleting it also
186− // disconnects, which deserves an explicit tap, not an
187− // accidental swipe). Render a plain card without the swipe
188− // affordance for the active row.
189− Card(
190− modifier = Modifier
191− .fillMaxWidth()
192− .animateItem()
193− .testTag("server_card")
194− .clickable(enabled = connectingId == null, role = Role.Button) {
195− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
196− vm.connect(profile, onConnected)
197− },
198− ) {
199− Row(
200− modifier = Modifier.fillMaxWidth().padding(16.dp),
201− verticalAlignment = Alignment.CenterVertically,
202− ) {
203− Column(modifier = Modifier.weight(1f)) {
204− Text(
205− profile.displayLabel,
206− style = MaterialTheme.typography.titleMedium,
207− maxLines = 1,
208− overflow = TextOverflow.Ellipsis,
209− )
210− Text(
211− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
212− style = MaterialTheme.typography.bodySmall,
213− color = MaterialTheme.colorScheme.onSurfaceVariant,
214− maxLines = 1,
215− overflow = TextOverflow.Ellipsis,
216− )
217− Text(
218− stringResource(R.string.connected),
219− style = MaterialTheme.typography.labelSmall,
220− color = MaterialTheme.colorScheme.primary,
221− )
222− LastUsedText(profile.lastUsed)
223− }
224− Icon(
225− Icons.Filled.CheckCircle,
226− contentDescription = stringResource(R.string.connected),
227− tint = MaterialTheme.colorScheme.primary,
228− modifier = Modifier.size(22.dp),
229− )
230− // Keep edit available on the active server so credentials can be
231− // fixed without disconnecting first. Delete is also offered: the
232− // confirmation dialog warns that removing the active server also
233− // disconnects (remove_server_active_text), and the ViewModel
234− // disconnects immediately on confirm. Swipe remains disabled for
235− // the active row so an accidental swipe can't disconnect.
236− IconButton(onClick = { onEditProfile(profile.id) }) {
237− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
238− }
239− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
240− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
241− }
242− IconButton(onClick = { pendingDeleteId = profile.id }) {
243− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
244− }
245− }
246− }
247− } else {
248− // Swipe end-to-start reveals a delete affordance and opens the
249− // same confirmation dialog as the trash icon (matching the
250− // session list). confirmValueChange always returns false (snap
251− // back) so the dialog guards the actual deletion.
252− val swipeState = rememberSwipeToDismissBoxState(
253− confirmValueChange = {
254− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
255− pendingDeleteId = profile.id
256− false
257− },
258− )
259− SwipeToDismissBox(
260− state = swipeState,
261− enableDismissFromStartToEnd = false,
262− modifier = Modifier.animateItem(),
263− backgroundContent = {
264− Box(
265− modifier = Modifier
266− .fillMaxSize()
267− .clip(MaterialTheme.shapes.medium)
268− .background(MaterialTheme.colorScheme.errorContainer)
269− .padding(horizontal = 20.dp),
270− contentAlignment = Alignment.CenterEnd,
271− ) {
272− Icon(
273− Icons.Filled.Delete,
274− contentDescription = stringResource(R.string.delete),
275− tint = MaterialTheme.colorScheme.onErrorContainer,
276− )
277− }
278− },
279− ) {
280− Card(
281− modifier = Modifier
282− .fillMaxWidth()
283− .testTag("server_card")
284− .clickable(enabled = connectingId == null, role = Role.Button) {
285− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
286− vm.connect(profile, onConnected)
287− },
288− ) {
289− Row(
290− modifier = Modifier.fillMaxWidth().padding(16.dp),
291− verticalAlignment = Alignment.CenterVertically,
292− ) {
293− Column(modifier = Modifier.weight(1f)) {
294− Text(
295− profile.displayLabel,
296− style = MaterialTheme.typography.titleMedium,
297− maxLines = 1,
298− overflow = TextOverflow.Ellipsis,
299− )
300− Text(
301− profile.baseUrl + if (profile.hasAuth) stringResource(R.string.server_auth_short) else "",
302− style = MaterialTheme.typography.bodySmall,
303− color = MaterialTheme.colorScheme.onSurfaceVariant,
304− maxLines = 1,
305− overflow = TextOverflow.Ellipsis,
306− )
307− LastUsedText(profile.lastUsed)
308− }
309− if (connectingId == profile.id) {
310− val connectingLabel = stringResource(R.string.connecting)
311− CircularProgressIndicator(
312− modifier = Modifier.size(24.dp).semantics { contentDescription = connectingLabel },
313− )
314− } else {
315− IconButton(onClick = { onEditProfile(profile.id) }) {
316− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.edit))
317− }
318− IconButton(onClick = { onDuplicateProfile(profile.id) }) {
319− Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.duplicate_server))
320− }
321− IconButton(onClick = { pendingDeleteId = profile.id }) {
322− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
323− }
324− }
325− }
326− }
327− }
328− }
329− }
330− }
331− }
332− }
333− }
334− }
335−
336− profiles.find { it.id == pendingDeleteId }?.let { profile ->
337− val isActiveProfile = profile.id == connectedId
338− AlertDialog(
339− onDismissRequest = { pendingDeleteId = null },
340− title = { Text(stringResource(R.string.remove_server_title)) },
341− text = {
342− Text(
343− if (isActiveProfile) stringResource(R.string.remove_server_active_text, profile.displayLabel)
344− else stringResource(R.string.remove_server_text, profile.displayLabel),
345− )
346− },
347− confirmButton = {
348− TextButton(onClick = {
349− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
350− pendingDeleteId = null
351− // delete() defers the actual removal so the undo snackbar can cancel
352− // it; the row disappears on the next profiles emission.
353− vm.delete(profile)
354− }) { Text(stringResource(R.string.remove), color = MaterialTheme.colorScheme.error) }
355− },
356− dismissButton = {
357− TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
358− },
359− )
360− }
361−}
362−
363−/** "Last used X ago" or "Not used yet" for a server profile, so the user can spot stale
364− * configs at a glance. Mirrors the relative-time formatting used elsewhere. */
365−@Composable
366−private fun LastUsedText(lastUsed: Long) {
367− if (lastUsed <= 0) {
368− Text(
369− stringResource(R.string.last_used_never),
370− style = MaterialTheme.typography.labelSmall,
371− color = MaterialTheme.colorScheme.onSurfaceVariant,
372− )
373− } else {
374− val relative = rememberRelativeTime(lastUsed)
375− if (relative.isNotEmpty()) {
376− Text(
377− stringResource(R.string.last_used, relative),
378− style = MaterialTheme.typography.labelSmall,
379− color = MaterialTheme.colorScheme.onSurfaceVariant,
380− )
381− }
382− }
383−}
384−
385−@Composable
386−private fun EmptyServers(onAdd: () -> Unit, modifier: Modifier = Modifier) {
387− Box(modifier = modifier, contentAlignment = Alignment.Center) {
388− Column(
389− modifier = Modifier.padding(32.dp),
390− horizontalAlignment = Alignment.CenterHorizontally,
391− ) {
392− Icon(
393− Icons.Filled.Dns,
394− contentDescription = null,
395− modifier = Modifier.size(56.dp),
396− tint = MaterialTheme.colorScheme.onSurfaceVariant,
397− )
398− Spacer(Modifier.size(16.dp))
399− Text(stringResource(R.string.no_servers_yet), style = MaterialTheme.typography.titleMedium)
400− Spacer(Modifier.size(8.dp))
401− Text(
402− stringResource(R.string.no_servers_hint),
403− style = MaterialTheme.typography.bodyMedium,
404− color = MaterialTheme.colorScheme.onSurfaceVariant,
405− )
406− Spacer(Modifier.size(20.dp))
407− TextButton(onClick = onAdd) {
408− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
409− Text(stringResource(R.string.add_server), modifier = Modifier.padding(start = 8.dp))
410− }
411− }
412− }
413−}
app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktdeleted−139
@@ -1,139 +0,0 @@
1−package soy.iko.opencode.ui.server
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
5−import soy.iko.opencode.data.model.ServerProfile
6−import soy.iko.opencode.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.delay
12−import kotlinx.coroutines.flow.MutableSharedFlow
13−import kotlinx.coroutines.flow.MutableStateFlow
14−import kotlinx.coroutines.flow.SharedFlow
15−import kotlinx.coroutines.flow.SharingStarted
16−import kotlinx.coroutines.flow.StateFlow
17−import kotlinx.coroutines.flow.asSharedFlow
18−import kotlinx.coroutines.flow.asStateFlow
19−import kotlinx.coroutines.flow.combine
20−import kotlinx.coroutines.flow.stateIn
21−import kotlinx.coroutines.flow.update
22−import kotlinx.coroutines.launch
23−
24−/** A transient error surfaced as a snackbar, optionally paired with the profile that
25− * failed to connect so the snackbar can offer a Retry action. */
26−data class ConnectError(val message: String, val profile: ServerProfile?)
27−
28−class ServerListViewModel(private val container: AppContainer) : ViewModel() {
29−
30− /** Ids optimistically hidden while their deferred delete's undo window is open, so a
31− * "deleted" row disappears immediately instead of lingering (tappable) for the whole
32− * undo window. Mirrors [SessionListViewModel]'s optimistic-removal pattern. */
33− private val _hiddenIds = MutableStateFlow<Set<String>>(emptySet())
34−
35− val profiles: StateFlow<List<ServerProfile>> =
36− combine(container.profileStore.profiles, _hiddenIds) { profiles, hidden ->
37− profiles.filterNot { it.id in hidden }
38− }.stateIn(
39− scope = viewModelScope,
40− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
41− initialValue = emptyList(),
42− )
43−
44− private val _connecting = MutableStateFlow<String?>(null)
45− val connectingId: StateFlow<String?> = _connecting.asStateFlow()
46−
47− /** One-shot error events surfaced as snackbars. A SharedFlow (not StateFlow) so each
48− * emission is delivered independently. */
49− private val _errorEvents = MutableSharedFlow<ConnectError>(
50− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
51− onBufferOverflow = BufferOverflow.DROP_OLDEST,
52− )
53− val errorEvents: SharedFlow<ConnectError> = _errorEvents.asSharedFlow()
54−
55− /** One-shot events carrying the id of a server profile marked for deferred deletion,
56− * so the UI can show an Undo snackbar. Mirrors the session list's undo pattern. */
57− private val _undoEvents = MutableSharedFlow<String>(
58− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
59− onBufferOverflow = BufferOverflow.DROP_OLDEST,
60− )
61− val undoEvents: SharedFlow<String> = _undoEvents.asSharedFlow()
62−
63− /** Profile awaiting the undo window to expire before its REST delete fires. */
64− // Keyed by profile id, not a single field: several deletes can overlap within the
65− // undo window, and a single field would let the second overwrite the first — leaving
66− // the first profile in the store forever while it's hidden from the list.
67− private val pendingDeletes = mutableMapOf<String, ServerProfile>()
68−
69− fun connect(profile: ServerProfile, onConnected: () -> Unit) {
70− if (_connecting.value != null) return
71− _connecting.value = profile.id
72− viewModelScope.launch {
73− try {
74− val result = runCatchingCancellable {
75− val conn = container.connect(profile)
76− conn.api.ping()
77− }
78− result.onSuccess { onConnected() }
79− .onFailure {
80− container.disconnect()
81− _errorEvents.tryEmit(ConnectError(container.friendlyError(it), profile))
82− }
83− } finally {
84− _connecting.value = null
85− }
86− }
87− }
88−
89− /** Retry connecting to the most recently used profile — the pull-to-refresh action on
90− * the server list. Gives the user a familiar gesture to retry a failed auto-connect
91− * instead of having to tap a server card. */
92− fun refresh(onConnected: () -> Unit) {
93− if (_connecting.value != null || container.reconnecting.value) return
94− val target = profiles.value.firstOrNull() ?: return
95− connect(target, onConnected)
96− }
97−
98− /**
99− * Mark [profile] for deferred deletion and emit an Undo event. The actual
100− * [ProfileStore.delete] is delayed by [NetworkConfig.undoServerDeleteDelayMs]; if
101− * [undoDelete] is called before it fires, the profile is restored. If the profile
102− * is the active one, disconnect immediately (the user has already confirmed via the
103− * dialog) so the UI reflects the disconnection right away — the profile row is
104− * hidden by the optimistic removal, and the undo re-shows it without reconnecting.
105− */
106− fun delete(profile: ServerProfile) {
107− pendingDeletes[profile.id] = profile
108− // Optimistically hide the row so the UI feels instant; undo re-shows it.
109− _hiddenIds.update { it + profile.id }
110− if (container.activeConnection.value?.profile?.id == profile.id) {
111− // disconnect() is suspend; run it on the VM scope so the optimistic removal
112− // and undo emission aren't blocked on the connection close completing.
113− viewModelScope.launch { container.disconnect() }
114− }
115− _undoEvents.tryEmit(profile.id)
116− viewModelScope.launch {
117− delay(NetworkConfig.undoServerDeleteDelayMs)
118− if (pendingDeletes.remove(profile.id) != null) {
119− runCatchingCancellable { container.profileStore.delete(profile.id) }
120− .onSuccess {
121− // Drop the optimistic hide only after the store has emitted the
122− // removal, so the row doesn't flash back before the list updates.
123− _hiddenIds.update { it - profile.id }
124− }
125− .onFailure {
126− // Delete failed: re-show the row so it isn't hidden forever.
127− _hiddenIds.update { it - profile.id }
128− _errorEvents.tryEmit(ConnectError(container.friendlyError(it), null))
129− }
130− }
131− }
132− }
133−
134− /** Cancel a pending delete (Undo snackbar action) and re-show the hidden row. */
135− fun undoDelete(profileId: String) {
136− pendingDeletes.remove(profileId)
137− _hiddenIds.update { it - profileId }
138− }
139−}
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktdeleted−715
@@ -1,715 +0,0 @@
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.ContentCopy
28−import androidx.compose.material.icons.filled.Delete
29−import androidx.compose.material.icons.filled.Dns
30−import androidx.compose.material.icons.filled.Edit
31−import androidx.compose.material.icons.filled.Folder
32−import androidx.compose.material.icons.filled.Refresh
33−import androidx.compose.material.icons.filled.Search
34−import androidx.compose.material.icons.filled.Settings
35−import androidx.compose.material3.AlertDialog
36−import androidx.compose.material3.Button
37−import androidx.compose.material3.Card
38−import androidx.compose.material3.CircularProgressIndicator
39−import androidx.compose.material3.DropdownMenu
40−import androidx.compose.material3.DropdownMenuItem
41−import androidx.compose.material3.ExperimentalMaterial3Api
42−import androidx.compose.material3.ExtendedFloatingActionButton
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.SwipeToDismissBox
51−import androidx.compose.material3.SwipeToDismissBoxValue
52−import androidx.compose.material3.Text
53−import androidx.compose.material3.TextButton
54−import androidx.compose.material3.TopAppBar
55−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
56−import androidx.compose.material3.rememberSwipeToDismissBoxState
57−import androidx.compose.runtime.Composable
58−import androidx.compose.runtime.CompositionLocalProvider
59−import androidx.compose.runtime.LaunchedEffect
60−import androidx.compose.runtime.getValue
61−import androidx.compose.runtime.mutableStateOf
62−import androidx.compose.runtime.remember
63−import androidx.compose.runtime.saveable.rememberSaveable
64−import androidx.compose.runtime.setValue
65−import androidx.compose.ui.Alignment
66−import androidx.compose.ui.Modifier
67−import androidx.compose.ui.draw.clip
68−import androidx.compose.ui.platform.LocalHapticFeedback
69−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
70−import androidx.compose.ui.platform.testTag
71−import androidx.compose.ui.semantics.Role
72−import androidx.compose.ui.semantics.contentDescription
73−import androidx.compose.ui.semantics.semantics
74−import androidx.compose.ui.text.input.ImeAction
75−import androidx.compose.ui.text.style.TextOverflow
76−import androidx.compose.ui.unit.dp
77−import androidx.compose.ui.res.stringResource
78−import androidx.lifecycle.compose.collectAsStateWithLifecycle
79−import androidx.lifecycle.viewmodel.compose.viewModel
80−import kotlinx.coroutines.coroutineScope
81−import kotlinx.coroutines.flow.collectLatest
82−import kotlinx.coroutines.delay
83−import kotlinx.coroutines.launch
84−import soy.iko.opencode.data.model.ServerProfile
85−import soy.iko.opencode.data.model.Session
86−import soy.iko.opencode.data.network.NetworkConfig
87−import soy.iko.opencode.di.AppContainer
88−import soy.iko.opencode.R
89−import soy.iko.opencode.ui.components.ConnectionBanner
90−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
91−import soy.iko.opencode.ui.components.rememberRelativeTime
92−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
93−import soy.iko.opencode.ui.vmFactory
94−
95−@OptIn(ExperimentalMaterial3Api::class)
96−@Composable
97−fun SessionListScreen(
98− container: AppContainer,
99− onOpenSession: (String) -> Unit,
100− onDisconnect: () -> Unit,
101− onOpenFiles: () -> Unit,
102− onOpenSettings: () -> Unit,
103− onAddServer: () -> Unit,
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− onRefresh = vm::refresh,
268− onQueryChange = vm::setQuery,
269− onOpenSession = onOpenSession,
270− onCreateSession = { vm.createSession(onCreated = onOpenSession) },
271− onRename = { pendingRenameId = it },
272− onDelete = { pendingDeleteId = it },
273− )
274− }
275− }
276− }
277−
278− pendingDelete?.let { session ->
279− AlertDialog(
280− onDismissRequest = { pendingDeleteId = null },
281− title = { Text(stringResource(R.string.delete_session_title)) },
282− text = { Text(stringResource(R.string.delete_session_text, session.displayTitle)) },
283− confirmButton = {
284− TextButton(onClick = {
285− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
286− pendingDeleteId = null
287− vm.deleteSession(session)
288− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
289− },
290− dismissButton = {
291− TextButton(onClick = { pendingDeleteId = null }) { Text(stringResource(R.string.cancel)) }
292− },
293− )
294− }
295−
296− pendingRename?.let { session ->
297− var title by rememberSaveable(session.id) { mutableStateOf(session.title ?: "") }
298− RenameSessionDialog(
299− title = title,
300− onTitleChange = { title = it },
301− onConfirm = {
302− val newName = title.trim()
303− pendingRenameId = null
304− if (newName.isNotEmpty() && newName != session.title) vm.renameSession(session, newName)
305− },
306− onDismiss = { pendingRenameId = null },
307− )
308− }
309−
310− if (showDisconnectConfirm) {
311− AlertDialog(
312− onDismissRequest = { showDisconnectConfirm = false },
313− title = { Text(stringResource(R.string.disconnect_active_title)) },
314− text = { Text(stringResource(R.string.disconnect_active_text)) },
315− confirmButton = {
316− TextButton(onClick = {
317− showDisconnectConfirm = false
318− onDisconnect()
319− }) { Text(stringResource(R.string.disconnect), color = MaterialTheme.colorScheme.error) }
320− },
321− dismissButton = {
322− TextButton(onClick = { showDisconnectConfirm = false }) { Text(stringResource(R.string.cancel)) }
323− },
324− )
325− }
326−}
327−
328−@OptIn(ExperimentalMaterial3Api::class)
329−@Composable
330−private fun androidx.compose.foundation.layout.BoxScope.SessionListBody(
331− state: SessionListState,
332− unread: Map<String, Int>,
333− refreshing: Boolean,
334− haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
335− onRefresh: () -> Unit,
336− onQueryChange: (String) -> Unit,
337− onOpenSession: (String) -> Unit,
338− onCreateSession: () -> Unit,
339− onRename: (String) -> Unit,
340− onDelete: (String) -> Unit,
341−) {
342− when {
343− state.loading -> {
344− val loadingLabel = stringResource(R.string.loading)
345− CircularProgressIndicator(
346− Modifier
347− .align(Alignment.Center)
348− .semantics { contentDescription = loadingLabel },
349− )
350− }
351− state.sessions.isEmpty() && state.error != null -> Column(
352− modifier = Modifier.align(Alignment.Center).padding(24.dp),
353− horizontalAlignment = Alignment.CenterHorizontally,
354− ) {
355− Text(
356− state.error ?: "",
357− color = MaterialTheme.colorScheme.error,
358− )
359− Spacer(Modifier.size(12.dp))
360− TextButton(onClick = onRefresh) {
361− Text(stringResource(R.string.retry))
362− }
363− }
364− state.sessions.isEmpty() -> EmptySessions(
365− onCreate = onCreateSession,
366− modifier = Modifier.align(Alignment.Center),
367− )
368− else -> Column(modifier = Modifier.fillMaxSize()) {
369− val keyboardController = LocalSoftwareKeyboardController.current
370− OutlinedTextField(
371− value = state.query,
372− onValueChange = onQueryChange,
373− modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).testTag("session_search"),
374− label = { Text(stringResource(R.string.search_sessions)) },
375− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
376− singleLine = true,
377− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
378− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
379− )
380− val sessions = remember(state.sessions, state.query, state.previews) {
381− // Fast-path an empty query: filtered just returns sessions, so skip
382− // the recompute (and the fresh list reference) when only previews
383− // churned in the background. With a non-empty query the previews
384− // map affects the match set, so recompute on any of the three.
385− if (state.query.isBlank()) state.sessions else state.filtered
386− }
387− if (sessions.isEmpty()) {
388− Box(
389− modifier = Modifier.fillMaxSize(),
390− contentAlignment = Alignment.Center,
391− ) {
392− Column(
393− horizontalAlignment = Alignment.CenterHorizontally,
394− modifier = Modifier.padding(32.dp),
395− ) {
396− Icon(
397− Icons.Filled.Search,
398− contentDescription = null,
399− modifier = Modifier.size(48.dp),
400− tint = MaterialTheme.colorScheme.onSurfaceVariant,
401− )
402− Spacer(Modifier.size(12.dp))
403− Text(
404− stringResource(R.string.no_sessions_match, state.query),
405− style = MaterialTheme.typography.bodyMedium,
406− color = MaterialTheme.colorScheme.onSurfaceVariant,
407− )
408− }
409− }
410− } else {
411− PullToRefreshBox(
412− isRefreshing = refreshing,
413− onRefresh = onRefresh,
414− modifier = Modifier.fillMaxSize(),
415− ) {
416− LazyColumn(
417− modifier = Modifier.fillMaxSize(),
418− contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 96.dp),
419− verticalArrangement = Arrangement.spacedBy(12.dp),
420− ) {
421− items(sessions, key = { it.id }) { session ->
422− // Swipe end-to-start reveals a delete affordance and opens the
423− // same confirmation dialog as the trash icon. We never commit
424− // the dismissal (always reset to Settled) so the card snaps back
425− // and the dialog guards against accidental data loss.
426− val swipeState = rememberSwipeToDismissBoxState(
427− confirmValueChange = {
428− // Haptic at the trigger so the swipe path matches the icon-path
429− // delete, which vibrates on confirm. Returns false (snap back) so
430− // the dialog guards the actual deletion.
431− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
432− onDelete(session.id)
433− false
434− },
435− )
436− SwipeToDismissBox(
437− state = swipeState,
438− enableDismissFromStartToEnd = false,
439− modifier = Modifier.animateItem(),
440− backgroundContent = {
441− Box(
442− modifier = Modifier
443− .fillMaxSize()
444− .clip(MaterialTheme.shapes.medium)
445− .background(MaterialTheme.colorScheme.errorContainer)
446− .padding(horizontal = 20.dp),
447− contentAlignment = Alignment.CenterEnd,
448− ) {
449− Icon(
450− Icons.Filled.Delete,
451− contentDescription = stringResource(R.string.delete),
452− tint = MaterialTheme.colorScheme.onErrorContainer,
453− )
454− }
455− },
456− ) {
457− SessionCard(
458− session = session,
459− preview = state.previews[session.id],
460− unreadCount = unread[session.id] ?: 0,
461− onClick = { onOpenSession(session.id) },
462− onRename = { onRename(session.id) },
463− onDelete = { onDelete(session.id) },
464− modifier = Modifier.testTag("session_card"),
465− )
466− }
467− }
468− }
469− }
470− }
471− }
472− }
473−}
474−
475−@Composable
476−private fun RenameSessionDialog(
477− title: String,
478− onTitleChange: (String) -> Unit,
479− onConfirm: () -> Unit,
480− onDismiss: () -> Unit,
481−) {
482− AlertDialog(
483− onDismissRequest = onDismiss,
484− title = { Text(stringResource(R.string.rename_session)) },
485− text = {
486− OutlinedTextField(
487− value = title,
488− onValueChange = { v -> onTitleChange(v.take(NetworkConfig.maxSessionTitleChars)) },
489− modifier = Modifier.fillMaxWidth(),
490− placeholder = { Text(stringResource(R.string.session_title_hint)) },
491− label = { Text(stringResource(R.string.rename_session)) },
492− singleLine = true,
493− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
494− keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm() }),
495− )
496− },
497− confirmButton = {
498− TextButton(onClick = onConfirm, enabled = title.isNotBlank()) { Text(stringResource(R.string.save)) }
499− },
500− dismissButton = {
501− TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
502− },
503− )
504−}
505−
506−@OptIn(ExperimentalMaterial3Api::class)
507−@Composable
508−private fun SessionCard(
509− session: Session,
510− preview: String?,
511− unreadCount: Int,
512− onClick: () -> Unit,
513− onRename: () -> Unit,
514− onDelete: () -> Unit,
515− modifier: Modifier = Modifier,
516−) {
517− Card(
518− modifier = modifier
519− .fillMaxWidth()
520− .clickable { onClick() },
521− ) {
522− Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
523− Row(
524− modifier = Modifier.fillMaxWidth(),
525− verticalAlignment = Alignment.CenterVertically,
526− ) {
527− Column(modifier = Modifier.weight(1f)) {
528− Row(verticalAlignment = Alignment.CenterVertically) {
529− if (unreadCount > 0) {
530− val unreadLabel = stringResource(R.string.unread_count, unreadCount)
531− // Count badge: shows the number of unread messages so the user
532− // can tell a single reply from a burst. Falls back to a dot for
533− // a count of 1 (the common "one reply" case) to avoid clutter.
534− if (unreadCount == 1) {
535− Box(
536− modifier = Modifier
537− .padding(end = 8.dp)
538− .size(10.dp)
539− .clip(androidx.compose.foundation.shape.CircleShape)
540− .background(MaterialTheme.colorScheme.primary)
541− .semantics { contentDescription = unreadLabel },
542− )
543− } else {
544− Box(
545− modifier = Modifier
546− .padding(end = 8.dp)
547− .clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
548− .background(MaterialTheme.colorScheme.primary)
549− .padding(horizontal = 6.dp, vertical = 1.dp)
550− .semantics { contentDescription = unreadLabel },
551− contentAlignment = Alignment.Center,
552− ) {
553− Text(
554− unreadCount.toString(),
555− style = MaterialTheme.typography.labelSmall,
556− color = MaterialTheme.colorScheme.onPrimary,
557− )
558− }
559− }
560− }
561− Text(
562− session.displayTitle,
563− style = MaterialTheme.typography.titleMedium,
564− color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
565− maxLines = 1,
566− overflow = TextOverflow.Ellipsis,
567− )
568− }
569− val time = rememberRelativeTime(session.time?.updated ?: session.time?.created)
570− if (time.isNotEmpty()) {
571− Text(
572− time,
573− style = MaterialTheme.typography.labelSmall,
574− color = MaterialTheme.colorScheme.onSurfaceVariant,
575− modifier = Modifier.padding(top = 2.dp),
576− )
577− }
578− }
579− IconButton(onClick = onRename) {
580− Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.rename))
581− }
582− IconButton(onClick = onDelete) {
583− Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.delete))
584− }
585− }
586− if (!preview.isNullOrBlank()) {
587− Spacer(Modifier.size(4.dp))
588− Text(
589− preview,
590− style = MaterialTheme.typography.bodyMedium,
591− color = if (unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
592− maxLines = 2,
593− overflow = TextOverflow.Ellipsis,
594− )
595− }
596− }
597− }
598−}
599−
600−@Composable
601−private fun EmptySessions(onCreate: () -> Unit, modifier: Modifier = Modifier) {
602− Column(
603− modifier = modifier.padding(32.dp),
604− horizontalAlignment = Alignment.CenterHorizontally,
605− ) {
606− Icon(
607− Icons.Filled.ChatBubbleOutline,
608− contentDescription = null,
609− modifier = Modifier.size(56.dp),
610− tint = MaterialTheme.colorScheme.onSurfaceVariant,
611− )
612− Spacer(Modifier.size(16.dp))
613− Text(stringResource(R.string.no_sessions_yet), style = MaterialTheme.typography.titleMedium)
614− Spacer(Modifier.size(8.dp))
615− Text(
616− stringResource(R.string.no_sessions_hint),
617− style = MaterialTheme.typography.bodyMedium,
618− color = MaterialTheme.colorScheme.onSurfaceVariant,
619− )
620− Spacer(Modifier.size(20.dp))
621− Button(onClick = onCreate) {
622− Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
623− Text(stringResource(R.string.new_session), modifier = Modifier.padding(start = 6.dp))
624− }
625− }
626−}
627−
628−/** Title + dropdown for quick-switching between saved servers. Highlights the active
629− * server with a check mark and primary color so the user can tell which one they're
630− * on at a glance. Includes an "Add server" item so adding a new server doesn't require
631− * navigating out to Settings → Manage servers — a long path for a common action. */
632−@Composable
633−private fun ServerSwitcherMenu(
634− serverLabel: String,
635− profiles: List<ServerProfile>,
636− connectedId: String?,
637− switchingId: String?,
638− expanded: Boolean,
639− onExpand: () -> Unit,
640− onDismiss: () -> Unit,
641− onSelect: (ServerProfile) -> Unit,
642− onAddServer: () -> Unit,
643−) {
644− Column {
645− Box {
646− Row(
647− modifier = Modifier
648− .clickable(role = Role.Button) { onExpand() },
649− verticalAlignment = Alignment.CenterVertically,
650− ) {
651− Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
652− Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.switch_server))
653− }
654− DropdownMenu(
655− expanded = expanded,
656− onDismissRequest = onDismiss,
657− ) {
658− profiles.forEach { profile ->
659− val isActiveProfile = profile.id == connectedId
660− DropdownMenuItem(
661− text = {
662− Row(verticalAlignment = Alignment.CenterVertically) {
663− Column(modifier = Modifier.weight(1f)) {
664− Text(
665− profile.displayLabel,
666− style = MaterialTheme.typography.bodyLarge,
667− color = if (isActiveProfile) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
668− maxLines = 1,
669− overflow = TextOverflow.Ellipsis,
670− )
671− Text(
672− profile.baseUrl,
673− style = MaterialTheme.typography.bodySmall,
674− color = MaterialTheme.colorScheme.onSurfaceVariant,
675− maxLines = 1,
676− overflow = TextOverflow.Ellipsis,
677− )
678− }
679− if (isActiveProfile) {
680− Icon(
681− Icons.Filled.CheckCircle,
682− contentDescription = stringResource(R.string.connected),
683− tint = MaterialTheme.colorScheme.primary,
684− modifier = Modifier.size(18.dp),
685− )
686− }
687− if (switchingId == profile.id) {
688− val switchingLabel = stringResource(R.string.loading)
689− CircularProgressIndicator(
690− Modifier
691− .size(18.dp)
692− .semantics { contentDescription = switchingLabel },
693− strokeWidth = 2.dp,
694− )
695− }
696− }
697− },
698− onClick = { onSelect(profile) },
699− )
700− }
701− // Divider + Add server item so the user can add a server without leaving
702− // the session list. The trailing icon reinforces the primary action.
703− androidx.compose.material3.HorizontalDivider()
704− DropdownMenuItem(
705− text = { Text(stringResource(R.string.add_server)) },
706− leadingIcon = { Icon(Icons.Filled.Add, contentDescription = null) },
707− onClick = {
708− onDismiss()
709− onAddServer()
710− },
711− )
712− }
713− }
714− }
715−}
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktdeleted−558
@@ -1,558 +0,0 @@
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. */
139− // Keyed by session id, not a single field: the undo window lets several deletes be
140− // in flight at once, and a single field would let a second delete overwrite the
141− // first — dropping the first's server-side delete while it stays removed from the UI.
142− private val pendingDeletes = mutableMapOf<String, Session>()
143−
144− private var previewJob: Job? = null
145− private var refreshJob: Job? = null
146−
147− /** Per-session in-flight preview loads, so rapid SSE events for the same session
148− * coalesce into a single request instead of firing one per event. Accessed from
149− * multiple coroutines (the SSE event handler and [loadPreview]), so the
150− * remove-then-put sequence is guarded by [previewLock] to stay atomic. */
151− private val livePreviewJobs = java.util.concurrent.ConcurrentHashMap<String, Job>()
152− private val previewLock = Any()
153−
154− /** Accumulates SessionUpdated events between debounce flushes so a burst of SSE
155− * updates during active streaming coalesces into one list rebuild + recomposition
156− * instead of one per event. */
157− private val pendingSessionUpdates = java.util.concurrent.ConcurrentHashMap<String, Session>()
158− private var sessionUpdateJob: Job? = null
159−
160− private val activeProfileId: String?
161− get() = container.activeConnection.value?.profile?.id
162−
163− init { refresh(); observeSessionEvents() }
164−
165− /**
166− * Keep the session list live by reacting to SSE session events. New/renamed
167− * sessions upsert and re-sort; deleted sessions drop immediately — no manual
168− * refresh needed when activity happens server-side.
169− */
170− private fun observeSessionEvents() {
171− viewModelScope.launch {
172− container.activeConnection.collectLatest { conn ->
173− if (conn == null) return@collectLatest
174− _serverLabel.value = conn.profile.displayLabel
175− // Cancel any in-flight preview loads from the previous connection so
176− // they don't complete against the old server and write stale previews
177− // for sessions that may not exist on the new one. collectLatest cancels
178− // the SSE collector, but the jobs in livePreviewJobs are independent
179− // launches on viewModelScope that survive the collector cancellation.
180− synchronized(previewLock) {
181− livePreviewJobs.values.forEach { it.cancel() }
182− livePreviewJobs.clear()
183− }
184− sessionUpdateJob?.cancel()
185− pendingSessionUpdates.clear()
186− // Reload the session list when a connection becomes available so the
187− // initial load (which may have run before auto-reconnect finished and
188− // found no connection) doesn't leave a stale "not connected" error.
189− refresh()
190− try {
191− conn.events.events.collect { event ->
192− when (event) {
193− is SessionUpdated -> {
194− val session = event.properties.info
195− // Ignore updates for a session the user just deleted (still
196− // within its undo window): the server hasn't deleted it yet
197− // and can legitimately emit updates, but buffering them here
198− // would resurrect the optimistically-hidden row mid-undo.
199− if (pendingDeletes.containsKey(session.id)) return@collect
200− pendingSessionUpdates[session.id] = session
201− if (sessionUpdateJob?.isActive != true) {
202− sessionUpdateJob = viewModelScope.launch {
203− delay(NetworkConfig.sessionUpdateDebounceMs)
204− flushPendingSessionUpdates()
205− }
206− }
207− }
208− is SessionDeleted -> {
209− val id = event.properties.info?.id ?: event.properties.sessionID
210− if (id != null) {
211− synchronized(previewLock) { livePreviewJobs.remove(id)?.cancel() }
212− // Drop any buffered update for this id so a pending flush
213− // can't re-add the just-deleted session to the list.
214− pendingSessionUpdates.remove(id)
215− container.draftStore.remove(id)
216− _state.update { s ->
217− s.copy(
218− sessions = s.sessions.filterNot { it.id == id },
219− previews = s.previews - id,
220− )
221− }
222− }
223− }
224− else -> {}
225− }
226− }
227− } catch (e: CancellationException) {
228− throw e
229− } catch (e: Exception) {
230− Log.w("SessionListVM", "SSE event collector error", e)
231− }
232− }
233− }
234− }
235−
236− /** Apply all buffered SessionUpdated events at once: upsert into the sessions list,
237− * re-sort by recency, and kick off preview fetches. Called after the debounce delay. */
238− private fun flushPendingSessionUpdates() {
239− val batch = pendingSessionUpdates.toMap()
240− if (batch.isEmpty()) return
241− // Remove only the snapshotted key/value pairs rather than clear()-ing the whole
242− // map: an update for a session that arrived between the toMap() snapshot and here
243− // would otherwise be dropped. The key/value remove overload also leaves a newer
244− // value for the same key in place to be flushed on the next pass.
245− batch.forEach { (k, v) -> pendingSessionUpdates.remove(k, v) }
246− _state.update { s ->
247− val byId = s.sessions.associateBy { it.id }.toMutableMap()
248− var anyChanged = false
249− batch.forEach { (id, session) ->
250− // Final guard: never re-add a session that's pending deletion (its undo
251− // window is still open), even if an update slipped into this batch.
252− if (id in pendingDeletes) return@forEach
253− if (byId[id] != session) { byId[id] = session; anyChanged = true }
254− }
255− // No-op update: skip the rebuild entirely so the StateFlow doesn't emit a
256− // new state (and trigger recomposition) when the batch held no real changes
257− // (e.g. a duplicate SessionUpdated for an unchanged session).
258− if (!anyChanged) return@update s
259− val sorted = byId.values.sortedByMode(s.sortMode)
260− // If the new ordering matches the existing one (e.g. only the already-newest
261− // session updated its timestamp), reuse the same list instance so downstream
262− // skips a recomposition for an identical reference.
263− val sessions = if (sorted == s.sessions) s.sessions else sorted
264− s.copy(sessions = sessions)
265− }
266− batch.keys.forEach { loadPreview(it) }
267− }
268−
269− fun setQuery(query: String) {
270− _state.update { it.copy(query = query) }
271− }
272−
273− /** Force the SSE stream to reconnect (recovery path for a Failed banner) and
274− * re-fetch the session list so the UI reflects the current server state. */
275− fun retryConnection() {
276− container.activeConnection.value?.events?.triggerReconnect()
277− refresh()
278− }
279−
280− fun refresh() {
281− val conn = container.activeConnection.value
282− if (conn == null) {
283− if (_state.value.sessions.isEmpty()) {
284− _state.value = SessionListState(loading = false, error = container.string(R.string.not_connected))
285− } else {
286− _state.update { it.copy(loading = false) }
287− _transientErrors.tryEmit(container.string(R.string.not_connected))
288− }
289− _refreshing.value = false
290− return
291− }
292− _serverLabel.value = conn.profile.displayLabel
293− _state.update { it.copy(loading = true, error = null) }
294− _refreshing.value = true
295− refreshJob?.cancel()
296− refreshJob = viewModelScope.launch {
297− val self = coroutineContext[Job]
298− try {
299− runCatchingCancellable { conn.repository.listSessions() }
300− .onSuccess { list ->
301− val sorted = list.sortedByMode(_state.value.sortMode)
302− _state.update { it.copy(sessions = sorted, loading = false, error = null) }
303− loadPreviews(sorted)
304− }
305− .onFailure {
306− if (_state.value.sessions.isNotEmpty()) {
307− _state.update { it.copy(loading = false) }
308− _transientErrors.tryEmit(container.friendlyError(it))
309− } else {
310− _state.value = SessionListState(loading = false, error = container.friendlyError(it))
311− }
312− }
313− } finally {
314− // Only clear the spinner if we're still the active refresh job. When a
315− // newer refresh() cancels this one, its finally runs asynchronously on
316− // the Main dispatcher and would otherwise clobber _refreshing back to
317− // false while the newer refresh is still in flight.
318− if (refreshJob === self) _refreshing.value = false
319− }
320− }
321− }
322−
323− /**
324− * Reload the preview for a single session (used by the live SSE handler).
325− * Cancels any prior in-flight load for the same session so a burst of
326− * [SessionUpdated] events coalesces into a single request. A short debounce
327− * delay before the fetch avoids launching one full-history download per event
328− * during active streaming — the fetch runs only after events quiet down.
329− */
330− private fun loadPreview(sessionId: String) {
331− val conn = container.activeConnection.value ?: return
332− val api = conn.api
333− val toolFormat = container.string(R.string.tool_preview)
334− synchronized(previewLock) {
335− livePreviewJobs.remove(sessionId)?.cancel()
336− livePreviewJobs[sessionId] = viewModelScope.launch {
337− val job = coroutineContext[kotlinx.coroutines.Job]
338− try {
339− delay(NetworkConfig.previewDebounceMs)
340− val preview = fetchSessionPreview(api, sessionId, toolFormat) ?: return@launch
341− _state.update { s -> s.copy(previews = s.previews + (sessionId to preview)) }
342− } catch (e: CancellationException) {
343− throw e
344− } catch (e: Exception) {
345− Log.w("SessionListVM", "Preview load failed for $sessionId", e)
346− } finally {
347− if (job != null) synchronized(previewLock) { livePreviewJobs.remove(sessionId, job) }
348− }
349− }
350− }
351− }
352−
353− /** Fetch the last text part of each session for list previews (best-effort, bounded).
354− * Sessions that already have a cached preview are skipped — their previews are kept
355− * fresh by the live SSE handler ([loadPreview] on [SessionUpdated]). Each preview
356− * fetch downloads the full conversation history, so re-fetching all of them on every
357− * refresh is wasteful when most are already cached. */
358− private fun loadPreviews(sessions: List<Session>) {
359− previewJob?.cancel()
360− val conn = container.activeConnection.value ?: return
361− val api = conn.api
362− val toolFormat = container.string(R.string.tool_preview)
363− // Keep previews for sessions still in the list; drop stale ones without
364− // clearing the whole map (which would cause a visual flash).
365− val keepIds = sessions.mapTo(mutableSetOf()) { it.id }
366− _state.update { s -> s.copy(previews = s.previews.filterKeys { it in keepIds }) }
367− val cached = _state.value.previews.keys
368− val toFetch = sessions.take(NetworkConfig.maxPreviewSessions).filter { it.id !in cached }
369− if (toFetch.isEmpty()) return
370− previewJob = viewModelScope.launch {
371− val semaphore = Semaphore(NetworkConfig.maxConcurrentPreviews)
372− toFetch.map { session ->
373− launch {
374− semaphore.withPermit {
375− val preview = fetchSessionPreview(api, session.id, toolFormat) ?: return@withPermit
376− _state.update { s -> s.copy(previews = s.previews + (session.id to preview)) }
377− }
378− }
379− }.joinAll()
380− }
381− }
382−
383− fun createSession(onCreated: (String) -> Unit) {
384− val conn = container.activeConnection.value ?: return
385− if (!_creating.compareAndSet(false, true)) return
386− viewModelScope.launch {
387− try {
388− runCatchingCancellable { conn.repository.createSession() }
389− .onSuccess { onCreated(it.id); refresh() }
390− .onFailure {
391− _state.update { it.copy(error = null) }
392− _transientErrors.tryEmit(container.friendlyError(it))
393− }
394− } finally {
395− _creating.value = false
396− }
397− }
398− }
399−
400− /**
401− * Mark [session] as pending deletion and hide it from the list immediately, then
402− * surface an Undo via [undoEvents]. The actual REST delete is deferred by
403− * [NetworkConfig.undoDeleteDelayMs]; if [undoDelete] is called before it fires,
404− * the session is restored. Otherwise the delete proceeds.
405− */
406− fun deleteSession(session: Session) {
407− // Optimistically remove from the visible list so the UI feels instant.
408− _state.update { s ->
409− s.copy(sessions = s.sessions.filterNot { it.id == session.id })
410− }
411− // Hold the pending delete so the snackbar can undo it.
412− pendingDeletes[session.id] = session
413− // Drop any already-buffered update for this session so a pending flush during the
414− // undo window can't re-add the row we just optimistically hid.
415− pendingSessionUpdates.remove(session.id)
416− _undoEvents.tryEmit(session.id)
417− viewModelScope.launch {
418− delay(NetworkConfig.undoDeleteDelayMs)
419− // If undo ran, this id was already removed — remove() returning null means
420− // "don't delete". Removing by id also can't be clobbered by a later delete.
421− if (pendingDeletes.remove(session.id) != null) {
422− val conn = container.activeConnection.value ?: return@launch
423− runCatchingCancellable { conn.repository.deleteSession(session.id) }
424− .onSuccess {
425− container.draftStore.remove(session.id)
426− refresh()
427− }
428− .onFailure {
429− // Restore the session to the list since the delete failed.
430− _state.update { s -> s.copy(sessions = (s.sessions + session).sortedByMode(s.sortMode)) }
431− _transientErrors.tryEmit(container.friendlyError(it))
432− }
433− }
434− }
435− }
436−
437− /** Cancel a pending delete (Undo snackbar action) and restore the session. */
438− fun undoDelete(sessionId: String) {
439− val pending = pendingDeletes.remove(sessionId)
440− if (pending != null) {
441− _state.update { s ->
442− s.copy(sessions = (s.sessions + pending).sortedByMode(s.sortMode))
443− }
444− }
445− }
446−
447− fun setSortMode(mode: SessionSortMode) {
448− if (mode == _state.value.sortMode) return
449− _state.update { s -> s.copy(sortMode = mode, sessions = s.sessions.sortedByMode(mode)) }
450− }
451−
452− fun renameSession(session: Session, newTitle: String) {
453− val conn = container.activeConnection.value ?: return
454− val title = newTitle.trim()
455− if (title.isEmpty() || title == session.title) return
456− viewModelScope.launch {
457− runCatchingCancellable { conn.api.updateSession(session.id, title) }
458− .onSuccess { refresh() }
459− .onFailure {
460− _state.update { it.copy(error = null) }
461− _transientErrors.tryEmit(container.friendlyError(it))
462− }
463− }
464− }
465−
466− /** Quick-switch to a different saved server without leaving the session list. */
467− fun switchServer(profile: ServerProfile) {
468− if (profile.id == activeProfileId) return
469− if (_switchingId.value != null) return
470− val previousProfile = container.activeConnection.value?.profile
471− val savedUnread = container.unread.value
472− _switchingId.value = profile.id
473− _state.update { it.copy(loading = true, error = null) }
474− viewModelScope.launch {
475− val result = runCatchingCancellable {
476− val conn = container.connect(profile)
477− conn.api.ping()
478− }
479− _switchingId.value = null
480− result.onSuccess { refresh() }
481− .onFailure { error ->
482− // connect() already closed the old connection; try to restore the
483− // previous profile so the session list survives. If there was none,
484− // show the error state.
485− container.disconnect()
486− if (previousProfile != null) {
487− val restored = runCatchingCancellable { container.connect(previousProfile) }
488− // connect() clears unread state; restore what we saved so the
489− // user doesn't lose unread badges on a failed server switch.
490− // Pass the real captured count so a session badged with "5
491− // unread" is restored as 5, not 1.
492− savedUnread.forEach { (id, count) -> container.restoreUnread(id, count) }
493− if (restored.isSuccess) {
494− // Clear stale sessions from the old server; the SSE observer
495− // will reload from the restored connection via refresh().
496− _state.update {
497− SessionListState(
498− sessions = emptyList(),
499− previews = emptyMap(),
500− loading = false,
501− )
502− }
503− // Surface the switch failure as a transient snackbar — refresh()
504− // would clear a state.error, so use transientErrors which survives it.
505− _transientErrors.tryEmit(container.friendlyError(error))
506− refresh()
507− } else {
508− // Restore also failed — the user is now disconnected from
509− // both servers. Surface both failures so the user understands
510− // why they're looking at an empty list.
511− val restoreMsg = container.friendlyError(restored.exceptionOrNull() ?: error)
512− _state.value = SessionListState(
513− loading = false,
514− error = container.string(R.string.error_switch_and_restore_failed,
515− container.friendlyError(error), restoreMsg),
516− )
517− }
518− } else {
519− _state.value = SessionListState(loading = false, error = container.friendlyError(error))
520− }
521− }
522− }
523− }
524−}
525−
526−/**
527− * Fetch a preview of a session's most recent activity, truncated to
528− * [NetworkConfig.previewTextMaxLength]. Returns the last text part if present; otherwise
529− * a short summary of the last tool call (e.g. "🔧 read") so a session whose last activity
530− * was a tool call (no assistant text yet) still shows a meaningful preview instead of
531− * stale/empty. Returns null on failure or when there's nothing usable.
532− *
533− * [toolPreviewFormat] is a format string (from resources) with a %1$s placeholder for
534− * the tool title/name, so the wrench emoji prefix is a presentation concern resolved
535− * by the caller rather than hardcoded here.
536− */
537−private suspend fun fetchSessionPreview(
538− api: soy.iko.opencode.data.network.OpencodeApiClient,
539− sessionId: String,
540− toolPreviewFormat: String,
541−): String? = runCatchingCancellable {
542− api.listMessages(sessionId).lastOrNull()?.let { msg ->
543− val text = msg.parts.filterIsInstance<TextPart>().lastOrNull()?.text
544− if (text != null) return@let text
545− val tool = msg.parts.filterIsInstance<ToolPart>().lastOrNull()
546− if (tool != null) {
547− val title = when (val st = tool.state) {
548− is ToolRunning -> st.title
549− is ToolCompleted -> st.title
550− is ToolError -> st.error
551− else -> null
552− }
553− return@let title?.takeIf { it.isNotBlank() }?.let { toolPreviewFormat.format(it) }
554− ?: toolPreviewFormat.format(tool.tool)
555− }
556− null
557− }
558−}.getOrNull()?.takeIf { it.isNotBlank() }?.take(NetworkConfig.previewTextMaxLength)
app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktdeleted−146
@@ -1,146 +0,0 @@
1−package soy.iko.opencode.ui.session
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Box
5−import androidx.compose.foundation.layout.Column
6−import androidx.compose.foundation.layout.Row
7−import androidx.compose.foundation.layout.fillMaxSize
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.widthIn
10−import androidx.activity.compose.BackHandler
11−import androidx.compose.material.icons.Icons
12−import androidx.compose.material.icons.automirrored.filled.Chat
13−import androidx.compose.material3.Icon
14−import androidx.compose.material3.MaterialTheme
15−import androidx.compose.material3.Text
16−import androidx.compose.runtime.Composable
17−import androidx.compose.runtime.LaunchedEffect
18−import androidx.compose.runtime.getValue
19−import androidx.compose.runtime.mutableStateOf
20−import androidx.compose.runtime.remember
21−import androidx.compose.runtime.saveable.rememberSaveable
22−import androidx.compose.runtime.setValue
23−import androidx.compose.ui.Alignment
24−import androidx.compose.ui.Modifier
25−import androidx.compose.ui.res.stringResource
26−import androidx.compose.ui.unit.dp
27−import androidx.lifecycle.compose.collectAsStateWithLifecycle
28−import soy.iko.opencode.R
29−import soy.iko.opencode.data.network.NetworkConfig
30−import soy.iko.opencode.di.AppContainer
31−import soy.iko.opencode.ui.chat.ChatScreen
32−
33−/**
34− * Master–detail layout for wide screens (≥ 840dp, e.g. tablets / unfolded foldables):
35− * the session list on the left, the selected conversation on the right. On compact
36− * widths the app uses the single-pane [androidx.navigation] back stack instead.
37− *
38− * Each side hosts an existing screen (which carries its own Scaffold + app bar), so the
39− * two panes are self-contained. Selecting a session updates the detail pane rather than
40− * pushing a destination; the chat [onBack] clears the selection. The chat composition is
41− * keyed by session id so each conversation gets its own ViewModel.
42− */
43−@Composable
44−fun TwoPaneSessionChat(
45− container: AppContainer,
46− onOpenFiles: () -> Unit,
47− onOpenFile: (String) -> Unit,
48− onOpenSettings: () -> Unit,
49− onDisconnect: () -> Unit,
50− onAddServer: () -> Unit,
51− modifier: Modifier = Modifier,
52−) {
53− var selected by rememberSaveable { mutableStateOf<String?>(null) }
54− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
55− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
56− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
57−
58− // A notification tap / deep link requests a session: open it in the detail pane.
59− LaunchedEffect(pendingOpenSession) {
60− pendingOpenSession?.let {
61− selected = it
62− container.consumePendingOpenSession()
63− }
64− }
65−
66− // Clear the selection when the active server profile changes (a server switch via
67− // the ServerSwitcherMenu). Without this, `selected` still points at the old
68− // server's session id and the detail pane renders a ChatScreen for a session that
69− // doesn't exist on the new server, showing a load error with no explanation.
70− // Skips the initial transition (null -> a profile id) so a deep link / pending
71− // open session that sets `selected` on the same first composition isn't clobbered.
72− var lastProfileId by remember { mutableStateOf<String?>(null) }
73− LaunchedEffect(activeConnection?.profile?.id) {
74− val currentId = activeConnection?.profile?.id
75− if (lastProfileId != null && lastProfileId != currentId) selected = null
76− lastProfileId = currentId
77− }
78−
79− // Inject a pending share into the currently selected session's draft (if any).
80− // Unlike single-pane mode where the session list is navigated to specifically for
81− // the share, in two-pane mode the list is always visible — so we inject into the
82− // session the user is already viewing. If no session is selected, the share is
83− // deferred until the user picks one (the LaunchedEffect re-fires when `selected`
84− // changes to non-null).
85− LaunchedEffect(pendingShare, selected) {
86− val target = selected
87− if (pendingShare != null && target != null) {
88− container.consumePendingShare()?.let { container.draftStore.set(target, it) }
89− }
90− }
91−
92− Row(modifier = modifier.fillMaxSize()) {
93− Box(
94− modifier = Modifier
95− .weight(NetworkConfig.twoPaneLeftWeight)
96− .widthIn(max = 460.dp),
97− ) {
98− SessionListScreen(
99− container = container,
100− onOpenSession = { id ->
101− selected = id
102− },
103− onDisconnect = onDisconnect,
104− onOpenFiles = onOpenFiles,
105− onOpenSettings = onOpenSettings,
106− onAddServer = onAddServer,
107− )
108− }
109−
110− Box(modifier = Modifier.weight(NetworkConfig.twoPaneRightWeight).fillMaxSize()) {
111− val sessionId = selected
112− if (sessionId == null) {
113− EmptyDetail()
114− } else {
115− BackHandler { selected = null }
116− ChatScreen(
117− container = container,
118− sessionId = sessionId,
119− onBack = { selected = null },
120− onOpenFile = onOpenFile,
121− )
122− }
123− }
124− }
125−}
126−
127−@Composable
128−private fun EmptyDetail() {
129− Column(
130− modifier = Modifier.fillMaxSize().padding(32.dp),
131− verticalArrangement = Arrangement.Center,
132− horizontalAlignment = Alignment.CenterHorizontally,
133− ) {
134− Icon(
135− Icons.AutoMirrored.Filled.Chat,
136− contentDescription = null,
137− tint = MaterialTheme.colorScheme.outline,
138− )
139− Text(
140− stringResource(R.string.empty_detail_pane),
141− style = MaterialTheme.typography.bodyLarge,
142− color = MaterialTheme.colorScheme.onSurfaceVariant,
143− modifier = Modifier.padding(top = 12.dp),
144− )
145− }
146−}
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktdeleted−381
@@ -1,381 +0,0 @@
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.delay
55−import kotlinx.coroutines.launch
56−import kotlinx.coroutines.withContext
57−import soy.iko.opencode.util.runCatchingCancellable
58−import soy.iko.opencode.ui.components.showToast
59−import androidx.compose.ui.Alignment
60−import androidx.compose.ui.Modifier
61−import androidx.compose.ui.draw.clip
62−import androidx.compose.ui.platform.LocalContext
63−import androidx.compose.ui.platform.LocalHapticFeedback
64−import androidx.compose.ui.res.stringResource
65−import androidx.compose.ui.semantics.Role
66−import androidx.compose.ui.semantics.contentDescription
67−import androidx.compose.ui.semantics.heading
68−import androidx.compose.ui.semantics.semantics
69−import androidx.compose.ui.unit.dp
70−import androidx.lifecycle.compose.collectAsStateWithLifecycle
71−import soy.iko.opencode.R
72−import soy.iko.opencode.data.network.NetworkConfig
73−import soy.iko.opencode.data.repo.CrashLogger
74−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
75−import soy.iko.opencode.ui.components.rememberRelativeTime
76−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
77−
78−@OptIn(ExperimentalMaterial3Api::class)
79−@Composable
80−fun DiagnosticsScreen(onBack: () -> Unit) {
81− val context = LocalContext.current
82− val logger = remember { CrashLogger.get(context) }
83− val reports by logger.reports.collectAsStateWithLifecycle()
84− var viewing by rememberSaveable { mutableStateOf<String?>(null) }
85− var showClearAll by rememberSaveable { mutableStateOf(false) }
86− var pendingReportDelete by rememberSaveable { mutableStateOf<String?>(null) }
87− val shareScope = rememberCoroutineScope()
88− val haptics = LocalHapticFeedback.current
89− val snackbar = remember { SnackbarHostState() }
90− val undoLabel = stringResource(R.string.undo)
91− val reportDeletedLabel = stringResource(R.string.report_deleted)
92− // Deferred report deletions are owned by the CrashLogger (its process-lived scope),
93− // so they survive both rotation and navigating away from this screen. The screen only
94− // shows the Undo snackbar and asks the logger to cancel a pending delete on undo.
95− val shareLabel = stringResource(R.string.share)
96− val shareSubject = stringResource(R.string.crash_report_share_subject)
97− val timeTick = rememberRelativeTimeTick()
98−
99− Scaffold(
100− topBar = {
101− TopAppBar(
102− title = { Text(stringResource(R.string.diagnostics)) },
103− navigationIcon = {
104− IconButton(onClick = onBack) {
105− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
106− }
107− },
108− actions = {
109− if (reports.isNotEmpty()) {
110− IconButton(onClick = {
111− showClearAll = true
112− }) {
113− Icon(Icons.Filled.DeleteSweep, contentDescription = stringResource(R.string.clear_all))
114− }
115− }
116− },
117− )
118− },
119− snackbarHost = { SnackbarHost(snackbar) },
120− ) { padding ->
121− CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
122− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
123− Text(
124− stringResource(R.string.diagnostics_desc),
125− style = MaterialTheme.typography.bodySmall,
126− color = MaterialTheme.colorScheme.onSurfaceVariant,
127− modifier = Modifier.padding(16.dp),
128− )
129− Text(
130− stringResource(R.string.crash_reports),
131− style = MaterialTheme.typography.titleSmall,
132− color = MaterialTheme.colorScheme.primary,
133− modifier = Modifier
134− .padding(start = 16.dp, bottom = 8.dp)
135− .semantics { heading() },
136− )
137− if (reports.isEmpty()) {
138− EmptyCrashReports(
139− modifier = Modifier.fillMaxWidth().padding(24.dp),
140− )
141− } else {
142− LazyColumn(modifier = Modifier.fillMaxSize()) {
143− items(reports, key = { it.fileName }) { report ->
144− // Swipe end-to-start reveals a delete affordance. The delete is
145− // deferred for an undo window (matching the session/server lists);
146− // confirmValueChange snaps back so the row remains while the undo
147− // snackbar is shown, and is removed only when the window expires.
148− val swipeState = rememberSwipeToDismissBoxState(
149− confirmValueChange = {
150− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
151− pendingReportDelete = report.fileName
152− false
153− },
154− )
155− SwipeToDismissBox(
156− state = swipeState,
157− enableDismissFromStartToEnd = false,
158− modifier = Modifier.animateItem(),
159− backgroundContent = {
160− Box(
161− modifier = Modifier
162− .fillMaxSize()
163− .clip(MaterialTheme.shapes.medium)
164− .background(MaterialTheme.colorScheme.errorContainer)
165− .padding(horizontal = 20.dp),
166− contentAlignment = Alignment.CenterEnd,
167− ) {
168− Icon(
169− Icons.Filled.DeleteSweep,
170− contentDescription = stringResource(R.string.delete),
171− tint = MaterialTheme.colorScheme.onErrorContainer,
172− )
173− }
174− },
175− ) {
176− Row(
177− modifier = Modifier
178− .fillMaxWidth()
179− .clickable(role = Role.Button) { viewing = report.fileName }
180− .padding(horizontal = 16.dp, vertical = 12.dp),
181− verticalAlignment = Alignment.CenterVertically,
182− horizontalArrangement = Arrangement.spacedBy(12.dp),
183− ) {
184− Icon(
185− Icons.Filled.BugReport,
186− contentDescription = null,
187− tint = MaterialTheme.colorScheme.error,
188− )
189− Column(modifier = Modifier.weight(1f)) {
190− Text(report.preview, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
191− Text(
192− rememberRelativeTime(report.timestamp),
193− style = MaterialTheme.typography.bodySmall,
194− color = MaterialTheme.colorScheme.onSurfaceVariant,
195− )
196− }
197− IconButton(onClick = {
198− shareScope.launch {
199− val content = withContext(Dispatchers.IO) {
200− logger.readReport(report.fileName).orEmpty()
201− }
202− val send = Intent(Intent.ACTION_SEND).apply {
203− type = "text/plain"
204− putExtra(Intent.EXTRA_SUBJECT, shareSubject)
205− putExtra(Intent.EXTRA_TEXT, content)
206− }
207− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
208− .onFailure {
209− Log.w("Diagnostics", "Failed to share crash report", it)
210− showToast(context, context.getString(R.string.no_share_app))
211− }
212− }
213− }) {
214− Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
215− }
216− }
217− }
218− HorizontalDivider()
219− }
220− }
221− }
222− }
223− }
224− }
225−
226− val reportName = viewing
227− if (reportName != null) {
228− var reportContent by remember(reportName) { mutableStateOf<String?>(null) }
229− var loadFailed by remember(reportName) { mutableStateOf(false) }
230− LaunchedEffect(reportName) {
231− val loaded = withContext(Dispatchers.IO) {
232− // runCatchingCancellable (not runCatching) so dismissing the dialog or
233− // switching reports mid-read lets the CancellationException propagate
234− // instead of being swallowed and setting state on a torn-down effect.
235− runCatchingCancellable { logger.readReport(reportName) }.getOrNull()
236− }
237− if (loaded != null) reportContent = loaded else loadFailed = true
238− }
239− val content = reportContent
240− AlertDialog(
241− onDismissRequest = { viewing = null },
242− confirmButton = {
243− TextButton(onClick = { viewing = null }) { Text(stringResource(R.string.close)) }
244− },
245− dismissButton = {
246− Row {
247− TextButton(onClick = {
248− val send = Intent(Intent.ACTION_SEND).apply {
249− type = "text/plain"
250− putExtra(Intent.EXTRA_SUBJECT, shareSubject)
251− putExtra(Intent.EXTRA_TEXT, content.orEmpty())
252− }
253− runCatchingCancellable { context.startActivity(Intent.createChooser(send, shareLabel)) }
254− .onFailure {
255− Log.w("Diagnostics", "Failed to share crash report", it)
256− showToast(context, context.getString(R.string.no_share_app))
257− }
258− }) { Text(stringResource(R.string.share)) }
259− Spacer(Modifier.size(8.dp))
260− TextButton(onClick = {
261− // Confirm before deleting a single report, matching clear-all.
262− pendingReportDelete = reportName
263− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
264− }
265− },
266− title = { Text(stringResource(R.string.crash_report)) },
267− text = {
268− when {
269− content != null -> Box(
270− modifier = Modifier
271− .fillMaxWidth()
272− .heightIn(max = 400.dp)
273− .verticalScroll(rememberScrollState()),
274− ) {
275− Text(
276− content,
277− style = MaterialTheme.typography.bodySmall,
278− modifier = Modifier.fillMaxWidth(),
279− )
280− }
281− loadFailed -> Text(
282− stringResource(R.string.report_load_failed),
283− color = MaterialTheme.colorScheme.error,
284− modifier = Modifier.padding(vertical = 24.dp),
285− )
286− else -> {
287− val loadingLabel = stringResource(R.string.loading)
288− Box(
289− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
290− contentAlignment = Alignment.Center,
291− ) {
292− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
293− }
294− }
295− }
296− },
297− )
298− }
299−
300− pendingReportDelete?.let { name ->
301− AlertDialog(
302− onDismissRequest = { pendingReportDelete = null },
303− title = { Text(stringResource(R.string.delete_report_title)) },
304− text = { Text(stringResource(R.string.delete_report_text)) },
305− confirmButton = {
306− TextButton(onClick = {
307− pendingReportDelete = null
308− viewing = null
309− // Defer the actual delete so the Undo snackbar can cancel it. The
310− // deletion is owned by the CrashLogger's own scope, so it commits even
311− // if the user navigates away during the undo window (the screen's
312− // shareScope would otherwise cancel it, silently dropping the delete).
313− logger.scheduleDelete(name, NetworkConfig.undoReportDeleteDelayMs)
314− shareScope.launch {
315− // Indefinite + a matching timed dismiss keeps the Undo button
316− // visible for exactly the undo window and no longer: a fixed
317− // SnackbarDuration.Long (~10s) outlasted the 5s window, leaving
318− // the button on screen but dead for its second half.
319− val dismisser = launch {
320− delay(NetworkConfig.undoReportDeleteDelayMs)
321− snackbar.currentSnackbarData?.dismiss()
322− }
323− val result = snackbar.showSnackbar(
324− message = reportDeletedLabel,
325− actionLabel = undoLabel,
326− duration = SnackbarDuration.Indefinite,
327− )
328− dismisser.cancel()
329− if (result == SnackbarResult.ActionPerformed) {
330− logger.cancelScheduledDelete(name)
331− }
332− }
333− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
334− },
335− dismissButton = {
336− TextButton(onClick = { pendingReportDelete = null }) { Text(stringResource(R.string.cancel)) }
337− },
338− )
339− }
340−
341− if (showClearAll) {
342− AlertDialog(
343− onDismissRequest = { showClearAll = false },
344− title = { Text(stringResource(R.string.clear_all)) },
345− text = { Text(stringResource(R.string.clear_all_confirm)) },
346− confirmButton = {
347− TextButton(onClick = {
348− showClearAll = false
349− logger.clearAll()
350− }) { Text(stringResource(R.string.clear_all), color = MaterialTheme.colorScheme.error) }
351− },
352− dismissButton = {
353− TextButton(onClick = { showClearAll = false }) { Text(stringResource(R.string.cancel)) }
354− },
355− )
356− }
357−}
358−
359−/** Empty state for the crash reports list. Rendered with an icon + text to match the
360− * empty-state pattern used by the session and server lists (the prior version was a
361− * bare `Text` line that read as a status message rather than an intentional state). */
362−@Composable
363−private fun EmptyCrashReports(modifier: Modifier = Modifier) {
364− Column(
365− modifier = modifier,
366− horizontalAlignment = Alignment.CenterHorizontally,
367− ) {
368− Icon(
369− Icons.Filled.BugReport,
370− contentDescription = null,
371− modifier = Modifier.size(48.dp),
372− tint = MaterialTheme.colorScheme.onSurfaceVariant,
373− )
374− Spacer(Modifier.size(12.dp))
375− Text(
376− stringResource(R.string.no_crash_reports),
377− style = MaterialTheme.typography.bodyMedium,
378− color = MaterialTheme.colorScheme.onSurfaceVariant,
379− )
380− }
381−}
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktdeleted−397
@@ -1,397 +0,0 @@
1−package soy.iko.opencode.ui.settings
2−
3−import android.content.pm.PackageManager
4−import android.os.Build
5−import androidx.compose.foundation.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 sees it (the ConnectionBanner on other screens handles this
233− // elsewhere). Only show non-Connected states to avoid clutter.
234− val stateText = when (connectionState) {
235− EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
236− EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
237− EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
238− EventStreamClient.ConnectionState.Connected -> null
239− }
240− stateText?.let {
241− Text(
242− it,
243− style = MaterialTheme.typography.bodySmall,
244− color = if (connectionState == EventStreamClient.ConnectionState.Failed)
245− MaterialTheme.colorScheme.error
246− else MaterialTheme.colorScheme.onSurfaceVariant,
247− modifier = Modifier.padding(top = 4.dp),
248− )
249− }
250− } else {
251− Text(
252− stringResource(R.string.not_connected),
253− modifier = Modifier.padding(top = 8.dp),
254− color = MaterialTheme.colorScheme.onSurfaceVariant,
255− )
256− }
257− Row(
258− modifier = Modifier
259− .fillMaxWidth()
260− .defaultMinSize(minHeight = 48.dp)
261− .padding(top = 8.dp)
262− .clickable(role = Role.Button) { onManageServers() }
263− .padding(vertical = 8.dp),
264− verticalAlignment = Alignment.CenterVertically,
265− ) {
266− Icon(Icons.Filled.Dns, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
267− Text(stringResource(R.string.manage_servers), modifier = Modifier.weight(1f).padding(start = 8.dp))
268− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
269− }
270−
271− HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
272−
273− Text(
274− stringResource(R.string.about),
275− style = MaterialTheme.typography.titleSmall,
276− color = MaterialTheme.colorScheme.primary,
277− modifier = Modifier.semantics { heading() },
278− )
279− Text(
280− stringResource(R.string.about_version, versionName),
281− style = MaterialTheme.typography.bodyMedium,
282− modifier = Modifier.padding(top = 8.dp),
283− )
284− Text(
285− stringResource(R.string.about_desc),
286− style = MaterialTheme.typography.bodySmall,
287− color = MaterialTheme.colorScheme.onSurfaceVariant,
288− modifier = Modifier.padding(top = 4.dp),
289− )
290−
291− Spacer(Modifier.size(16.dp))
292− Row(
293− modifier = Modifier
294− .fillMaxWidth()
295− .defaultMinSize(minHeight = 48.dp)
296− .clickable(role = Role.Button) { onOpenDiagnostics() }
297− .padding(vertical = 8.dp),
298− verticalAlignment = Alignment.CenterVertically,
299− ) {
300− Icon(Icons.Filled.BugReport, contentDescription = null, tint = MaterialTheme.colorScheme.error)
301− Text(stringResource(R.string.diagnostics), modifier = Modifier.weight(1f).padding(start = 8.dp))
302− // Badge with the crash count so the user can tell at a glance whether
303− // there's something worth investigating.
304− if (crashCount > 0) {
305− Badge(count = crashCount)
306− Spacer(Modifier.size(8.dp))
307− }
308− Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
309− }
310− }
311− }
312−}
313−
314−@Composable
315−private fun ThemeRow(mode: ThemeMode, selected: Boolean, onSelect: () -> Unit) {
316− // Preview swatches so the user can see what each mode looks like before selecting,
317− // instead of choosing from text labels alone. Shows primary/secondary/tertiary +
318− // background so the palette character (e.g. Tokyo Night's blue/green) is visible.
319− val swatches: List<Color> = when (mode) {
320− ThemeMode.SYSTEM -> {
321− val dark = isSystemInDarkThemeStatic()
322− if (dark) DarkSwatches else LightSwatches
323− }
324− ThemeMode.LIGHT -> LightSwatches
325− ThemeMode.DARK -> DarkSwatches
326− }
327− Row(
328− modifier = Modifier
329− .fillMaxWidth()
330− .selectable(selected = selected, onClick = onSelect)
331− .padding(vertical = 8.dp),
332− horizontalArrangement = Arrangement.spacedBy(8.dp),
333− verticalAlignment = Alignment.CenterVertically,
334− ) {
335− RadioButton(selected = selected, onClick = null)
336− Text(
337− when (mode) {
338− ThemeMode.SYSTEM -> stringResource(R.string.theme_system)
339− ThemeMode.LIGHT -> stringResource(R.string.theme_light)
340− ThemeMode.DARK -> stringResource(R.string.theme_dark)
341− },
342− style = MaterialTheme.typography.bodyLarge,
343− modifier = Modifier.weight(1f),
344− )
345− // Three small color dots previewing the palette for this mode.
346− Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
347− swatches.forEach { color ->
348− Box(
349− modifier = Modifier
350− .size(14.dp)
351− .clip(CircleShape)
352− .background(color),
353− )
354− }
355− }
356− }
357−}
358−
359−// Palette preview dots for the theme rows. Primary/secondary/tertiary/background convey
360−// the character of each scheme at a glance without rendering a full sample surface.
361−private val DarkSwatches: List<Color> = DarkPaletteSwatches
362−private val LightSwatches: List<Color> = LightPaletteSwatches
363−
364−// Composable-side read of the system dark setting for the "System" theme row preview.
365−// isSystemInDarkTheme() is a @Composable function, so it can't be called inside a plain
366−// helper; hoist it here so the ThemeRow stays a plain @Composable with no preview-time
367−// recomposition surprises.
368−@Composable
369−private fun isSystemInDarkThemeStatic(): Boolean = androidx.compose.foundation.isSystemInDarkTheme()
370−
371−/** Small wrapper so the three appearance settings load atomically and the UI can gate
372− * on a single null check instead of flashing hardcoded defaults. */
373−private data class SettingsValues(
374− val themeMode: ThemeMode,
375− val dynamicColor: Boolean,
376− val sendOnEnter: Boolean,
377−)
378−
379−/** Small circular count badge used to indicate pending crash reports. */
380−@Composable
381−private fun Badge(count: Int) {
382− val overflow = stringResource(R.string.crash_count_overflow)
383− Box(
384− modifier = Modifier
385− .size(20.dp)
386− .clip(CircleShape)
387− .background(MaterialTheme.colorScheme.error),
388− contentAlignment = Alignment.Center,
389− ) {
390− Text(
391− if (count > 99) overflow else count.toString(),
392− style = MaterialTheme.typography.labelSmall,
393− color = MaterialTheme.colorScheme.onError,
394− textAlign = TextAlign.Center,
395− )
396− }
397−}
app/src/main/java/soy/iko/opencode/ui/theme/Color.ktnew file+19
@@ -0,0 +1,19 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.ui.graphics.Color
4+
5+// A calm, terminal-adjacent palette. Used when dynamic color isn't available.
6+val Emerald = Color(0xFF3DDC84)
7+val EmeraldDark = Color(0xFF1B8F52)
8+
9+val DarkBackground = Color(0xFF0F1115)
10+val DarkSurface = Color(0xFF171A21)
11+val DarkSurfaceVariant = Color(0xFF232833)
12+val DarkOnSurface = Color(0xFFE6E9EF)
13+val DarkOutline = Color(0xFF3A4150)
14+
15+val LightBackground = Color(0xFFF7F8FA)
16+val LightSurface = Color(0xFFFFFFFF)
17+val LightSurfaceVariant = Color(0xFFEBEDF2)
18+val LightOnSurface = Color(0xFF1A1C1F)
19+val LightOutline = Color(0xFFC4C9D4)
app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktdeleted−19
@@ -1,19 +0,0 @@
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+38−110
@@ -1,8 +1,5 @@
11package soy.iko.opencode.ui.theme
22
3−import android.app.Activity
4−import android.content.Context
5−import android.content.ContextWrapper
63import android.os.Build
74import androidx.compose.foundation.isSystemInDarkTheme
85import androidx.compose.material3.MaterialTheme
@@ -11,88 +8,50 @@ import androidx.compose.material3.dynamicDarkColorScheme
118import androidx.compose.material3.dynamicLightColorScheme
129import androidx.compose.material3.lightColorScheme
1310import androidx.compose.runtime.Composable
14−import androidx.compose.runtime.SideEffect
1511import androidx.compose.ui.graphics.Color
1612import androidx.compose.ui.platform.LocalContext
17−import androidx.compose.ui.platform.LocalView
18−import androidx.core.view.WindowCompat
1913
20−/**
21− * Tokyo Night — a dark, muted blue/purple palette. Every Material 3 color role is set
22− * explicitly (not just primary/secondary/tertiary) so components that read `error`,
23− * `primaryContainer`, `surfaceVariant`, `outline`, etc. match the brand instead of
24− * falling back to stock M3 defaults that clash with it.
25− *
26− * Source palette (Tokyo Night):
27− * bg #1A1B26 fg #C0CAF5 blue #7AA2F7 cyan #7DCFFF
28− * green #9ECE6A magenta #BB9AF7 red #F7768E yellow #E0AF68
29− * comment #565F89 gutter #414868
30− */
3114private val DarkColors = darkColorScheme(
32− primary = Color(0xFF7AA2F7),
33− onPrimary = Color(0xFF112036),
34− primaryContainer = Color(0xFF2A3A5C),
35− onPrimaryContainer = Color(0xFFD6E2FF),
36− secondary = Color(0xFF9ECE6A),
37− onSecondary = Color(0xFF13260A),
38− secondaryContainer = Color(0xFF2B3B1F),
39− onSecondaryContainer = Color(0xFFD2F0A0),
40− tertiary = Color(0xFFBB9AF7),
41− onTertiary = Color(0xFF221036),
42− tertiaryContainer = Color(0xFF3A2A55),
43− onTertiaryContainer = Color(0xFFE6D6FF),
44− error = Color(0xFFF7768E),
45− onError = Color(0xFF370610),
46− errorContainer = Color(0xFF5C1A24),
47− onErrorContainer = Color(0xFFFFD9DE),
48− background = Color(0xFF1A1B26),
49− onBackground = Color(0xFFC0CAF5),
50− surface = Color(0xFF1F2335),
51− onSurface = Color(0xFFC0CAF5),
52− surfaceVariant = Color(0xFF2D3247),
53− onSurfaceVariant = Color(0xFF9AA5CE),
54− surfaceTint = Color(0xFF7AA2F7),
55− inverseSurface = Color(0xFFC0CAF5),
56− inverseOnSurface = Color(0xFF1A1B26),
57− outline = Color(0xFF565F89),
58− outlineVariant = Color(0xFF414868),
59− scrim = Color(0xFF000000),
15+ primary = Emerald,
16+ onPrimary = Color(0xFF0F1115),
17+ primaryContainer = Color(0xFF1A3D2A),
18+ onPrimaryContainer = Color(0xFFA8E6C1),
19+ secondary = EmeraldDark,
20+ onSecondary = Color(0xFFE6E9EF),
21+ secondaryContainer = Color(0xFF1A2A3D),
22+ onSecondaryContainer = Color(0xFFA8C5E6),
23+ tertiary = Color(0xFF6B7280),
24+ onTertiary = Color(0xFFE6E9EF),
25+ tertiaryContainer = Color(0xFF2A2A3D),
26+ onTertiaryContainer = Color(0xFFC5C5E6),
27+ errorContainer = Color(0xFF4D2626),
28+ onErrorContainer = Color(0xFFFFB4B4),
29+ background = DarkBackground,
30+ surface = DarkSurface,
31+ surfaceVariant = DarkSurfaceVariant,
32+ onSurface = DarkOnSurface,
33+ outline = DarkOutline,
6034)
6135
62−/**
63− * Tokyo Night Light — the day-mode counterpart. Pairs the same hue family with a soft
64− * off-white background so the brand identity survives a light setting instead of
65− * snapping to stock M3 purple-grey.
66− */
6736private val LightColors = lightColorScheme(
68− primary = Color(0xFF34548A),
69− onPrimary = Color(0xFFFFFFFF),
70− primaryContainer = Color(0xFFD6E2FF),
71− onPrimaryContainer = Color(0xFF001A41),
72− secondary = Color(0xFF485E30),
73− onSecondary = Color(0xFFFFFFFF),
74− secondaryContainer = Color(0xFFC8F0A0),
75− onSecondaryContainer = Color(0xFF0E2000),
76− tertiary = Color(0xFF5A4A78),
37+ primary = EmeraldDark,
38+ primaryContainer = Color(0xFFD7F5E3),
39+ onPrimaryContainer = Color(0xFF0A3D22),
40+ secondary = Emerald,
41+ onSecondary = Color(0xFF1A1C1F),
42+ secondaryContainer = Color(0xFFD7E8F5),
43+ onSecondaryContainer = Color(0xFF0A2A4D),
44+ tertiary = Color(0xFF6B7280),
7745 onTertiary = Color(0xFFFFFFFF),
78− tertiaryContainer = Color(0xFFE6D6FF),
79− onTertiaryContainer = Color(0xFF180038),
80− error = Color(0xFF9B1C2E),
81− onError = Color(0xFFFFFFFF),
82− errorContainer = Color(0xFFFFD9DE),
83− onErrorContainer = Color(0xFF410008),
84− background = Color(0xFFF6F7FB),
85− onBackground = Color(0xFF1A1B26),
86− surface = Color(0xFFF1F3FA),
87− onSurface = Color(0xFF1A1B26),
88− surfaceVariant = Color(0xFFE0E4F0),
89− onSurfaceVariant = Color(0xFF444B6E),
90− surfaceTint = Color(0xFF34548A),
91− inverseSurface = Color(0xFF2D3247),
92− inverseOnSurface = Color(0xFFEEF0FB),
93− outline = Color(0xFF747DAA),
94− outlineVariant = Color(0xFFC3C8DA),
95− scrim = Color(0xFF000000),
46+ tertiaryContainer = Color(0xFFE8E8F5),
47+ onTertiaryContainer = Color(0xFF2A2A4D),
48+ errorContainer = Color(0xFFFFE0E0),
49+ onErrorContainer = Color(0xFF660000),
50+ background = LightBackground,
51+ surface = LightSurface,
52+ surfaceVariant = LightSurfaceVariant,
53+ onSurface = LightOnSurface,
54+ outline = LightOutline,
9655)
9756
9857@Composable
@@ -108,41 +67,10 @@ fun OpencodeTheme(
10867 darkTheme -> DarkColors
10968 else -> LightColors
11069 }
111− val view = LocalView.current
112− if (!view.isInEditMode) {
113− SideEffect {
114− val window = view.context.findActivity()?.window ?: return@SideEffect
115− WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
116− }
117− }
70+
11871 MaterialTheme(
11972 colorScheme = colorScheme,
120− typography = OpencodeTypography,
121− shapes = OpencodeShapes,
73+ typography = AppTypography,
12274 content = content,
12375 )
12476}
125−
126−/**
127− * Preview swatches for the Tokyo Night palettes, exposed so the Settings theme picker
128− * can show color dots without reaching into the (otherwise private) full color schemes.
129− * Each list is [primary, secondary, tertiary, background].
130− */
131−val DarkPaletteSwatches: List<Color> = listOf(
132− DarkColors.primary,
133− DarkColors.secondary,
134− DarkColors.tertiary,
135− DarkColors.background,
136−)
137−val LightPaletteSwatches: List<Color> = listOf(
138− LightColors.primary,
139− LightColors.secondary,
140− LightColors.tertiary,
141− LightColors.background,
142−)
143−
144−private tailrec fun Context.findActivity(): Activity? = when (this) {
145− is Activity -> this
146− is ContextWrapper -> baseContext.findActivity()
147− else -> null
148−}
app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+15
@@ -0,0 +1,15 @@
1+package soy.iko.opencode.ui.theme
2+
3+import androidx.compose.material3.Typography
4+import androidx.compose.ui.text.TextStyle
5+import androidx.compose.ui.text.font.FontFamily
6+import androidx.compose.ui.unit.sp
7+
8+private val base = Typography()
9+
10+val AppTypography = base.copy(
11+ bodyLarge = base.bodyLarge.copy(lineHeight = 22.sp),
12+ // Slightly monospaced feel for message bodies is applied inline where needed.
13+)
14+
15+val MonoStyle = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktdeleted−87
@@ -1,87 +0,0 @@
1−package soy.iko.opencode.ui.theme
2−
3−import androidx.compose.material3.Typography
4−import androidx.compose.ui.text.TextStyle
5−import androidx.compose.ui.text.font.FontFamily
6−import androidx.compose.ui.text.font.FontWeight
7−import androidx.compose.ui.unit.sp
8−
9−/**
10− * Material 3 typography tuned for chat readability. Keeps the M3 default role names so
11− * every component that reads [MaterialTheme.typography] gets the adjusted styles
12− * automatically; only the values that materially affect readability of a long,
13− * streaming conversation are overridden.
14− *
15− * The chat body (markdown text) uses [bodyLarge]/[bodyMedium] with a slightly larger
16− * line height than the M3 default so token reflow during streaming doesn't feel
17− * cramped, and a comfortable 15–16sp size. Monospace code uses [labelMedium].
18− */
19−val OpencodeTypography = Typography(
20− displaySmall = TextStyle(
21− fontFamily = FontFamily.Default,
22− fontWeight = FontWeight.Normal,
23− fontSize = 30.sp,
24− lineHeight = 40.sp,
25− letterSpacing = 0.sp,
26− ),
27− headlineMedium = TextStyle(
28− fontFamily = FontFamily.Default,
29− fontWeight = FontWeight.SemiBold,
30− fontSize = 26.sp,
31− lineHeight = 34.sp,
32− ),
33− titleLarge = TextStyle(
34− fontFamily = FontFamily.Default,
35− fontWeight = FontWeight.SemiBold,
36− fontSize = 20.sp,
37− lineHeight = 26.sp,
38− ),
39− titleMedium = TextStyle(
40− fontFamily = FontFamily.Default,
41− fontWeight = FontWeight.Medium,
42− fontSize = 16.sp,
43− lineHeight = 22.sp,
44− letterSpacing = 0.1.sp,
45− ),
46− bodyLarge = TextStyle(
47− fontFamily = FontFamily.Default,
48− fontWeight = FontWeight.Normal,
49− fontSize = 16.sp,
50− lineHeight = 24.sp,
51− letterSpacing = 0.3.sp,
52− ),
53− bodyMedium = TextStyle(
54− fontFamily = FontFamily.Default,
55− fontWeight = FontWeight.Normal,
56− fontSize = 14.sp,
57− lineHeight = 21.sp,
58− letterSpacing = 0.2.sp,
59− ),
60− bodySmall = TextStyle(
61− fontFamily = FontFamily.Default,
62− fontWeight = FontWeight.Normal,
63− fontSize = 12.sp,
64− lineHeight = 18.sp,
65− letterSpacing = 0.3.sp,
66− ),
67− labelLarge = TextStyle(
68− fontFamily = FontFamily.Default,
69− fontWeight = FontWeight.Medium,
70− fontSize = 14.sp,
71− lineHeight = 20.sp,
72− letterSpacing = 0.1.sp,
73− ),
74− labelMedium = TextStyle(
75− fontFamily = FontFamily.Monospace,
76− fontWeight = FontWeight.Normal,
77− fontSize = 12.sp,
78− lineHeight = 18.sp,
79− ),
80− labelSmall = TextStyle(
81− fontFamily = FontFamily.Default,
82− fontWeight = FontWeight.Medium,
83− fontSize = 11.sp,
84− lineHeight = 16.sp,
85− letterSpacing = 0.4.sp,
86− ),
87−)
app/src/main/java/soy/iko/opencode/util/Coroutines.ktdeleted−35
@@ -1,35 +0,0 @@
1−package soy.iko.opencode.util
2−
3−import kotlinx.coroutines.CancellationException
4−
5−/**
6− * Like [runCatching] but rethrows [CancellationException] so structured cancellation
7− * works correctly inside coroutines. Using plain [runCatching] in a coroutine swallows
8− * cancellation, causing [Result.onFailure] to run on a dying scope and mutate state
9− * that may never be observed (and defeating cooperative cancellation).
10− */
11−inline fun <T> runCatchingCancellable(block: () -> T): Result<T> = try {
12− Result.success(block())
13−} catch (e: CancellationException) {
14− throw e
15−} catch (e: Exception) {
16− Result.failure(e)
17−}
18−
19−/**
20− * A log-safe exception summary that avoids leaking the request URL (which
21− * [ClientRequestException] embeds in its message and may contain auth or paths).
22− * Use this instead of logging the full exception object when it may originate
23− * from a Ktor HTTP call.
24− */
25−fun safeExceptionSummary(e: Throwable): String {
26− var current: Throwable? = e
27− var hops = 0
28− while (current != null && hops < 8) {
29− val status = (current as? io.ktor.client.plugins.ClientRequestException)?.response?.status?.value
30− if (status != null) return "${current.javaClass.simpleName}($status)"
31− current = current.cause
32− hops++
33− }
34− return e.javaClass.simpleName
35−}
app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−13
@@ -1,13 +0,0 @@
1−<vector xmlns:android="http://schemas.android.com/apk/res/android"
2− android:width="108dp"
3− android:height="108dp"
4− android:viewportWidth="108"
5− android:viewportHeight="108">
6− <!-- A terminal prompt chevron + underscore cursor, the opencode motif. -->
7− <path
8− android:fillColor="#FFFFFF"
9− android:pathData="M34,38 L52,54 L34,70 L40,70 L58,54 L40,38 Z" />
10− <path
11− android:fillColor="#FFFFFF"
12− android:pathData="M62,66 L78,66 L78,72 L62,72 Z" />
13−</vector>
app/src/main/res/mipmap-anydpi/ic_launcher.xmldeleted−6
@@ -1,6 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
3− <background android:drawable="@color/ic_launcher_background" />
4− <foreground android:drawable="@drawable/ic_launcher_foreground" />
5− <monochrome android:drawable="@drawable/ic_launcher_foreground" />
6−</adaptive-icon>
app/src/main/res/values-night/colors.xmlnew file+4
@@ -0,0 +1,4 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <color name="window_background">#0F1115</color>
4+</resources>
app/src/main/res/values-night/themes.xmlnew file+8
@@ -0,0 +1,8 @@
1+<?xml version="1.0" encoding="utf-8"?>
2+<resources>
3+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar">
4+ <item name="android:windowBackground">@color/window_background</item>
5+ <item name="android:statusBarColor">@android:color/transparent</item>
6+ <item name="android:navigationBarColor">@android:color/transparent</item>
7+ </style>
8+</resources>
app/src/main/res/values/colors.xml+1−2
@@ -1,5 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
3− <color name="splash_background">#34548A</color>
4− <color name="ic_launcher_background">#34548A</color>
3+ <color name="window_background">#F7F8FA</color>
54</resources>
app/src/main/res/values/strings.xml−258
@@ -1,262 +1,4 @@
11<?xml version="1.0" encoding="utf-8"?>
22<resources>
33 <string name="app_name">opencode</string>
4− <!-- Non-numeric fallback so a PackageManager failure can\'t show a stale version number. -->
5− <string name="unknown_version">—</string>
6−
7− <!-- Common -->
8− <string name="back">Back</string>
9− <string name="cancel">Cancel</string>
10− <string name="save">Save</string>
11− <string name="delete">Delete</string>
12− <string name="edit">Edit</string>
13− <string name="remove">Remove</string>
14− <string name="copy">Copy</string>
15− <string name="copied">Copied</string>
16− <string name="no_share_app">No app available to share</string>
17− <string name="retry">Retry</string>
18− <string name="refresh">Refresh</string>
19− <string name="settings">Settings</string>
20− <string name="files">Files</string>
21− <string name="share">Share</string>
22− <string name="share_subject">opencode session</string>
23− <string name="send">Send</string>
24− <string name="stop">Stop</string>
25− <string name="rename">Rename</string>
26− <string name="disconnect">Disconnect</string>
27− <string name="loading">Loading…</string>
28−
29− <!-- Theme modes -->
30− <string name="theme_system">System</string>
31− <string name="theme_light">Light</string>
32− <string name="theme_dark">Dark</string>
33−
34− <!-- Connection banner -->
35− <string name="connecting">Connecting…</string>
36− <string name="reconnecting">Reconnecting…</string>
37− <string name="connection_failed">Connection failed — check credentials</string>
38− <string name="reconnect">Reconnect</string>
39− <string name="not_connected">Not connected.</string>
40− <string name="no_servers_to_reconnect">No saved server to reconnect to.</string>
41− <string name="retry_now">Retry now</string>
42−
43− <!-- Server list -->
44− <string name="servers_title">opencode servers</string>
45− <string name="add_server">Add server</string>
46− <string name="edit_server">Edit server</string>
47− <string name="connected">Connected</string>
48− <string name="no_servers_yet">No servers yet</string>
49− <string name="no_servers_hint">Add an opencode serve endpoint to connect.</string>
50− <string name="remove_server_title">Remove server?</string>
51− <string name="remove_server_text">“%1$s” will be removed from your saved servers.</string>
52− <string name="remove_server_active_text">“%1$s” is your current server. Removing it will also disconnect you.</string>
53− <string name="server_auth_short"> • auth</string>
54− <string name="server_auth_basic"> • Basic auth</string>
55− <string name="server_removed">Server removed</string>
56−
57− <!-- Server edit -->
58− <string name="label_optional">Label (optional)</string>
59− <string name="base_url">Base URL</string>
60− <string name="base_url_hint">http://192.168.1.10:4096</string>
61− <string name="invalid_url">Enter a valid http:// or https:// URL.</string>
62− <string name="check_connectivity">Check connectivity</string>
63− <string name="checking">Checking…</string>
64− <string name="auth_required">This server requires authentication.</string>
65− <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
66− <string name="username_optional">Username (optional)</string>
67− <string name="password_optional">Password (optional)</string>
68− <string name="save_and_connect">Save &amp; connect</string>
69− <string name="discard_title">Discard changes?</string>
70− <string name="discard_text">Your edits will be lost.</string>
71− <string name="discard">Discard</string>
72− <string name="keep_editing">Keep editing</string>
73− <string name="show_password">Show password</string>
74− <string name="hide_password">Hide password</string>
75− <string name="test_credentials">Test credentials</string>
76− <string name="testing_credentials">Testing…</string>
77− <string name="credentials_ok">Credentials accepted.</string>
78− <string name="credentials_rejected">Credentials rejected.</string>
79−
80− <!-- Session list -->
81− <string name="new_session">New session</string>
82− <string name="unread">Unread</string>
83− <string name="unread_count">%1$d unread</string>
84− <string name="no_sessions_yet">No sessions yet</string>
85− <string name="no_sessions_hint">Start a conversation with opencode.</string>
86− <string name="search_sessions">Search sessions…</string>
87− <string name="no_sessions_match">No sessions match “%1$s”.</string>
88− <string name="switch_server">Switch server</string>
89− <string name="delete_session_title">Delete session?</string>
90− <string name="delete_session_text">“%1$s” will be permanently deleted. This cannot be undone.</string>
91− <string name="rename_session">Rename session</string>
92− <string name="session_title_hint">Session title</string>
93− <string name="undo">Undo</string>
94− <string name="session_deleted">Session deleted</string>
95− <string name="disconnect_active_title">Disconnect?</string>
96− <string name="disconnect_active_text">An agent run is still active. Disconnecting will stop it.</string>
97−
98− <!-- Chat -->
99− <string name="session">Session</string>
100− <string name="default_model">default model</string>
101− <string name="message_placeholder">Message opencode…</string>
102− <string name="empty_chat_title">Start a conversation</string>
103− <string name="empty_chat_hint">Type a message below to begin.</string>
104− <string name="empty_chat_suggest_1">Explain what this codebase does</string>
105− <string name="empty_chat_suggest_2">Find and fix a bug</string>
106− <string name="empty_chat_suggest_3">Add a feature</string>
107− <string name="draft_chars_remaining">%1$d / %2$d</string>
108− <string name="load_messages_failed">Couldn\'t load messages.</string>
109− <string name="empty_detail_pane">Select a session to view the conversation.</string>
110− <string name="working">working…</string>
111− <string name="latest">Latest</string>
112− <string name="stop_and_exit_title">Stop and exit?</string>
113− <string name="stop_and_exit_text">The agent is still working. Exit anyway?</string>
114− <string name="stop_and_exit">Stop &amp; exit</string>
115− <string name="stay">Stay</string>
116− <string name="commands">Commands</string>
117− <string name="choose_agent">Choose agent</string>
118− <string name="choose_model">Choose model</string>
119− <string name="change_model_agent">Change model and agent</string>
120− <string name="share_conversation">Share conversation</string>
121− <string name="stop_run_title">Stop the run?</string>
122− <string name="stop_run_text">The agent is still working. Stop it now?</string>
123− <string name="session_deleted_msg">This session was deleted on another device.</string>
124− <string name="queued">Queued — will send when the run finishes</string>
125−
126− <!-- Errors (view models) -->
127− <string name="error_generic">Something went wrong.</string>
128− <string name="error_client_status">Request failed (%1$d).</string>
129− <string name="error_agent_reported">The agent reported an error.</string>
130− <string name="error_not_reachable">Could not reach %1$s</string>
131− <string name="error_timeout">The request timed out. Check the connection and try again.</string>
132− <string name="error_server">The server reported an error. Try again later.</string>
133− <string name="error_load_timeout">Loading took too long. Try again.</string>
134− <string name="error_switch_and_restore_failed">Could not connect to the new server (%1$s), and restoring the previous server also failed (%2$s).</string>
135−
136− <!-- Part renderer -->
137− <string name="thinking">Thinking…</string>
138− <string name="thoughts">Thoughts</string>
139− <string name="show_more">Show more</string>
140− <string name="show_less">Show less</string>
141− <string name="tool_completed">Completed</string>
142− <string name="tool_error">Error</string>
143− <string name="tool_running">Running</string>
144− <string name="tool_unknown">Unknown tool state</string>
145− <string name="state_expanded">Expanded</string>
146− <string name="state_collapsed">Collapsed</string>
147−
148− <!-- Permission dialog -->
149− <string name="permission_title">Permission requested</string>
150− <string name="permission_text">Allow this tool to run?</string>
151− <string name="allow_once">Allow once</string>
152− <string name="always_allow">Always allow</string>
153− <string name="reject">Reject</string>
154−
155− <!-- Pickers -->
156− <string name="model">Model</string>
157− <string name="no_models">No models reported by the server.</string>
158− <string name="agent">Agent</string>
159− <string name="no_agents">No agents reported by the server.</string>
160− <string name="default_agent">Default</string>
161− <string name="default_agent_desc">Server default agent</string>
162− <string name="no_commands">No commands reported by the server.</string>
163− <string name="no_commands_match">No commands match "%1$s".</string>
164− <string name="load_failed">Failed to load. Tap retry.</string>
165− <string name="saving">Saving…</string>
166− <string name="image_failed">Failed to load image</string>
167− <string name="search_models">Search models…</string>
168− <string name="search_agents">Search agents…</string>
169− <string name="search_commands">Search commands…</string>
170− <string name="no_models_match">No models match "%1$s".</string>
171− <string name="no_agents_match">No agents match "%1$s".</string>
172−
173− <!-- File browser -->
174− <string name="search_files">Search files…</string>
175− <string name="root">Root</string>
176− <string name="no_matches">No matches.</string>
177− <string name="git_added">Added</string>
178− <string name="git_modified">Modified</string>
179− <string name="git_deleted">Deleted</string>
180− <string name="binary_file">Binary file — preview not supported.</string>
181− <string name="empty_folder">This folder is empty</string>
182− <string name="empty_file">This file is empty</string>
183− <string name="parent_dir">Parent directory</string>
184− <string name="folder">Folder: %1$s</string>
185− <string name="file_label">File: %1$s</string>
186− <string name="file">File</string>
187− <string name="show_diff">Show diff</string>
188− <string name="show_raw">Show raw</string>
189−
190− <!-- Message metadata -->
191− <string name="image">image</string>
192− <string name="tokens_in_out">%1$s in · %2$s out</string>
193− <string name="cost_format_short">$%1$.2f</string>
194− <string name="cost_format_long">$%1$.4f</string>
195−
196− <!-- Settings -->
197− <string name="appearance">Appearance</string>
198− <string name="dynamic_color">Dynamic color</string>
199− <string name="dynamic_color_desc">Use Material You wallpaper colors (Android 12+)</string>
200− <string name="connection">Connection</string>
201− <string name="manage_servers">Manage servers</string>
202− <string name="about">About</string>
203− <string name="about_version">opencode-android %1$s</string>
204− <string name="about_desc">A native Android client for the opencode serve HTTP API.</string>
205−
206− <!-- Diagnostics -->
207− <string name="diagnostics">Diagnostics</string>
208− <string name="diagnostics_desc">Crash reports are stored locally on this device.</string>
209− <string name="crash_reports">Crash reports</string>
210− <string name="no_crash_reports">No crashes recorded.</string>
211− <string name="clear_all">Clear all</string>
212− <string name="clear_all_confirm">Delete all stored crash reports? This cannot be undone.</string>
213− <string name="crash_report">Crash report</string>
214− <string name="close">Close</string>
215− <string name="delete_report_title">Delete crash report?</string>
216− <string name="delete_report_text">This report will be permanently deleted.</string>
217− <string name="report_load_failed">Failed to load crash report.</string>
218− <string name="crash_count_format">%1$d</string>
219− <string name="crash_count_overflow">99+</string>
220− <string name="report_deleted">Crash report deleted</string>
221− <string name="crash_report_share_subject">opencode crash report</string>
222−
223− <!-- Notifications -->
224− <string name="notif_channel_status">Agent activity</string>
225− <string name="notif_channel_status_desc">Shown while an opencode agent is running.</string>
226− <string name="notif_channel_completed">Session completed</string>
227− <string name="notif_channel_completed_desc">Shown when a background session finishes a run.</string>
228− <string name="notif_running_title">opencode</string>
229− <string name="notif_running_text">Agent is working…</string>
230− <string name="notif_completed_title">Session ready</string>
231− <string name="notif_completed_text">opencode replied in “%1$s”.</string>
232−
233− <!-- UX additions -->
234− <string name="tool_input">Input</string>
235− <string name="copy_path">Copy path</string>
236− <string name="path_copied">Path copied</string>
237− <string name="open_file">Open file</string>
238− <string name="unknown_message">Unknown message type</string>
239− <string name="unknown_part">Unsupported content</string>
240− <string name="tool_preview">🔧 %1$s</string>
241− <string name="server_reachable">Server is reachable ✓</string>
242− <string name="suggest_scheme">Add http:// prefix</string>
243− <string name="sort">Sort</string>
244− <string name="sort_recent">Recent activity</string>
245− <string name="sort_title">Title</string>
246− <string name="last_used_never">Not used yet</string>
247− <string name="last_used">Last used %1$s</string>
248− <string name="duplicate_server">Duplicate</string>
249− <string name="find_in_file">Find in file</string>
250− <string name="find_previous">Find previous</string>
251− <string name="find_next">Find next</string>
252− <string name="find_hint">Find…</string>
253− <string name="no_matches_in_file">No matches</string>
254− <string name="match_count">%1$d / %2$d</string>
255− <string name="wrap_lines">Wrap lines</string>
256− <string name="send_on_enter">Send on Enter</string>
257− <string name="send_on_enter_desc">When off, Enter inserts a newline; press Ctrl+Enter to send.</string>
258− <string name="offline">You\'re offline</string>
259− <string name="run_command_title">Run /%1$s?</string>
260− <string name="run_command_text">Run the command “%1$s”?</string>
261− <string name="run">Run</string>
2624</resources>
app/src/main/res/values/themes.xml+9−10
@@ -1,14 +1,13 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<!--
3− The Compose theme (OpencodeTheme) takes over once the UI inflates; this XML
4− theme only governs the window before Compose and hosts the SplashScreen API.
5−-->
62<resources>
7− <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.NoActionBar" />
8−
9− <!-- Shown by core-splashscreen on cold start, then swapped for the theme above. -->
10− <style name="Theme.OpencodeAndroid.Splash" parent="Theme.SplashScreen">
11− <item name="windowSplashScreenBackground">@color/splash_background</item>
12− <item name="postSplashScreenTheme">@style/Theme.OpencodeAndroid</item>
3+ <!--
4+ Minimal platform theme. All real theming is done in Compose (OpencodeTheme);
5+ this only removes the action bar and gives a neutral window background so
6+ there's no flash before the first frame.
7+ -->
8+ <style name="Theme.OpencodeAndroid" parent="android:Theme.Material.Light.NoActionBar">
9+ <item name="android:windowBackground">@color/window_background</item>
10+ <item name="android:statusBarColor">@android:color/transparent</item>
11+ <item name="android:navigationBarColor">@android:color/transparent</item>
1312 </style>
1413</resources>
app/src/main/res/xml/data_extraction_rules.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<data-extraction-rules>
3− <cloud-backup>
4− <exclude domain="root" />
5− <exclude domain="file" />
6− <exclude domain="database" />
7− <exclude domain="sharedpref" />
8− </cloud-backup>
9− <device-transfer>
10− <exclude domain="root" />
11− <exclude domain="file" />
12− <exclude domain="database" />
13− <exclude domain="sharedpref" />
14− </device-transfer>
15−</data-extraction-rules>
app/src/main/res/xml/network_security_config.xmldeleted−15
@@ -1,15 +0,0 @@
1−<?xml version="1.0" encoding="utf-8"?>
2−<!--
3− opencode serve speaks plain HTTP. Android blocks cleartext by default on API 28+,
4− so we permit it. This is a developer tool talking to a self-hosted server on a
5− trusted network; cleartext is permitted broadly here.
6−-->
7−<network-security-config xmlns:tools="http://schemas.android.com/tools">
8− <base-config
9− cleartextTrafficPermitted="true"
10− tools:ignore="InsecureBaseConfiguration">
11− <trust-anchors>
12− <certificates src="system" />
13− </trust-anchors>
14− </base-config>
15−</network-security-config>
app/src/test/java/soy/iko/opencode/M5DecodingTest.ktdeleted−107
@@ -1,107 +0,0 @@
1−package soy.iko.opencode
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.FileContent
5−import soy.iko.opencode.data.model.FileNode
6−import soy.iko.opencode.data.model.PermissionReplied
7−import soy.iko.opencode.data.model.PermissionUpdated
8−import soy.iko.opencode.data.model.ProvidersResponse
9−import soy.iko.opencode.data.model.defaultOption
10−import soy.iko.opencode.data.model.toOptions
11−import soy.iko.opencode.data.network.OpencodeJson
12−import kotlinx.serialization.builtins.ListSerializer
13−import kotlinx.serialization.builtins.serializer
14−import org.junit.Assert.assertEquals
15−import org.junit.Assert.assertNull
16−import org.junit.Assert.assertTrue
17−import org.junit.Test
18−
19−class M5DecodingTest {
20−
21− private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
22− OpencodeJson.decodeFromString(s, json)
23−
24− @Test
25− fun decodesPermissionUpdatedWithArrayPattern() {
26− val json = """
27− { "type": "permission.updated", "properties": {
28− "id": "perm_1", "sessionID": "ses_1", "type": "bash",
29− "pattern": ["rm -rf *", "git push"], "title": "Run bash",
30− "metadata": { "cwd": "/tmp" }, "time": { "created": 123 } } }
31− """.trimIndent()
32− val event = decode(BusEvent.serializer(), json) as PermissionUpdated
33− assertEquals("perm_1", event.properties.id)
34− assertEquals("bash", event.properties.type)
35− assertEquals("rm -rf *, git push", event.properties.patternText)
36− }
37−
38− @Test
39− fun decodesPermissionUpdatedWithStringPattern() {
40− val json = """
41− { "type": "permission.updated", "properties": {
42− "id": "perm_2", "sessionID": "ses_1", "type": "edit", "pattern": "src/Main.kt" } }
43− """.trimIndent()
44− val event = decode(BusEvent.serializer(), json) as PermissionUpdated
45− assertEquals("src/Main.kt", event.properties.patternText)
46− }
47−
48− @Test
49− fun decodesPermissionReplied() {
50− val json = """{"type":"permission.replied","properties":{"sessionID":"s","permissionID":"perm_1","response":"once"}}"""
51− val event = decode(BusEvent.serializer(), json) as PermissionReplied
52− assertEquals("perm_1", event.properties.permissionID)
53− assertEquals("once", event.properties.response)
54− }
55−
56− @Test
57− fun decodesRichProvidersAndFlattens() {
58− // Mirrors the real (richer) /config/providers shape; unknown fields must be ignored.
59− val json = """
60− {
61− "providers": [
62− { "id": "anthropic", "name": "Anthropic", "source": "env", "env": ["ANTHROPIC_API_KEY"],
63− "options": {},
64− "models": {
65− "claude-sonnet": { "id": "claude-sonnet", "providerID": "anthropic", "name": "Claude Sonnet",
66− "capabilities": { "reasoning": true }, "cost": { "input": 3, "output": 15 },
67− "limit": { "context": 200000, "output": 8192 }, "status": "active" }
68− } }
69− ],
70− "default": { "anthropic": "claude-sonnet" }
71− }
72− """.trimIndent()
73− val resp = decode(ProvidersResponse.serializer(), json)
74− val options = resp.toOptions()
75− assertEquals(1, options.size)
76− assertEquals("Claude Sonnet", options.single().modelLabel)
77− assertEquals("claude-sonnet", resp.defaultOption(options)?.modelID)
78− }
79−
80− @Test
81− fun decodesFileContentAndDirectoryListing() {
82− val content = decode(FileContent.serializer(), """{"type":"text","content":"hello\nworld"}""")
83− assertEquals("hello\nworld", content.content)
84− assertTrue(!content.isBinary)
85−
86− val nodes = decode(
87− ListSerializer(FileNode.serializer()),
88− """[{"name":"src","path":"src","type":"directory"},{"name":"a.kt","path":"src/a.kt","type":"file"}]""",
89− )
90− assertEquals(2, nodes.size)
91− assertTrue(nodes[0].isDirectory)
92− assertTrue(!nodes[1].isDirectory)
93− }
94−
95− @Test
96− fun decodesFindFilesAsStringArray() {
97− val paths = decode(ListSerializer(String.serializer()), """["src/a.kt","src/b.kt"]""")
98− assertEquals(listOf("src/a.kt", "src/b.kt"), paths)
99− }
100−
101− @Test
102− fun binaryFileContentIsFlagged() {
103− val content = decode(FileContent.serializer(), """{"type":"binary","content":"AAAA","encoding":"base64","mimeType":"image/png"}""")
104− assertTrue(content.isBinary)
105− assertNull(content.diff)
106− }
107−}
app/src/test/java/soy/iko/opencode/M6Test.ktdeleted−102
@@ -1,102 +0,0 @@
1−package soy.iko.opencode
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Assert.assertFalse
5−import org.junit.Assert.assertTrue
6−import org.junit.Test
7−import soy.iko.opencode.data.model.Agent
8−import soy.iko.opencode.data.model.Command
9−import soy.iko.opencode.data.network.OpencodeJson
10−import soy.iko.opencode.ui.components.DiffLine
11−import soy.iko.opencode.ui.components.looksLikeDiff
12−import soy.iko.opencode.ui.components.parseDiff
13−import kotlinx.serialization.builtins.ListSerializer
14−
15−class M6Test {
16−
17− private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
18− OpencodeJson.decodeFromString(s, json)
19−
20− // --- Agent / Command decoding ---
21−
22− @Test
23− fun decodesAgentList() {
24− val json = """
25− [
26− { "name": "build", "description": "Build and implement code", "mode": "primary",
27− "builtIn": true, "tools": {}, "options": {} },
28− { "name": "custom", "mode": "subagent", "builtIn": false }
29− ]
30− """.trimIndent()
31− val agents = decode(ListSerializer(Agent.serializer()), json)
32− assertEquals(2, agents.size)
33− assertEquals("build", agents[0].name)
34− assertTrue(agents[0].isPrimary)
35− assertTrue(agents[0].builtIn)
36− assertEquals("Build and implement code", agents[0].displayDescription)
37− assertFalse(agents[1].isPrimary)
38− assertEquals("Custom agent", agents[1].displayDescription)
39− }
40−
41− @Test
42− fun decodesCommandList() {
43− val json = """
44− [
45− { "name": "compact", "description": "Compact the session",
46− "template": "Summarize the conversation so far", "subtask": false },
47− { "name": "test", "agent": "build", "template": "Run the tests" }
48− ]
49− """.trimIndent()
50− val commands = decode(ListSerializer(Command.serializer()), json)
51− assertEquals(2, commands.size)
52− assertEquals("compact", commands[0].name)
53− assertEquals("Compact the session", commands[0].displayDescription)
54− assertEquals("build", commands[1].agent)
55− }
56−
57− // --- Diff parsing ---
58−
59− @Test
60− fun parsesUnifiedDiff() {
61− val diff = """
62− --- a/src/Main.kt
63− +++ b/src/Main.kt
64− @@ -10,3 +10,4 @@
65− fun main() {
66− - println("old")
67− + println("new")
68− + // added
69− }
70− """.trimIndent()
71− val lines = parseDiff(diff)
72− assertTrue(lines[0] is DiffLine.FileHeader)
73− assertTrue(lines[1] is DiffLine.FileHeader)
74− assertTrue(lines[2] is DiffLine.Hunk)
75− assertTrue(lines[3] is DiffLine.Context)
76− assertTrue(lines[4] is DiffLine.Remove)
77− assertTrue(lines[5] is DiffLine.Add)
78− assertTrue(lines[6] is DiffLine.Add)
79− assertTrue(lines[7] is DiffLine.Context)
80− assertEquals(" println(\"old\")", (lines[4] as DiffLine.Remove).text)
81− assertEquals(" println(\"new\")", (lines[5] as DiffLine.Add).text)
82− }
83−
84− @Test
85− fun looksLikeDiffDetectsRealDiffs() {
86− val realDiff = """
87− @@ -1,2 +1,2 @@
88− -old line
89− +new line
90− """.trimIndent()
91− assertTrue(looksLikeDiff(realDiff))
92−
93− val plainText = "This is just a regular message from the assistant."
94− assertFalse(looksLikeDiff(plainText))
95− }
96−
97− @Test
98− fun looksLikeDiffIgnoresStrayMarkers() {
99− assertFalse(looksLikeDiff("not a diff at all"))
100− assertFalse(looksLikeDiff("just some + text - here"))
101− }
102−}
app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktdeleted−88
@@ -1,88 +0,0 @@
1−package soy.iko.opencode
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.BusEvent
5−import soy.iko.opencode.data.model.MessagePartUpdated
6−import soy.iko.opencode.data.model.MessageWithParts
7−import soy.iko.opencode.data.model.Part
8−import soy.iko.opencode.data.model.SessionIdle
9−import soy.iko.opencode.data.model.TextPart
10−import soy.iko.opencode.data.model.ToolCompleted
11−import soy.iko.opencode.data.model.ToolPart
12−import soy.iko.opencode.data.model.UnknownEvent
13−import soy.iko.opencode.data.model.UnknownPart
14−import soy.iko.opencode.data.network.OpencodeJson
15−import org.junit.Assert.assertEquals
16−import org.junit.Assert.assertTrue
17−import org.junit.Test
18−
19−class OpencodeJsonTest {
20−
21− private fun <T> decode(serializer: kotlinx.serialization.KSerializer<T>, json: String): T =
22− OpencodeJson.decodeFromString(serializer, json)
23−
24− @Test
25− fun decodesPromptResponseWithPartsAndTokens() {
26− val json = """
27− {
28− "info": {
29− "role": "assistant", "id": "msg_1", "sessionID": "ses_1",
30− "modelID": "claude-sonnet", "providerID": "anthropic",
31− "cost": 0.012,
32− "tokens": { "input": 10, "output": 20, "reasoning": 0, "cache": { "read": 1, "write": 2 } },
33− "time": { "created": 100, "completed": 200 }
34− },
35− "parts": [
36− { "type": "text", "id": "prt_1", "messageID": "msg_1", "sessionID": "ses_1", "text": "Hello" }
37− ]
38− }
39− """.trimIndent()
40−
41− val msg = decode(MessageWithParts.serializer(), json)
42− val info = msg.info as AssistantMessage
43− assertEquals("anthropic", info.providerID)
44− assertEquals(20L, info.tokens?.output)
45− assertTrue(info.isComplete)
46− assertEquals("Hello", (msg.parts.single() as TextPart).text)
47− }
48−
49− @Test
50− fun decodesToolPartStateMachine() {
51− val json = """
52− { "type": "tool", "id": "prt_2", "messageID": "msg_1", "callID": "c1", "tool": "bash",
53− "state": { "status": "completed", "output": "done", "title": "ran bash" } }
54− """.trimIndent()
55− val part = decode(Part.serializer(), json) as ToolPart
56− assertEquals("bash", part.tool)
57− assertEquals("done", (part.state as ToolCompleted).output)
58− }
59−
60− @Test
61− fun decodesPartUpdatedEvent() {
62− val json = """
63− { "type": "message.part.updated",
64− "properties": { "part": { "type": "text", "id": "prt_1", "messageID": "msg_1", "text": "Hel" } } }
65− """.trimIndent()
66− val event = decode(BusEvent.serializer(), json) as MessagePartUpdated
67− assertEquals("Hel", (event.properties.part as TextPart).text)
68− }
69−
70− @Test
71− fun decodesSessionIdleEvent() {
72− val event = decode(BusEvent.serializer(), """{"type":"session.idle","properties":{"sessionID":"ses_1"}}""")
73− assertTrue(event is SessionIdle)
74− assertEquals("ses_1", (event as SessionIdle).properties.sessionID)
75− }
76−
77− @Test
78− fun unknownEventTypeFallsBackInsteadOfThrowing() {
79− val event = decode(BusEvent.serializer(), """{"type":"some.future.event","properties":{"x":1}}""")
80− assertEquals(UnknownEvent, event)
81− }
82−
83− @Test
84− fun unknownPartTypeFallsBackInsteadOfThrowing() {
85− val part = decode(Part.serializer(), """{"type":"future-part","id":"p9","foo":true}""")
86− assertTrue(part is UnknownPart)
87− }
88−}
app/src/test/java/soy/iko/opencode/UxTest.ktdeleted−91
@@ -1,91 +0,0 @@
1−package soy.iko.opencode
2−
3−import org.junit.Assert.assertEquals
4−import org.junit.Assert.assertTrue
5−import org.junit.Test
6−import soy.iko.opencode.data.model.Session
7−import soy.iko.opencode.data.model.TimeInfo
8−import soy.iko.opencode.ui.components.relativeTime
9−import soy.iko.opencode.ui.session.SessionListState
10−import java.util.concurrent.TimeUnit
11−
12−class UxTest {
13−
14− // --- relativeTime ---
15−
16− @Test
17− fun relativeTimeHandlesNullAndZero() {
18− assertEquals("", relativeTime(null))
19− assertEquals("", relativeTime(0))
20− assertEquals("", relativeTime(-1))
21− }
22−
23− @Test
24− fun relativeTimeFormatsMinutes() {
25− val now = System.currentTimeMillis()
26− assertEquals("now", relativeTime(now))
27− assertEquals("5m", relativeTime(now - TimeUnit.MINUTES.toMillis(5)))
28− }
29−
30− @Test
31− fun relativeTimeFormatsHoursAndDays() {
32− val now = System.currentTimeMillis()
33− assertEquals("3h", relativeTime(now - TimeUnit.HOURS.toMillis(3)))
34− assertEquals("2d", relativeTime(now - TimeUnit.DAYS.toMillis(2)))
35− assertEquals("1w", relativeTime(now - TimeUnit.DAYS.toMillis(7)))
36− }
37−
38− // --- Session search/filter ---
39−
40− private fun session(id: String, title: String) =
41− Session(id = id, title = title)
42−
43− @Test
44− fun emptyQueryReturnsAllSessions() {
45− val state = SessionListState(
46− sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
47− )
48− assertEquals(2, state.filtered.size)
49− }
50−
51− @Test
52− fun queryFiltersByTitle() {
53− val state = SessionListState(
54− sessions = listOf(session("1", "Refactor tests"), session("2", "Add docs")),
55− query = "refactor",
56− )
57− val result = state.filtered
58− assertEquals(1, result.size)
59− assertEquals("1", result.first().id)
60− }
61−
62− @Test
63− fun queryFiltersByPreviewText() {
64− val state = SessionListState(
65− sessions = listOf(session("1", "Alpha"), session("2", "Beta")),
66− previews = mapOf("2" to "discuss the database migration"),
67− query = "database",
68− )
69− val result = state.filtered
70− assertEquals(1, result.size)
71− assertEquals("2", result.first().id)
72− }
73−
74− @Test
75− fun queryIsCaseInsensitive() {
76− val state = SessionListState(
77− sessions = listOf(session("1", "Build Pipeline")),
78− query = "build",
79− )
80− assertEquals(1, state.filtered.size)
81− }
82−
83− @Test
84− fun noMatchReturnsEmpty() {
85− val state = SessionListState(
86− sessions = listOf(session("1", "Alpha")),
87− query = "zzz",
88− )
89− assertTrue(state.filtered.isEmpty())
90− }
91−}
app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktnew file+69
@@ -0,0 +1,69 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Dispatchers
5+import kotlinx.coroutines.test.runTest
6+import okhttp3.OkHttpClient
7+import okhttp3.Interceptor
8+import org.junit.Assert.assertEquals
9+import org.junit.Assert.assertTrue
10+import org.junit.Test
11+import java.io.IOException
12+
13+/**
14+ * Covers two related connect-screen bugs:
15+ * - the URL field must stay editable — normalizing per keystroke made it
16+ * "impossible to input" (it ate the "://" as you typed);
17+ * - a scheme-less URL must still be normalized to http:// on connect, and the
18+ * HTTP client must never crash on a malformed URL.
19+ */
20+class ConnectUrlTest {
21+ private fun core() = OpencodeCore(CoroutineScope(Dispatchers.Unconfined))
22+
23+ /** A client that never touches the network, so Connect's probe can't hang. */
24+ private fun offlineCore(): OpencodeCore {
25+ val offline = OkHttpClient.Builder()
26+ .addInterceptor(Interceptor { throw IOException("offline in test") })
27+ .build()
28+ return OpencodeCore(CoroutineScope(Dispatchers.Unconfined), HttpClient(offline))
29+ }
30+
31+ @Test
32+ fun `typing preserves raw input verbatim`() {
33+ val core = core()
34+ // Mid-scheme text the user is still typing must not be rewritten.
35+ core.dispatch(Event.ServerUrlChanged("http:/"))
36+ assertEquals("http:/", core.view.value.serverUrl)
37+ }
38+
39+ @Test
40+ fun `connect normalizes a scheme-less url to http`() {
41+ val core = offlineCore()
42+ core.dispatch(Event.ServerUrlChanged("192.168.1.10:4096"))
43+ assertEquals("192.168.1.10:4096", core.view.value.serverUrl) // raw while typing
44+ core.dispatch(Event.Connect)
45+ assertEquals("http://192.168.1.10:4096", core.view.value.serverUrl) // normalized on connect
46+ }
47+
48+ @Test
49+ fun `connect trims and keeps an explicit scheme`() {
50+ val core = offlineCore()
51+ core.dispatch(Event.ServerUrlChanged(" https://host:4096/ "))
52+ core.dispatch(Event.Connect)
53+ assertEquals("https://host:4096", core.view.value.serverUrl)
54+ }
55+
56+ @Test
57+ fun `connect on a blank url reports an error`() {
58+ val core = core()
59+ core.dispatch(Event.ServerUrlChanged(" "))
60+ core.dispatch(Event.Connect)
61+ assertEquals("Enter a server URL", core.view.value.error)
62+ }
63+
64+ @Test
65+ fun `http client returns failure rather than throwing on an unparseable url`() = runTest {
66+ val result = HttpClient().get("not a valid url", auth = null)
67+ assertTrue("expected a Result.failure, got $result", result.isFailure)
68+ }
69+}
app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktdeleted−37

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktdeleted−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.ymldeleted−60

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+27−17

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

gradle.properties+5−2

This diff is too big to show here.

gradle/libs.versions.toml+25−50

This diff is too big to show here.

renovate.jsondeleted−32

This diff is too big to show here.

settings.gradle.kts+3

This diff is too big to show here.

shared/Cargo.locknew file+1358

This diff is too big to show here.

shared/Cargo.tomlnew file+31

This diff is too big to show here.

shared/src/app.rsnew file+1051

This diff is too big to show here.

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

This diff is too big to show here.

shared/src/lib.rsnew file+17

This diff is too big to show here.

shared/src/protocol.rsnew file+248

This diff is too big to show here.