From d8bb1699a82ef76a8ca69697e85d744ecbd25ec5 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 11 Sep 2026 01:18:24 -0400 Subject: [PATCH] Make the Rust client the sole app --- .cargo/config.toml | 10 - .dev-updater.ron | 32 +- .gitignore | 19 +- AGENTS.md | 871 +----- app-rust/android-project/app/build.gradle | 90 - .../app/src/main/AndroidManifest.xml | 68 - app-rust/build-apk.sh | 120 - app-rust/src/lib.rs | 20 - app-rust/src/shell/jcall.rs | 99 - app-rust/src/shell/mod.rs | 95 - app-rust/src/shell/notify.rs | 534 ---- app-rust/src/shell/settings.rs | 133 - app-rust/src/shell/share.rs | 157 -- {app-rust => app}/.gitignore | 5 +- {app-rust => app}/Cargo.lock | 1 - {app-rust => app}/Cargo.toml | 4 +- app/android-env.sh | 55 - app/android-project/app/build.gradle | 63 + .../app/src/main/AndroidManifest.xml | 42 + .../dev/iris/android/demo/DevLogProvider.java | 0 .../java/dev/iris/android/demo/IrisView.java | 0 .../dev/iris/android/demo/MainActivity.java | 0 .../android/rustview/RustInputConnection.java | 0 .../linebender/android/rustview/RustView.java | 0 .../android-project/build.gradle | 0 .../android-project/settings.gradle | 0 app/androidApp/build.gradle.kts | 226 -- app/androidApp/src/main/AndroidManifest.xml | 123 - .../src/main/kotlin/com/example/aiapp/Ansi.kt | 311 -- .../src/main/kotlin/com/example/aiapp/Api.kt | 1342 --------- .../main/kotlin/com/example/aiapp/AppRoot.kt | 297 -- .../kotlin/com/example/aiapp/AskQuestion.kt | 392 --- .../kotlin/com/example/aiapp/Attachment.kt | 65 - .../kotlin/com/example/aiapp/Attachments.kt | 171 -- .../kotlin/com/example/aiapp/BenchFixture.kt | 108 - .../kotlin/com/example/aiapp/BenchNetwork.kt | 181 -- .../main/kotlin/com/example/aiapp/BenchRun.kt | 325 --- .../main/kotlin/com/example/aiapp/Bubble.kt | 51 - .../main/kotlin/com/example/aiapp/BusyItem.kt | 95 - .../main/kotlin/com/example/aiapp/Chevron.kt | 69 - .../kotlin/com/example/aiapp/CodeFence.kt | 220 -- .../main/kotlin/com/example/aiapp/Commands.kt | 151 - .../kotlin/com/example/aiapp/Compaction.kt | 62 - .../main/kotlin/com/example/aiapp/CrashLog.kt | 63 - .../kotlin/com/example/aiapp/DebugStats.kt | 184 -- .../main/kotlin/com/example/aiapp/Dividers.kt | 90 - .../main/kotlin/com/example/aiapp/Drafts.kt | 33 - .../kotlin/com/example/aiapp/Durations.kt | 37 - .../kotlin/com/example/aiapp/EventStream.kt | 44 - .../main/kotlin/com/example/aiapp/Events.kt | 317 --- .../kotlin/com/example/aiapp/FileEditor.kt | 162 -- .../kotlin/com/example/aiapp/FileLines.kt | 122 - .../kotlin/com/example/aiapp/FileViewer.kt | 242 -- .../kotlin/com/example/aiapp/FilesScreen.kt | 688 ----- .../kotlin/com/example/aiapp/FrameStats.kt | 204 -- .../kotlin/com/example/aiapp/Highlighter.kt | 316 --- .../kotlin/com/example/aiapp/ImportScreen.kt | 667 ----- .../com/example/aiapp/ImportableStream.kt | 25 - .../kotlin/com/example/aiapp/Languages.kt | 453 --- .../kotlin/com/example/aiapp/LoadState.kt | 28 - .../kotlin/com/example/aiapp/MainActivity.kt | 246 -- .../kotlin/com/example/aiapp/MainScreen.kt | 153 - .../main/kotlin/com/example/aiapp/Markdown.kt | 667 ----- .../kotlin/com/example/aiapp/MarkdownLinks.kt | 320 --- .../com/example/aiapp/MarkdownPieces.kt | 244 -- .../com/example/aiapp/MarkdownSyntax.kt | 450 --- .../kotlin/com/example/aiapp/MemoryNote.kt | 162 -- .../kotlin/com/example/aiapp/ModelName.kt | 32 - .../kotlin/com/example/aiapp/ModelsScreen.kt | 374 --- .../kotlin/com/example/aiapp/NerdIcons.kt | 270 -- .../kotlin/com/example/aiapp/Notifications.kt | 352 --- .../kotlin/com/example/aiapp/PeerMessage.kt | 141 - .../com/example/aiapp/PendingAttachments.kt | 162 -- .../kotlin/com/example/aiapp/PinnedCert.kt | 16 - .../main/kotlin/com/example/aiapp/RawBlock.kt | 35 - .../com/example/aiapp/ResetCountdown.kt | 64 - .../kotlin/com/example/aiapp/ScrollAnchor.kt | 53 - .../kotlin/com/example/aiapp/ServerConfig.kt | 27 - .../kotlin/com/example/aiapp/SessionAlerts.kt | 176 -- .../kotlin/com/example/aiapp/SessionImage.kt | 265 -- .../com/example/aiapp/SessionListScreen.kt | 530 ---- .../kotlin/com/example/aiapp/SessionScreen.kt | 2502 ----------------- .../example/aiapp/SessionSettingsDialog.kt | 562 ---- .../com/example/aiapp/SessionUsageBar.kt | 273 -- .../com/example/aiapp/SettingsScreen.kt | 197 -- .../kotlin/com/example/aiapp/SetupsScreen.kt | 410 --- .../main/kotlin/com/example/aiapp/Share.kt | 44 - .../main/kotlin/com/example/aiapp/Sizes.kt | 20 - .../kotlin/com/example/aiapp/SpawnScreen.kt | 386 --- .../src/main/kotlin/com/example/aiapp/Sse.kt | 100 - .../main/kotlin/com/example/aiapp/TextCap.kt | 108 - .../main/kotlin/com/example/aiapp/Theme.kt | 336 --- .../kotlin/com/example/aiapp/ToolInput.kt | 219 -- .../main/kotlin/com/example/aiapp/ToolRows.kt | 494 ---- .../com/example/aiapp/TranscriptAddress.kt | 27 - .../com/example/aiapp/TranscriptCache.kt | 596 ---- .../com/example/aiapp/TranscriptItems.kt | 547 ---- .../com/example/aiapp/TranscriptList.kt | 128 - .../com/example/aiapp/TranscriptSource.kt | 177 -- .../com/example/aiapp/TranscriptUnits.kt | 478 ---- .../kotlin/com/example/aiapp/UniqueItems.kt | 40 - .../kotlin/com/example/aiapp/UsageDialog.kt | 206 -- .../src/main/res/font/nerd_icons.ttf | Bin 2860 -> 0 bytes .../src/main/res/values/strings.xml | 6 - .../test/kotlin/com/example/aiapp/AnsiTest.kt | 87 - .../kotlin/com/example/aiapp/DurationsTest.kt | 56 - .../kotlin/com/example/aiapp/FileLinesTest.kt | 87 - .../com/example/aiapp/HighlighterTest.kt | 405 --- .../kotlin/com/example/aiapp/LimitRowTest.kt | 32 - .../kotlin/com/example/aiapp/TextCapTest.kt | 63 - .../com/example/aiapp/TranscriptCacheTest.kt | 310 -- app/bench-fixture/README.md | 24 +- app/bench-fixture/generate.py | 9 +- app/bench-lib.sh | 57 - app/build-apk.sh | 251 +- app/build-icon-font.sh | 83 - app/build.gradle.kts | 6 - app/debug-transcript.sh | 149 - {app-rust => app}/examples/phone.rs | 0 {app-rust => app}/examples/transcript.rs | 0 app/gradle.properties | 7 - app/gradle/libs.versions.toml | 97 - app/gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 0 bytes app/gradle/wrapper/gradle-wrapper.properties | 7 - app/gradlew | 248 -- app/gradlew.bat | 82 - app/iris-scroll.sh | 34 - app/run-android.sh | 48 - {app-rust => app}/run-bench.sh | 8 +- {app-rust => app}/rust-toolchain.toml | 0 app/settings.gradle.kts | 30 - app/shellApp/build.gradle.kts | 163 -- app/shellApp/src/main/AndroidManifest.xml | 62 - .../com/example/aiapp/shell/MainActivity.java | 50 - .../aiapp/shell/NotificationService.java | 45 - {app-rust => app}/src/android/app_log.rs | 0 {app-rust => app}/src/android/bench_client.rs | 2 +- {app-rust => app}/src/android/bench_jni.rs | 0 {app-rust => app}/src/android/devlog.rs | 0 {app-rust => app}/src/android/enrollment.rs | 0 {app-rust => app}/src/android/mod.rs | 0 .../src/android/transcript_client.rs | 0 {app-rust => app}/src/bin_desktop.rs | 0 {app-rust => app}/src/client/ansi.rs | 3 +- {app-rust => app}/src/client/api.rs | 0 {app-rust => app}/src/client/config.rs | 7 +- {app-rust => app}/src/client/durations.rs | 0 {app-rust => app}/src/client/event_stream.rs | 2 +- .../src/client/highlight/languages.rs | 0 .../src/client/highlight/markdown.rs | 0 {app-rust => app}/src/client/highlight/mod.rs | 7 +- {app-rust => app}/src/client/log_ring.rs | 0 .../src/client/markdown_blocks.rs | 0 {app-rust => app}/src/client/mod.rs | 0 {app-rust => app}/src/client/notifications.rs | 0 {app-rust => app}/src/client/sse.rs | 0 {app-rust => app}/src/client/text_cap.rs | 0 {app-rust => app}/src/client/tool_summary.rs | 0 .../src/client/transcript_cache.rs | 0 .../src/client/transcript_fold.rs | 6 +- .../src/client/transcript_source.rs | 8 +- {app-rust => app}/src/desktop/app.rs | 0 {app-rust => app}/src/desktop/config.rs | 0 {app-rust => app}/src/desktop/mod.rs | 0 {app-rust => app}/src/desktop/startup.rs | 0 app/src/lib.rs | 10 + {app-rust => app}/src/ui/composer.rs | 12 +- {app-rust => app}/src/ui/fixture.rs | 2 +- {app-rust => app}/src/ui/markdown.rs | 0 {app-rust => app}/src/ui/mod.rs | 5 +- {app-rust => app}/src/ui/row.rs | 9 +- {app-rust => app}/src/ui/tap.rs | 0 {app-rust => app}/src/ui/theme.rs | 0 {app-rust => app}/src/ui/tool.rs | 124 +- app/stream-bench.sh | 94 - {app-rust => app}/tests/catch_a_fling.rs | 0 {app-rust => app}/tests/fence_fling.rs | 0 {app-rust => app}/tests/gesture_cancel.rs | 0 .../tests/input_log_roundtrip.rs | 0 {app-rust => app}/tests/phone_screen.rs | 0 {app-rust => app}/tests/top_edge.rs | 0 {app-rust => app}/touch/flick-120hz.touch | 0 {app-rust => app}/touch/flick-cancelled.touch | 0 {app-rust => app}/touch/long-press.touch | 0 {app-rust => app}/touch/tap.touch | 0 app/trace-draw.sh | 129 - app/transcript-bench.sh | 65 - app/ui-sandbox.sh | 5 +- docs/CLIENT_CORE.md | 6 +- docs/IRIS_TODO.md | 8 +- docs/PLAN.md | 25 +- docs/RUST.md | 1104 ++------ docs/SCROLL.md | 2 +- iris/core/build-icon-font.sh | 10 +- iris/run-headless.sh | 10 +- iris/src/android/access.rs | 2 +- iris/src/android/view.rs | 25 +- iris/src/lib.rs | 22 +- iris/src/{ => rsc}/attr.rs | 0 iris/src/{ => rsc}/event.rs | 2 +- iris/src/{runtime.rs => rsc/mod.rs} | 19 + iris/src/{ => rsc}/overlay.rs | 0 iris/src/{ => rsc}/sense.rs | 0 iris/src/{ => rsc}/sense_tests.rs | 0 iris/src/{ => rsc}/state.rs | 5 +- iris/src/{ => rsc}/task.rs | 2 +- iris/src/widget/event.rs | 0 iris/src/widget/{position => layout}/align.rs | 0 iris/src/widget/{position => layout}/layer.rs | 0 .../widget/{position => layout}/lazy_span.rs | 0 .../widget/{position => layout}/max_size.rs | 0 iris/src/widget/{position => layout}/mod.rs | 0 .../src/widget/{position => layout}/offset.rs | 0 iris/src/widget/{position => layout}/pad.rs | 0 .../{position => layout}/scroll_area.rs | 0 .../widget/{position => layout}/scrollable.rs | 0 iris/src/widget/{position => layout}/sized.rs | 0 iris/src/widget/{position => layout}/span.rs | 0 iris/src/widget/{position => layout}/stack.rs | 0 iris/src/widget/mod.rs | 4 +- scripts/rigs/ui-profile/Cargo.toml | 2 +- .../rigs/ui-profile/tests/frame_profile.rs | 2 +- scripts/run-tests.sh | 2 +- scripts/xtask/Cargo.lock | 7 - scripts/xtask/Cargo.toml | 9 - scripts/xtask/src/apk.rs | 458 --- scripts/xtask/src/keystore.rs | 241 -- scripts/xtask/src/main.rs | 93 - scripts/xtask/src/sdk.rs | 130 - server/src/main.rs | 5 +- 230 files changed, 762 insertions(+), 27300 deletions(-) delete mode 100644 .cargo/config.toml delete mode 100644 app-rust/android-project/app/build.gradle delete mode 100644 app-rust/android-project/app/src/main/AndroidManifest.xml delete mode 100755 app-rust/build-apk.sh delete mode 100644 app-rust/src/lib.rs delete mode 100644 app-rust/src/shell/jcall.rs delete mode 100644 app-rust/src/shell/mod.rs delete mode 100644 app-rust/src/shell/notify.rs delete mode 100644 app-rust/src/shell/settings.rs delete mode 100644 app-rust/src/shell/share.rs rename {app-rust => app}/.gitignore (51%) rename {app-rust => app}/Cargo.lock (99%) rename {app-rust => app}/Cargo.toml (95%) delete mode 100755 app/android-env.sh create mode 100644 app/android-project/app/build.gradle create mode 100644 app/android-project/app/src/main/AndroidManifest.xml rename {app-rust => app}/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java (100%) rename {app-rust => app}/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java (100%) rename {app-rust => app}/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java (100%) rename {app-rust => app}/android-project/app/src/main/java/org/linebender/android/rustview/RustInputConnection.java (100%) rename {app-rust => app}/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java (100%) rename {app-rust => app}/android-project/build.gradle (100%) rename {app-rust => app}/android-project/settings.gradle (100%) delete mode 100644 app/androidApp/build.gradle.kts delete mode 100644 app/androidApp/src/main/AndroidManifest.xml delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt delete mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt delete mode 100644 app/androidApp/src/main/res/font/nerd_icons.ttf delete mode 100644 app/androidApp/src/main/res/values/strings.xml delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/AnsiTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/DurationsTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/LimitRowTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt delete mode 100644 app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt delete mode 100644 app/bench-lib.sh delete mode 100755 app/build-icon-font.sh delete mode 100644 app/build.gradle.kts delete mode 100755 app/debug-transcript.sh rename {app-rust => app}/examples/phone.rs (100%) rename {app-rust => app}/examples/transcript.rs (100%) delete mode 100644 app/gradle.properties delete mode 100644 app/gradle/libs.versions.toml delete mode 100644 app/gradle/wrapper/gradle-wrapper.jar delete mode 100644 app/gradle/wrapper/gradle-wrapper.properties delete mode 100755 app/gradlew delete mode 100644 app/gradlew.bat delete mode 100755 app/iris-scroll.sh delete mode 100755 app/run-android.sh rename {app-rust => app}/run-bench.sh (93%) rename {app-rust => app}/rust-toolchain.toml (100%) delete mode 100644 app/settings.gradle.kts delete mode 100644 app/shellApp/build.gradle.kts delete mode 100644 app/shellApp/src/main/AndroidManifest.xml delete mode 100644 app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java delete mode 100644 app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java rename {app-rust => app}/src/android/app_log.rs (100%) rename {app-rust => app}/src/android/bench_client.rs (99%) rename {app-rust => app}/src/android/bench_jni.rs (100%) rename {app-rust => app}/src/android/devlog.rs (100%) rename {app-rust => app}/src/android/enrollment.rs (100%) rename {app-rust => app}/src/android/mod.rs (100%) rename {app-rust => app}/src/android/transcript_client.rs (100%) rename {app-rust => app}/src/bin_desktop.rs (100%) rename {app-rust => app}/src/client/ansi.rs (98%) rename {app-rust => app}/src/client/api.rs (100%) rename {app-rust => app}/src/client/config.rs (96%) rename {app-rust => app}/src/client/durations.rs (100%) rename {app-rust => app}/src/client/event_stream.rs (98%) rename {app-rust => app}/src/client/highlight/languages.rs (100%) rename {app-rust => app}/src/client/highlight/markdown.rs (100%) rename {app-rust => app}/src/client/highlight/mod.rs (98%) rename {app-rust => app}/src/client/log_ring.rs (100%) rename {app-rust => app}/src/client/markdown_blocks.rs (100%) rename {app-rust => app}/src/client/mod.rs (100%) rename {app-rust => app}/src/client/notifications.rs (100%) rename {app-rust => app}/src/client/sse.rs (100%) rename {app-rust => app}/src/client/text_cap.rs (100%) rename {app-rust => app}/src/client/tool_summary.rs (100%) rename {app-rust => app}/src/client/transcript_cache.rs (100%) rename {app-rust => app}/src/client/transcript_fold.rs (99%) rename {app-rust => app}/src/client/transcript_source.rs (97%) rename {app-rust => app}/src/desktop/app.rs (100%) rename {app-rust => app}/src/desktop/config.rs (100%) rename {app-rust => app}/src/desktop/mod.rs (100%) rename {app-rust => app}/src/desktop/startup.rs (100%) create mode 100644 app/src/lib.rs rename {app-rust => app}/src/ui/composer.rs (93%) rename {app-rust => app}/src/ui/fixture.rs (97%) rename {app-rust => app}/src/ui/markdown.rs (100%) rename {app-rust => app}/src/ui/mod.rs (99%) rename {app-rust => app}/src/ui/row.rs (97%) rename {app-rust => app}/src/ui/tap.rs (100%) rename {app-rust => app}/src/ui/theme.rs (100%) rename {app-rust => app}/src/ui/tool.rs (86%) delete mode 100755 app/stream-bench.sh rename {app-rust => app}/tests/catch_a_fling.rs (100%) rename {app-rust => app}/tests/fence_fling.rs (100%) rename {app-rust => app}/tests/gesture_cancel.rs (100%) rename {app-rust => app}/tests/input_log_roundtrip.rs (100%) rename {app-rust => app}/tests/phone_screen.rs (100%) rename {app-rust => app}/tests/top_edge.rs (100%) rename {app-rust => app}/touch/flick-120hz.touch (100%) rename {app-rust => app}/touch/flick-cancelled.touch (100%) rename {app-rust => app}/touch/long-press.touch (100%) rename {app-rust => app}/touch/tap.touch (100%) delete mode 100755 app/trace-draw.sh delete mode 100755 app/transcript-bench.sh rename iris/src/{ => rsc}/attr.rs (100%) rename iris/src/{ => rsc}/event.rs (98%) rename iris/src/{runtime.rs => rsc/mod.rs} (88%) rename iris/src/{ => rsc}/overlay.rs (100%) rename iris/src/{ => rsc}/sense.rs (100%) rename iris/src/{ => rsc}/sense_tests.rs (100%) rename iris/src/{ => rsc}/state.rs (92%) rename iris/src/{ => rsc}/task.rs (98%) delete mode 100644 iris/src/widget/event.rs rename iris/src/widget/{position => layout}/align.rs (100%) rename iris/src/widget/{position => layout}/layer.rs (100%) rename iris/src/widget/{position => layout}/lazy_span.rs (100%) rename iris/src/widget/{position => layout}/max_size.rs (100%) rename iris/src/widget/{position => layout}/mod.rs (100%) rename iris/src/widget/{position => layout}/offset.rs (100%) rename iris/src/widget/{position => layout}/pad.rs (100%) rename iris/src/widget/{position => layout}/scroll_area.rs (100%) rename iris/src/widget/{position => layout}/scrollable.rs (100%) rename iris/src/widget/{position => layout}/sized.rs (100%) rename iris/src/widget/{position => layout}/span.rs (100%) rename iris/src/widget/{position => layout}/stack.rs (100%) delete mode 100644 scripts/xtask/Cargo.lock delete mode 100644 scripts/xtask/Cargo.toml delete mode 100644 scripts/xtask/src/apk.rs delete mode 100644 scripts/xtask/src/keystore.rs delete mode 100644 scripts/xtask/src/main.rs delete mode 100644 scripts/xtask/src/sdk.rs diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 1ed9742..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,10 +0,0 @@ -# xtask convention (https://github.com/matklad/cargo-xtask), without folding -# every crate in this repo into one workspace -- they are deliberately -# independent (see scripts/run-tests.sh, which cds into each). -# -# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs. -# The manifest path is relative to the working directory cargo is run from, -# so the root is where it works; this file is found from any directory -# inside the checkout, but the path inside it is not. -[alias] -xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --" diff --git a/.dev-updater.ron b/.dev-updater.ron index bc42ad5..637154b 100644 --- a/.dev-updater.ron +++ b/.dev-updater.ron @@ -17,9 +17,7 @@ label: "AI Sessions", // three constants -- there is nothing here worth spawning a process for. resources: Ron("resources.ron"), -// The two halves this checkout produces: the server a phone talks to, and -// the app that talks to it. They are built in parallel -- this list is the -// set, not a sequence, so nothing here should be read as an order. +// The server and Rust app build in parallel. components: [ Server( name: "server", @@ -39,15 +37,8 @@ components: [ ), Apk( name: "app", - // Release first: the first mode is the default, and the phone runs - // the release build -- a debuggable one runs Compose at a fraction - // of the speed. Each command below is run with the chosen mode as - // its last argument, which is exactly build-apk.sh's interface. modes: ["release", "debug"], - // Resolved against this directory, and run in `app/` -- the script - // cds to its own directory anyway, so the cwd is here to say where - // the app is rather than because the build needs it. - build: "app/build-apk.sh", + build: "./build-apk.sh", cwd: "app", // The Enroll button in this component's settings: prints the link // that enrols the phone against the server built here, for the @@ -55,23 +46,4 @@ components: [ // the terminal the QR would be printed on. enroll: "server/enroll-link.sh", ), - // E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle - // (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed - // with the same release key as "app" above so the two can install - // over each other -- a separate component, not a mode of "app" above, - // because it is a different applicationId (com.example.aiapp.shell) - // built by a different tool from different sources. No `cwd`: it - // defaults to this checkout's root, which both the `cargo xtask` - // alias (`.cargo/config.toml`, resolved relative to the working - // directory cargo is run from) and `cargo xtask apk`'s own publishing - // step (`scripts/build/outputs/apk//*.apk`, matching - // discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's - // module doc) both need. The publish directory is `scripts/build` - // rather than `scripts/xtask/build` for exactly that reason: the - // pattern is one directory deep, and the tool moved two on 2026-09-09. - Apk( - name: "shell", - modes: ["release", "debug"], - build: "cargo xtask apk", - ), ], diff --git a/.gitignore b/.gitignore index 715da09..d8d57fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,6 @@ -.gradle/ -build/ -app/androidApp/build/ -app/shellApp/build/ -local.properties -.kotlin/ -*.iml -.idea/ -.DS_Store server/target/ event-model/target/ -app-rust/target/ - -# E3's native library, built by cargo-ndk straight into the Gradle module -# (RUST.md) -- an artifact, like server/target/ above, not source. -app/shellApp/src/main/jniLibs/ +app/target/ # Server logs from a development run (ai-server.log by convention, # wg-test.log from ./test-wg-tunnel.sh). @@ -32,8 +19,4 @@ sessions/ # iris, the in-house UI library, is vendored at iris/ and built by cargo. iris/target/ -# The packaging xtask and the GPU rigs, both under scripts/. `build/` -# above already covers scripts/build/outputs/apk, where `cargo xtask apk` -# publishes for Dev Updater to find. -scripts/xtask/target/ scripts/rigs/gpu-probe/target/ diff --git a/AGENTS.md b/AGENTS.md index e093dc1..8312459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,776 +1,177 @@ # ai-app -A phone interface to AI coding sessions (Claude Code and llama.cpp), -replacing the Claude app for daily use. Rust/Axum backend on the desktop, -Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token -between them. +A phone and desktop interface to AI coding sessions. The backend is Rust/Axum; +the shared client and UI are Rust, drawn by the in-tree `iris` framework. The +Android app uses a thin Java activity and `android-view`; desktop uses winit. -**`docs/PLAN.md` is the design source of truth** — every decision with its -date, its rationale, and what was rejected. Read it before changing anything -structural, and update it in place when a decision changes rather than -letting this file and the plan become two versions of the truth. This file is -the working notes layer: layout, commands, rigs, and things that have bitten. -The design and working documents live under `docs/` — everything except this -file and `CLAUDE.md`, which stay at the root because that is where Claude -Code and other agent harnesses look for them. +`docs/PLAN.md` is the design source of truth. Read it before structural work +and update it when a decision changes. Working documents are pruned as work +lands: preserve current invariants, measurements, and failed hypotheses, not a +chronicle of completed tasks. Do not create a decisions log. -The central design point, worth not undoing by accident: **a session is a -child process, translated into one common event model.** A new session type -is a new driver — never a session-type branch in shared code (routes, -transcript, app screens). +## Architecture -The second one, for the Rust port on the `rustify` branch: **the phone app -and a planned desktop app share almost all of their code.** Screens, -widgets, folding, paging, config and the network client live in -`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android` -and `src/desktop` are thin entry points that own only what the platform -forces (JNI and the IME on one side, winit and argv on the other). The two -*layouts* will differ, to suit a phone's screen and a finger against a -desktop's screen and a mouse -- but the widgets a layout is made of (a -button, a text field, a list, a card) and the styling (colours, spacing, -type) are one implementation with no per-platform copy. Anything that could -work on both goes in `ui` the first time it is written, and a platform -module growing a widget or a colour is a defect to move, not a convenience -to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details. +A session is a child process translated by a driver into one common event +model. A new session type is a new driver, never a session-type branch in +shared routes, transcripts, or screens. -The third, from Iris on 2026-09-08: **`iris/` is the UI framework and -nothing else.** Nothing in it may know about a session, a transcript, a -setup or a server; anything that does belongs in `app-rust/`, and the -dependency runs one way only. The port's project code is **one crate** -(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's -"One app crate" for what forced each of the splits that were removed and -the two that remain. +Android and desktop share `app/src/client` and `app/src/ui`. Platform modules +own only what the platform forces: JNI, lifecycle, insets and IME on one side; +winit and argv on the other. Layouts may differ, but widgets, styling, folding, +paging, config, and network logic are shared. + +`iris/` is a UI framework and nothing else. It must not know about sessions, +transcripts, setups, or servers. Product code belongs in `app/`, and the +dependency runs one way. ## Layout -Mirrors `../dev-updater` deliberately: same stack (axum 0.8 + -axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single -`:androidApp` module), same cert scheme, same registry pattern. Read -dev-updater's `README.md` and `AGENTS.md` before diverging from them. -Module-by-module intent is in `docs/PLAN.md`'s "Backend layout". +- `server/` — `ai-server`. `routes.rs`'s module comment is the HTTP table. +- `event-model/` — the wire contract shared by server and app. +- `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui` + contains Iris widget trees; `android` and `desktop` are thin hosts. + `android-project/` packages the Rust cdylib. The `bench` feature and + `bench-fixture/` are retained performance rigs, not a second app. +- `iris/` — the framework, proc macro, tabs demo, and input rig. +- `scripts/` — repository-wide scripts and independent profiling rigs. +- `wg-app-link/` — a git submodule shared with dev-updater. Clone with + `--recurse-submodules` or run `git submodule update --init`. +- `docs/` — design and working documents. -- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc - comment is the HTTP table and the surface's source of truth. -- `event-model/` — the wire shape `server/` and `app-rust/` both depend on, - which is the whole reason it is a crate of its own rather than part of - either: it is the contract between them, so the two agree by construction. -- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/ - client` is everything with no UI in it (the REST and SSE clients, the - transcript cache and fold, the highlighter, the ANSI parser, config and - the enrolment link); `src/ui` is the screens as iris widget trees; - `src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android` - is the `android-view` entry point and `android-project/` its Gradle app; - `src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls. - Features pick which face a build is: `screens` (default) for anything - that draws, `shell` for the Compose app's bridge, `bench` for P0's - fixture build. See its `Cargo.toml` header. -- `iris/` — the UI framework, and **only** the UI framework: `core`, - `macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree) - and `rig-input`. It must not mention anything this product is about. -- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions". - `AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs - (sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE - clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and - the Keystore-sealed token. -- `scripts/` — everything at the root that was neither a program nor a - document: the three repo-wide shell scripts (`run-tests.sh`, - `test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and - `virtgpu-probe` device probes, and `ui-profile`'s two layer-1 - profiling rigs), and `xtask/`. **A project's own scripts - stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and - `server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant - top level sh files"). - `scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask) - convention: an ordinary Rust binary that does build work a shell script - would otherwise do, run as `cargo xtask apk` **from the repo root** - (`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to - the working directory). It packages `app/shellApp` without Gradle - driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` → - `apksigner` — and publishes to `scripts/build/outputs/apk//`, - which is where Dev Updater looks. There is deliberately **no `target/` - at the repo root** any more: there is no workspace there, and what used - to be one was only xtask's own scratch space, now in - `scripts/xtask/target/`. -- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA - and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0 - binding and the certificate's SANs (`netif`), owner-only files (`private`), - and the RON house rules (`format`). Clone with `--recurse-submodules`, or - `git submodule update --init` in an existing checkout — `server/` will not - build without it, since it is a path dependency, which is what keeps the two - projects version-locked to the commit this repo pins. What deliberately did - **not** move is the API surface and the config *schema*: routes, drivers, - sessions and setups are what makes this project itself. -- `docs/` — every design and working document except this file and - `CLAUDE.md`: - - `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs` - and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). - - `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. - Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or - the opening and stream effects in `SessionScreen.kt`. - - `docs/TODO.md` — the working list. - - `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the - phone's view, and the choices behind the shape. - - `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify` - branch of the `ai-app-2` clone): what has to be reproduced, the - framework decision, and the ordered experiments with their pass - conditions. Read it before touching anything under that branch. - - `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`, - `docs/CLIENT_CORE.md` — iris's open working list, its layout/render - design, its texture-atlas design, and the design of `app-rust`'s - `client` module, respectively. +Nerd Font icons are a committed subset. `iris/core/build-icon-font.sh` +produces `iris/core/assets/fonts/nerd_icons.ttf`; its codepoints must match +`iris/core/src/icon.rs`. Body and monospace fonts come from the platform. - **These documents are pruned as the work lands, not appended to - forever** (Iris, 2026-09-08: *"remove everything that's already done and - decided… many with checkboxes already ticked off that just fill up - context"*). A ticked box, a finished experiment and a completed review - are deleted once carried out; `IRIS_TODO.md` holds only open items, and - a finished document is removed rather than archived in place. What - survives is what cannot be cheaply re-derived — measurements, dead ends - and failed hypotheses, invariants and their reasons, and the design of - what exists now rather than the route to it. +## Checking work - **There is no decisions log and no design log, and one should not be - started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on - 2026-09-09 at Iris's instruction: *"I've decided to instead make - decisions when planning with agents rather than after they do things, - and they're both too long for me to wanna read, + don't cover all the - decisions I'll wanna make about the code anyways. I'll just naturally - run into things for now."* So raise a choice **while planning it with - her**, when the direction is still cheap to change; otherwise decide it, - put the reasoning at the code it governs, and carry on. TODO lists are - still wanted — a list of open work is useful, a list of finished work - is not. - - `docs/SCROLL.md` — how anything in iris scrolls: one - `ScrollController` holds the position, the gesture, the fling and the - pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own - one each through the `Scrollable` trait. Read it before touching - `scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that - pans, flings or lays out a long list. -- `.dev-updater.ron` — what Dev Updater builds here: the server (run as - `service: Managed(…)`, supervised by Dev Updater's own implementation - rather than a script kept here), the Compose app and the shell APK, in - parallel. **It does not publish either benchmark APK.** Phone benchmark - builds live in the separate `~/repos/ai-app-bench` repository: build here, - copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped - path, then commit and push that repo. Dev Updater pulls the committed APK - from there; pushing `ai-app-2` alone cannot update its benchmark card. This - file points at - `resources.ron`, which is *ours* rather than Dev Updater's — it names - `~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can - offer them. Note what deleting the config directory takes with it: the CA - under `certs`, which is the one-way door. **Stop** on the server card stops - the server a phone reaches through the tunnel, so on that phone it stays - down until somebody starts it again; Dev Updater reaches it over its own - port and is unaffected, which is what makes the button safe to press and - easy to regret. +Commit each coherent, warning-clean slice and push it. -### Icons +- Whole product: `./scripts/run-tests.sh`. +- Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets + -- -D warnings && cargo test`. +- App: `cd app && cargo fmt --all --check && cargo clippy --all-targets -- + -D warnings && cargo test`. +- Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's + emulator, or `./build-apk.sh release` for a phone. The script builds with + cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone + frame times from a debug emulator build. -**Nerd Fonts glyphs from a committed subset**, not vector assets and not -ordinary Unicode. `NerdIcons.kt` declares each codepoint and -`app/build-icon-font.sh` subsets the font; the two lists have to agree, -because a codepoint in the Kotlin that the script did not subset is a glyph -that silently isn't there. Rerun the script and commit its output when adding -one — it needs network access. `md-cog` and `md-refresh` are deliberately the -same codepoints dev-updater uses and must not drift from it. The subset is -the **Mono** face, where every glyph is one em square, which is what makes -two icon buttons the same width without either being given one — and why -`GLYPH_SIZE` is smaller than it looks like it should be. +`app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through +per-directory toolchain files. `server/` and `event-model/` use stable. -**The Rust app does the same, from its own subset**: -`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`, -with the codepoints named in `iris/core/src/icon.rs` and drawn as text -with `Family::Icons`. Same rule about the two lists agreeing (there is a -test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material -Design family so an icon means the same thing in both apps. Its subset is -separate rather than shared because subsetting only what one app draws is -the point. This is the **only** font iris bundles — body and monospace -text come from the platform (decided 2026-09-07), and an icon -is the opposite case: a small closed set of codepoints no system font is -guaranteed to have. +The release signing key lives at `~/.config/ai-app/release.jks`, never in the +checkout. `build-apk.sh` creates it once. Normal builds use application id +`com.example.aiapp`; benchmark builds add `.bench` and are built explicitly: -## Checking your work + ./build-apk.sh release --features "transcript-screen bench" -- **Commit completed work.** Once a coherent piece of work has passed its - relevant checks and has no known major issue or unresolved design decision, - commit it rather than leaving it in the worktree. Keep independently - completed slices in separate commits. +## Running the server -- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`, - `server/` and `app-rust/`; `cd iris && cargo test` runs the framework's - own suite, which is slower and not about this product. Each workspace - also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays - warning-clean and rustfmt-clean at the defaults — there is no - `rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the - UI profiling rig use the rolling nightly channel through per-directory - `rust-toolchain.toml` files; `server/` and `event-model/` are stable. -- **App**: from `app/`, - `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat - :androidApp:compileDebugKotlin :androidApp:lintDebug - :androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the - syntax highlighter, the ANSI parser and the transcript cache — the app's - pure logic with no Android in it. Touching anything under `BenchFixture.kt`, - `BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs - `:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build - type compiles separately and lint has caught real bugs debug alone never - would (see "Android Lint" below). -- **Android Lint is not optional and is not run by a build.** It found a - crash that had been shipping (`java.time` on a minSdk-24 app with - desugaring off) and later a permission check that silently dropped every - notification on Android 12 and below. Fully clean as of 2026-08-31; keep it - that way, and suppress with `tools:ignore` plus a written reason rather - than by lowering the bar. -- Then `./build-apk.sh` for the APK to install on a phone through Dev - Updater, or `./run-android.sh` to build, install and launch on the - emulator. **The phone gets the release build**, signed with a key the - script generates once under `~/.config/ai-app/release.jks` (never in the - repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's - build modes call the script with exactly that word. Dev Updater lists every - variant under `build/outputs/apk`, so pick `release` there; a phone still - holding the debug build has to uninstall it first, since the two are signed - differently. -- The emulator scripts stay on the debug build. **Never read a frame time - from one as the app's** — a debuggable build runs Compose at a fraction of - release speed; the render report says which build it came from. +Use `--bind 127.0.0.1` for emulator development. Without it the server binds +wg0, which the emulator cannot reach. Use scratch state: -## Running it here + ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \ + --data-dir /tmp/ai-sessions --port 8444 -- Run the server for development with `--bind 127.0.0.1`. Without it the - server binds wg0, which exists here but is unreachable from the emulator - (it dials 10.0.2.2). First run prints the enrollment QR/URI with the token. - `ai-server --enroll-link` mints one more device's link while the server - keeps running; the server adopts that token on its first use. It is what - Dev Updater's Enroll button runs. -- Point development at a scratch state directory rather than the real one: - `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. -- **The APK pins the CA of the machine that builds it**, read at build time - from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the - server must have started once on that machine first — the build stops with - that instruction otherwise — and an APK built in this VM only works against - a server in this VM. -- Prefer exercising the server directly over going through the UI: - `curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`. - The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`, - never in the checkout, so a relative `certs/ca.pem` finds nothing. - The emulator app reaches it at `https://10.0.2.2:8443`; enroll with - `adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`. -- **`ai-server --delay MS` holds every response back.** Over the tunnel a - phone's requests take tens to hundreds of milliseconds, and several faults - live entirely in what the app does *while* one is outstanding. On a - loopback server those windows close before anything can be observed, so the - bug looks like it is not there. -- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`, - `after` and what came back, and logs each SSE subscriber's cursor and - whether it was continued or reset (`stream backlog:`). That is the only - place "how far had this phone fallen behind" is answerable — the app sees a - window arrive and cannot tell. -- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two - network namespaces inside one machine and drives the server through it — a - genuine handshake against 10.66.0.1 with pinned TLS, no router or phone - involved. That is how to verify the wg0-only posture. +The emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints +another device link while the server runs. `--delay MS` is important for UI +states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug` +logs transcript page bounds and SSE catch-up/reset decisions. -## The rigs +Exercise the server directly when possible: -Each exists because something was invisible without it. + curl --cacert ~/.config/ai-app/certs/ca.pem \ + -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions -- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md - and the 2026-09-05 decisions), the phone benchmark gate Iris - asked for before porting continues: a deterministic, checked-in synthetic - transcript (`app/bench-fixture/generate.py`, never a real one) that both - this app and iris open with no server, so a frame-time comparison - measures the renderer rather than the data. `./build-apk.sh bench` builds - it — own application id (`com.example.aiapp.bench`) and label ("AI - Sessions bench") so it installs beside a real enrollment rather than - replacing it. Opening it goes straight to a session screen holding the - fixture (no enrollment, no permission prompts) with a "Run benchmark" - control beside "Copy" in session settings: it drives the same scroll loop - and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over - `ui-trace`, but in-process, since a real phone has no usable system - tracing and no agent can drive one (this-machine-android's skill). - `BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a - `URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s - requests from an in-memory copy of the fixture instead of opening a - socket — so the fold, the paging and `uniqueItems` under test are the - screen's real ones, never a shortcut built just for this. The report - gains a `bench:` section (process CPU time, peak RSS, battery current) on - every build, empty except when `BenchRun.kt` filled it in. -- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config - and data directory, holding eight invented Claude Code transcripts and a - `claude` that is two lines of shell. **That isolation is the point**: the - import screen lists whatever is in `~/.claude/projects`, which in this VM is - real agent transcripts, so exercising *delete* against the ordinary server - deletes somebody's conversation and exercising *import* starts a real - `--resume` on the owner's account. - Its port and root derive from the checkout's name, so two checkouts' - sandboxes cannot reach each other, and its token is generated once into - `~/.config/ai-app/sandbox-token` and carried across restarts along with any - the enrolment flow appended — so the emulator app is enrolled **once** (the - start banner prints the command) and stays enrolled. It shares the real TLS - certificates, because the installed APK pins that CA. - Driving verbs, so none of this is re-derived per session: - `./ui-sandbox.sh spawn [title]` (an echo session, prints its id), - `./ui-sandbox.sh send SID text|@file`, and - `./ui-sandbox.sh api /path [curl args]`. - `./ui-sandbox.sh keep` restarts the server without wiping the sessions and - enrolment already there — for when the fixture under test was expensive to - build; plain `start` wipes them, which is right for the list-screen - fixtures and wrong for that. - It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large - transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the - fake CLI slow to start. Both exist because operations that finish in - milliseconds have states on the way that nothing can observe, and an - unobservable state is one where broken and working look identical. - It also builds a fixture tree at the sandbox home's `~/files` for the - explorer, holding the states otherwise only reachable by finding a real - machine in one: an empty directory, a name with a tab and one with an - apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a - symlink to a directory and a broken one, a source file per language, and - the three sizes the limits were measured against (`edit-32k.rs`, - `edit-128k.rs`, `big-source.rs`). Point a session at it with - `./ui-sandbox.sh api /sessions//cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`. - The explorer's 409 is produced by editing the file on the machine - (`printf … > file`) between pressing the pencil and pressing save. -- **`app/debug-transcript.sh`** — a real conversation on the emulator. The - echo driver is the right rig for most things and the wrong one for anything - whose cost scales with what was actually written: a real reply is longer, - is real markdown, and carries tool calls whose input and output are - kilobytes. Two faults were invisible until a real transcript was loaded — a - page of history landing mid-fling threw the reader back to the newest end, - and parsing one real reply took 51ms against 4.6ms for a synthetic one. - `-b` takes the biggest conversation on the machine rather than the newest, - which is what a scrolling test wants; `--stop` takes it down. - It copies the transcript into `/tmp` and gives the server a `HOME` of its - own, so the import can only see the copy — importing spawns `claude - --resume`, and against the real file that is a second CLI writing to a - conversation somebody may still be in. **A transcript never goes in this - repository**: they hold whatever was said, read and written in that - session, and `~/repos` is shared with the host besides. -- **A fake CLI exercises the process lifecycle without a token.** Point a - `claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and - `cat > /dev/null` — and it behaves the way the lifecycle code cares about: - it holds the fifo open, records a real pid, writes nothing, and dies on a - signal. So adopt, stop, restart and start are all drivable without a real - `--resume` and without spending a turn on somebody's account. Reach for - this when what is under test is *whether a process is running*, and for - `debug-transcript.sh` when it is *what the transcript draws*. -- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens - the first session (or `-k` keeps the current screen), scrolls a fixed - gesture loop, and prints the app's render report — the same one the in-app - copy button produces, whose `on screen:` line names what the viewport was - holding. Compare two runs with the same gestures; the emulator's absolute - frame times transfer nothing, the report's accounting does. Run it either - side of any change under `Markdown*.kt`, `Transcript*.kt` or - `SessionScreen.kt`'s list, and put the report in the commit. The numbers - that move first are the worst `record: one block`, the reparse mean while - streaming, and the draw phase's accounting line. -- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still - arriving. It taps "Jump to latest" so the list is pinned to the newest end, - resets the report, sends FILE, waits for the transcript to stop growing, - and prints. Both of those are corrections to a first version that measured - nothing: a transcript parked further back never redraws while a reply - streams into it, and a session is idle at *both* ends of a turn, so polling - for idle answers before the turn has started. -- **`app/trace-draw.sh`** names what a scrolling frame spends inside the - framework, from `atrace` text output with no trace processor needed. It is - how the cost of a layout node per link was attributed to the framework - rather than guessed at. -- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features - ...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`, - straight into `android-project/app/src/main/jniLibs/`) and its APK - (Gradle, from `android-project/`) in one step and verifies the result - (`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`** - installs it on this checkout's own emulator, taps "Run benchmark" by - label, and prints the report -- written so the P0 - build/install/tap/read-report cycle stops being retyped by hand each - time (docs/RUST.md's P0 box). It passes `--no-default-features`, so - `--features` alone decides what is in the `.so`; that is what keeps the - 1.9 MB bench fixture out of a build that did not ask for `bench`. A phone - build is published only by replacing - `~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk` - and pushing the **bench repository**, not this checkout. -- **iris's three test layers** (docs/RUST.md's "Three test layers" has - the commands and what each cannot answer): test at the cheapest one - that can answer the question. `cd app-rust && cargo test` runs the real - transcript screen over the bench fixture with **no window, no - compositor and no GPU** (`iris::harness`), on a clock the test owns and - a gesture replayed from a `t_ms action x y` file under - `app-rust/touch/` -- which is how the batched 120Hz - flick a finger actually makes is testable at all, since a `ui-trace` - swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone - --dir ../app-rust --shot …` opens the same screen in a window at the - phone's own size and density for looking at, and `--replay FILE` drives - the same recording into it (`--dir` names the workspace to build in, - since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface - lifecycle and one verification run before a build goes to the phone -- - not for iterating on layout. -- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in - a crate of their own so a rig's dependencies stay out of the app's - (Iris, 2026-09-09: *"Rigs should probably all be in their own crate so - dependencies and such don't get mixed"*). Run either from that - directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh` - neither runs them nor can fail on them, and both need **release or the - numbers mean nothing**. -- **`tests/frame_profile.rs`** is what a frame costs on the CPU, - at layer 1 -- `cargo test --release --test frame_profile -- --ignored - --nocapture`. Two runs: a fling over the bench - fixture eight times out and back, and a reply streaming into it one - event at a time. Text shaping dominates, which is why the profile is - meaningless unoptimised. It cannot answer anything about the GPU, the - swapchain or the phone's own clock. +`./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel +between network namespaces and verifies pinned TLS against 10.66.0.1. - What it established on 2026-09-09, worth not re-deriving. A **fling** - is not CPU-bound: only about one frame in six lays anything out (the - rest are moved on the GPU through `move_offsets`), and the - multi-millisecond spikes are all in the *first* pass over a stretch of - transcript -- every later pass over the same rows is p99 0.26ms. A - **streamed event** is, and it is not where it looks: folding the event - is 0.35ms and applying the diff to the widget tree is 0.41ms, while the - *frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the - hypothesis, from `foldEvent`'s Compose lesson under "Things that have - bitten"; measuring it is what ruled it out.) That frame is one - `TextBuffer::shape` of the block a delta landed in, and **the fixture's - is 14,888 characters in a single block** -- against a largest-ever - 1,580 across 7,706 blocks of real replies. So the stream phase's number - is a property of the fixture, not of streaming; docs/RUST.md's - "Incremental text" has the measurements and why parley cannot help. +## Rigs - The last two runs (`where_a_streamed_deltas_cost_is`, - `what_the_fixture_streams`) exist to keep that answerable: what a delta - costs to re-split and re-compare, and what the fixture actually - streams. -- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half - of a frame layer 1 builds and never performs, and so the half - `frame_profile.rs` cannot see at all. It prints three numbers per GPU - array per frame, and the point of the rig is that no two of them alone - are honest: **floor** (entries whose bytes actually differ, found by - diffing), **uploaded** (what iris really writes, read from the same - `Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the - old code wrote whenever anything changed). A gap between the first two - is over-marking; one was 122x and invisible until both were printed - side by side. +- `app/ui-sandbox.sh` runs an isolated delayed server with invented + transcripts, a fake CLI, stable enrollment, and a file-explorer fixture. + Its HOME and data are disposable; never point import/delete tests at real + `~/.claude/projects`. +- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption, + stop, restart, and process lifetime without using an account or token. +- `app/run-bench.sh` installs a benchmark APK on this checkout's emulator, + taps its accessibility-labelled control, and prints the report. +- `cd app && cargo test` drives the real transcript screen without a window + through `iris::harness`; touch recordings live in `app/touch/`. +- `iris/run-headless.sh phone --phone --dir ../app --shot …` opens the same + screen at phone size. `--replay ../app/touch/flick-120hz.touch` replays a + recorded gesture. +- `scripts/rigs/ui-profile/tests/frame_profile.rs` measures CPU frame cost; + `arena_churn.rs` measures GPU-array upload. Run ignored profiling tests in + release mode or the numbers are meaningless. - What it established on 2026-09-09, and what the three optimisations it - drove were. Uploading the whole arena on any change cost **758 MB over - a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed - frame. Three things were wrong and each is now guarded by this rig: - `ArrBuf` reallocated on every length change, so adding one glyph made - the buffer's contents undefined and forced a full rewrite; a redraw - freed its primitives and pushed new ones, which -- since freed slots - are only reusable next frame and provisional layout nested -- grew - the arena to **127,443 slots for 11,569 live primitives**; and nothing - tracked *which* entries changed. Now: the stream arena is 11,569 slots - for 11,569 live, and every array uploads within a hair of its floor. - The CPU half improved with it, since the freeing and renumbering - went away: a streamed frame is p50 1.39ms, from 2.20ms. +The checked-in benchmark transcript is synthetic. Never put a real transcript +in this repository; it contains conversation text, tool input, and file data. - The remaining layout cost was then removed at the framework boundary: - `Painter::set_child_offset` gives a container one retained coordinate slot - for its child subtree, and `LazySpan` keeps row boxes stable behind it. - Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from - 71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496. - `Primitives` also cancels dirty marks for provisional writes restored before - upload, so CPU-only layout states never become GPU work. -- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08; - docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader - in software -- while its GLES *is* the host's real GPU through virgl at - ES 3.1, so an ordinary build's runtime fallback lands there by itself - and nothing should pass `force-gles` to arrange it. The Vulkan path is - verified on the desktop build and on Iris's phone. Do not boot the - emulator with SwiftShader Vulkan to "test the Vulkan path": that - measures a software rasteriser and steers iris away from the one - hardware-accelerated backend it has there. Every run says which adapter - drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read - that line before reading a number. +The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while +GLES is host-accelerated through virgl. Let Iris's runtime fallback select +GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before +interpreting a measurement. Vulkan is verified on desktop and a real phone. -### Driving the UI +## Driving Android UI -**No script that drives this app's UI presses a coordinate.** Every control -is found by the name it already carries for assistive technology — -`ui-trace record --do "tap 'Session settings'"` — which resolves the label -against the screen at the moment of the gesture and fails the whole run when -it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A -coordinate is a position measured once by hand, and anything that moves the -control makes the tap land on whatever now sits there — the bench then -reports a number that was never measured, which reads exactly like a result. -Both bench scripts pressed the render report at `tap 723 205` until that -button moved into the session settings dialog on 2026-09-03. The check that -none has crept back: +Read the installed `this-machine-android` skill before using Gradle, adb, an +AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it +with `emu serial` rather than typing a device name. - grep -n "tap [0-9]" app/*.sh +Scripts tap controls by accessibility label, never by coordinate. Coordinates +are allowed for swipes because a swipe describes a distance across a scrolling +surface. A coordinate tap can silently hit a different control and turn a +failed run into a plausible-looking result. -Swipes are still coordinates, deliberately: a gesture across a scrolling area -is a distance rather than a control. +## Host and VM boundary -**Two traps in the emulator bench loop**, each of which cost a run. -`adb shell pm clear` removes the enrolment and the notification permission -along with the saved anchors, so the next run measures a permission dialog — -re-enrol with the command `ui-sandbox.sh` prints, and -`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id, -so the only way two builds start a scroll from the same place is a *fresh -session for each*. +Production `ai-server` runs on the host, where the phone can reach WireGuard. +The Claude CLI is in this VM, so the host reaches it as a remote provider. +The VM's wg0 is useful for development but has no reachable phone peer. A dev +server in the VM creates a throwaway CA; never install an APK enrolled against +that CA on the real phone. -**The emulator is `~/repos/emulator-tools`' business, not this repo's.** -`emu up` creates and boots the AVD named after this checkout — whatever `emu -name` prints, never a name typed out here, since this file is the same in -every clone. `run-android.sh` is that plus a build and an install. The `adb` -on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills -in `-s` from the same rule. Gradle does not go through it, so a Gradle init -script from `emulator-tools` runs `emu check` before `installDebug`, -`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out -to every attached device; when it refuses, say which device you mean at the -moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`. +For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that +directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because +their fluent nonsense resembles a broken integration. -### Testing llama.cpp and ssh here +For SSH transport tests, SSH this VM to itself with a throwaway key and a +harmless command. Remove the key afterwards. The remote login shell is fish, +so POSIX-quoting assumptions require explicit verification. -The prebuilt CPU llama.cpp lives outside the repo at -`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its -own directory on `LD_LIBRARY_PATH`, so start the server as -`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's -`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at -usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the -IQ2_XXS of that model produces fluent nonsense, which reads exactly like a -broken driver — `llama-cli` produces the same from the file directly, which -is how to tell the two apart in a hurry. +## Session invariants -There is no second machine, so **ssh this VM to itself**: generate a -throwaway key, append the public half to `~/.ssh/authorized_keys`, and -configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus -`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches -nothing real. Point a provider's `command` at something harmless like -`/bin/echo` rather than at `claude`: the transport is what is under test, the -process exiting immediately is the signal, and it costs no tokens. **Take the -key back out afterwards.** The remote login shell here is **fish**; the -remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in -both, but that is luck rather than design, and a shell that is neither is the -thing to suspect first if a remote spawn ever mangles an argument. +Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes +running; restart adopts their process records without starting stopped +sessions. Sending a message to a stopped session starts it. Use +`--throwaway-sessions` for test-created sessions. -## Where things run (host vs this VM) +Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`, +and `stderr.log`. Do not edit or remove them while live: the stdout byte offset +in `process.json` prevents replay and loss. -This checkout runs in a VM while production runs on its host: +Never import a Claude Code session open in a terminal. One Claude session id +may occur in multiple project directories; import listing deduplicates by id +and prefers the copy with more lines, while deletion removes every copy. -- **`ai-server` belongs on the host in production.** That is where the LAN - address the phone can reach is, and where WireGuard terminates. - `scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it - there with `sudo WG_ENDPOINT=`. -- **The tunnel and the real phone can never terminate in the VM**, because - nothing outside can open a connection into it. Phone bring-up is host work. -- `wg0` (10.66.0.1) exists in this VM too, so the production path is - exercisable during development. It has no reachable peer and does not need - one — but with no `--bind` the emulator cannot reach the server. -- **The `claude` CLI is only in the VM, so from the host it is a remote.** - The backend reaches it as it would any other machine. -- Starting the server in the VM makes a separate throwaway dev CA. **Never - install a build pinning that on the real phone.** +Deleting an app session only deletes the provider's transcript when +`deleteForeign=true`. The server deletes the foreign transcript first so an +unreachable machine cannot leave a half-deleted session. -## Sessions outlive the backend +## Known traps -Since 2026-08-29 a session's process is deliberately left running when -`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design; -day to day: - -- **Stopping the server no longer stops the sessions.** After `pkill - ai-server` the `claude` processes are still there, on purpose - (`reattaching to the claude-cli it left running` in the log). To end one, - `POST /sessions/{id}/stop` — which keeps the session and its transcript, - and `/start` brings the process back on the same conversation — or delete - the session, which ends the conversation too. -- **A message or a command sent to a stopped session starts it**, so the - Start button is for when you want a process and nothing to say to it yet. -- **A backend start adopts and starts nothing.** If you are looking for a - stopped session's process after a restart, there is deliberately none. -- **A session spawned while testing cleans itself up**: `--throwaway-sessions`, - which a debug build defaults to on. Pass `--throwaway-sessions=false` to - keep what a development server spawns. The flag decides only what **new** - sessions are marked as; what happens on the way out is decided by the - **mark**. -- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and - `stderr.log`. `stdout.log` is the driver's input, read from the byte offset - in `process.json`; removing either by hand while the session is live loses - output or replays it. - -## Importing - -The import list reports each session's **size as well as its line count**, -because the two disagree in the way that matters: these transcripts embed -screenshots as base64, so one line can be a megabyte. On this machine a 69 MB -session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line -count tells you what continuing a session will cost. Shown, not warned about; -importing a large session is a choice somebody is entitled to make. - -**Never import a Claude Code session that is open in a terminal.** The app -refuses it — see docs/PLAN.md for the incident that made that a refusal rather -than a warning. - -**One Claude Code session id can name two files, and the listing offers it -once.** Resuming from a different working directory makes the CLI write a -second transcript with the same id under that directory's project folder — an -ordinary state of a machine, not corruption. Everything downstream addresses -a session by id, and the phone keyed its list on it, so two rows sharing one -**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps -the copy with the most lines, because the other is usually a few-hundred-byte -stub and is often the *newer* of the two, so recency is the wrong key. -Deleting removes every copy rather than the first, or the row came back after -a delete that reported success. The phone's half is `uniqueItems`, which -every list keyed on a server-chosen id goes through: a repeat there must -never be able to close the app, whatever produced it. - -**Deleting a session offers to take the machine's own transcript with it** — -`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the -confirmation, and only where the driver keeps a record of its own -(`keepsOwnTranscript`, which today means Claude Code). Off by default, -because leaving that copy is what makes an ordinary delete recoverable — and -the dialog's paragraph is rewritten when it is on rather than appended to, -since the sentence promising the conversation "should still be there to -import again" is exactly the one the switch makes false. The server deletes -the machine's copy *first*, so a machine it cannot reach leaves the session -where it was instead of half-deleted. - -## Shared appearance - -- **A row something is happening to is dimmed, drained of colour, and says - which operation in a word** — `BusyItem`, used by both the session list and - the import list so the appearance is learned once. The word rather than a - bare spinner because "deleting" and "importing" differ in kind. It does - **not** make the row inert: the caller disables its own click handler while - it passes a label. An overlay consuming pointer events was tried and - swallowed the drag along with the tap, so a list could not be scrolled - while anything in it was busy. - -## Things that have bitten - -Project-specific only; keep cross-project machine notes out of this file. - -- **tracing caches callsite interest process-wide.** A test that hits a - `tracing::warn!` with no subscriber installed can poison the interest cache - for a concurrent test that captures logs (flaky "nothing was logged" - failures). Keep every exercise of a logging code path under the one - capturing subscriber — that is why the auth middleware has a single - combined gating+logging test. -- **The composer can get stuck floating above the bottom of the screen after - the keyboard closes, while a reply is streaming.** The composer's position - and the transcript's bottom padding are both driven by the raw, animated - `WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid - recomposing the whole screen every frame of the keyboard's animation. That - animation is carried by a `WindowInsetsAnimationCallback`, and a callback - interrupted mid-flight leaves whatever it was carrying frozen at its last - value with nothing left to correct it. A streaming reply invalidates the - view every frame, which is exactly the condition known to starve that - callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the - failure mode — it is set once, from the platform's own start/end of the - transition over a different path — so it is read once per keyboard toggle - and used to force both places back to zero. - **The guard is a boolean; the inset itself must never be read in the - composable body.** That correction first shipped as a `padding(bottom = … - imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value - that changes every frame: measured at **16 full recompositions of - `SessionScreen` per keyboard open, against 1**. It is - `.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead — - `imePadding` reads the inset in the layout phase, and dropping the modifier - is the same coercion to zero the boolean was added for. The counter to - check is `session screen recomposed` in the debug report, which should move - by one across a keyboard open, not by the number of frames it took. -- **The keyboard pans the window unless the activity opts into resize.** - Without `android:windowSoftInputMode="adjustResize"`, opening the IME - slides the whole window up (top bar off screen) instead of resizing — - `imePadding()` alone does not fix it and the transcript looks empty. -- **A PEM constant must start at the opening quotes.** A generated - `"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its - preamble sniff, so it tries DER instead and fails at runtime with - `ASN.1 … DECODE_ERROR` — nowhere near the code that produced it. -- **ZXing only looks for a dark code on a light ground.** The enrollment QR - is block characters in the terminal's foreground colour, so a dark-themed - terminal renders it as a negative and the in-app scanner silently never - matches — while the phone's own camera app, which tries both, does. The - scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and - inverted frames; keep it that way rather than making the server dictate the - colours. -- **`serde_json`'s default float parser is not correctly rounded**, so the - server handed out the same transcript line two different ways: a `ts` of - `1788546972.6030757` came back from `/transcript` as `…0755` while the SSE - stream sent the original. Nothing on screen could show it — a `ts` is drawn - as a relative time — and what found it was the phone's cache comparing a - line it held against the server's answer. The `float_roundtrip` feature in - `server/Cargo.toml` is the fix and - `a_line_read_back_is_the_line_that_was_written` is what keeps it; that test - fails within a second of the feature being dropped. -- **Resolving one importable session used to list every one of them.** - `import::delete` and the import seed both called `list`, which reads every - transcript Claude Code has ever written — measured at 3.7 seconds against - the 867 MB in this VM, paid once per session in a batch. `import::find` - takes the same script with one glob narrower: 78ms. Ids are checked - (`is_session_id`) before they reach that glob, since a `/` or `..` walks it - out of the projects directory. -- **A transcript page used to cost the whole transcript.** `read_window` read - and parsed every line and then kept the last `limit` of them, so the work - was the size of the conversation rather than the size of the answer: one - page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and - took the same 500ms whichever page was asked for. It is a bisection now - (`Indexed` in `transcript.rs`) — sequence numbers only increase, so the - edge of a range is found by parsing one line per halving. Same page, - ~110ms, of which ~20ms is the file scan. The file is still read whole; that - is where the remaining cost is, and going further means a chunked backwards - reader. -- **Paging back has two failures that look like "there is simply no more - history", and neither says anything on screen.** Both invisible on a - loopback server and reproducible at `--delay 150`. The pager fires on the - *first layout*, before any event has arrived — `moreHistory` starts true, - so the spinner is in the list and `visibleItemsInfo` is not empty — and - `before = 0` asks for the events before the first one, which is none, which - is exactly how this code is told it has reached the start. `loadOlderPage` - refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the - path where a *split* call had been found, so a boundary landing cleanly - between two calls — most of them — left one run of tool calls drawn as two - groups with the seam wherever the reader happened to have paged. - Reproducing either takes a boundary placed on purpose: the opening page is - 80 events, so arrange the transcript so that event counts back from the - newest. -- **A page is 800 events and a screen is a handful of rows, and the two have - no fixed ratio.** A run of thirty-five tool calls is one row; a reply is - hundreds of text deltas folded into one. So anything that budgets in rows - has to measure a screen rather than name a number: the history cushion was - eight rows, which on a tool-heavy transcript is less than one screenful, so - the reader hit the end of what was loaded on every swipe and stood there - for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what - is actually on screen. -- **Only `fetchTranscript` was off the main thread; the fold was not.** - `foldEvent` returns a new list per event, so a page is that many copies of - a growing list — fine at 80 events and about 300,000 element copies at 800, - run in the middle of the scroll that asked for it. `warm` had the same - shape: the `markdownIn` scan that decides *what* to parse ran before the - hop to `Dispatchers.Default`. The shape to watch for is a `withContext` - that wraps the *fetch* and leaves the work done with the result outside it. - -## Measurements worth not re-taking - -- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU - emulator against a real imported transcript with the server at - `--delay 120`. Settled and flinging fast, both into fresh history and back - through rows already drawn: **5.2–5.9% janky frames, 99th percentile - 29–32ms, 0–2 slow UI-thread frames.** The stock Settings app on the same - device is 3.3% and 38ms, so this is at the platform floor. The number that - is *not* at the floor is the first few seconds after opening a session, - where every row on the way is being composed for the first time; that is - inherent to a lazy list and it is why a measurement taken before the screen - settles reads three times worse. **Settle first, then reset `gfxinfo`.** -- **The reset path is not reachable by reopening a session.** Measured - 2026-09-04 against a session streaming at 20 events a second: reopening one - with an anchor 1,800 events back connects **87–119 events behind**, well - under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the - opening page, then one span covering the whole distance. To exercise the - reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5 - the app takes the reset on a live connection, clears, refills and carries - on without reconnecting. -- **The session screen's stream survives backgrounding here** — 20 seconds at - the launcher while 415 events were produced brought no reconnect at all, - which is not what the comment above that loop expects, and is most likely - this emulator being headless rather than the phone's behaviour. -- **Reopening a cached session costs one request for one event** (the probe), - and scrolling the whole conversation back costs nothing more; a cold open - of the same 500-event session is two pages, 100 events. Measured - 2026-09-04 on the emulator against the sandbox. -- **Reading is cheap and editing is not.** The viewer handles a 1 MiB, - 28,000-line file because it draws one row per line; the editor is one - `BasicTextField`, which costs two seconds a frame at 128 kB and stops the - app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on - screen. If you make the editor faster, that number is what to move. - docs/EXPLORER.md's "What the measurements said" has the rest. +- `tracing` caches callsite interest process-wide. Logging tests must install + their capturing subscriber before any tested callsite runs. +- `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve + identical timestamp bytes. +- Import lookup must use `import::find`, not list every transcript. Validate + ids before putting them in a glob. +- Transcript sequence numbers increase, so page edges are found by bisection. + Do not replace indexed window reads with whole-transcript parsing. +- A page's event count has no fixed relationship to visible rows because + deltas and tool calls fold together. History cushions are measured in + viewports, not row counts. +- Android generic motion is separate from touch. Keep hover, wheel, and mouse + button handling in `iris::android`; product UI consumes the same pointer + state on desktop and Android. diff --git a/app-rust/android-project/app/build.gradle b/app-rust/android-project/app/build.gradle deleted file mode 100644 index a13c6f6..0000000 --- a/app-rust/android-project/app/build.gradle +++ /dev/null @@ -1,90 +0,0 @@ -plugins { - id("com.android.application") -} - -// The Rust side (this directory's Cargo.toml) is built separately with -// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root -// AGENTS.md-style comment at the top of Cargo.toml for why this crate -// stays outside the main Rust workspace, and RUST.md's I2 for the exact -// build command. -android { - namespace = "dev.iris.android.demo" - compileSdk = 37 - - defaultConfig { - applicationId = "dev.iris.android.demo" - // 29, not 26: `iris::android::view`'s touch handler dates each - // sample with `MotionEvent.getEventTimeNanos` and - // `getHistoricalEventTimeNanos`, both API 29, and a missing JNI - // method there is a hard crash on the first touch rather than a - // degraded fling. Raised deliberately rather than guarded at - // runtime: nothing this app is built for runs below 29, and an - // untested fallback path is its own defect. `build-apk.sh`'s - // `cargo ndk -P` is kept at the same number. - minSdk = 29 - // 37, matching `compileSdk` and the Compose app in `app/` -- which - // is the one part of this that is measured rather than reasoned: - // that app targets 37 and its keyboard does push the transcript up - // on Iris's phone, and this one targeted 34 and does not - // (2026-09-07). The emulator here is API 36 and the push-up works - // there at either target, so the target is the only difference the - // two devices do not share. - // - // The mechanism, stated as the reading it is: below targetSdk 35 - // a window keeps the legacy behaviour, where `adjustResize` shrinks - // the window for the IME and `getInsets(ime()).bottom` therefore - // measures the overlap with an already-shrunk window -- zero, with - // nothing left to push up. `MainActivity`'s - // `setDecorFitsSystemWindows(false)` opts out of that, and on API - // 36 it still takes; Android 16 deprecated it and Android 17 is - // where it appears not to. At 35+ edge-to-edge is not opt-in, so - // the app is handed the real overlap without relying on a - // deprecated call. If the phone still reports `ime_bottom=0` with - // a nonzero `dispatches` in the Diagnostics pane, this reading was - // wrong and the `WindowInsetsAnimation.Callback` in - // `MainActivity` is the other half to look at. - targetSdk = 37 - versionCode = 1 - versionName = "1.0" - } - - // A release build must be signed, and the key is per machine rather than per repo -- same - // reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone - // recognises the app by, and a secret never lives in a checkout (the mount is shared with an - // untrusted VM). `build-apk.sh` generates this key once and points at it through the - // environment; without it a release build here is unsigned, which is fine for everything - // except installing. - def keystore = System.getenv("AI_APP_KEYSTORE") - signingConfigs { - if (keystore != null) { - release { - storeFile = file(keystore) - storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") - keyAlias = "ai-app" - keyPassword = storePassword - } - } - } - - buildTypes { - debug { - } - // P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so` - // itself is built separately with `cargo ndk --release --features "transcript-screen - // force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) -- - // Gradle here only packages and signs whatever is already there, the same division as the - // debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it - // installable beside a debug build of the tabs demo rather than replacing it. - release { - applicationIdSuffix ".bench" - if (keystore != null) { - signingConfig = signingConfigs.release - } - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } -} diff --git a/app-rust/android-project/app/src/main/AndroidManifest.xml b/app-rust/android-project/app/src/main/AndroidManifest.xml deleted file mode 100644 index 833f9f0..0000000 --- a/app-rust/android-project/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app-rust/build-apk.sh b/app-rust/build-apk.sh deleted file mode 100755 index 5fb1bfe..0000000 --- a/app-rust/build-apk.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/sh -# Builds the Android app end to end: the cdylib (cargo ndk from this -# directory, straight into android-project/app/src/main/jniLibs/) then the -# APK (Gradle, from android-project/). Written to stop re-typing -# the same incantation by hand every time (ANDROID_HOME/NDK exports, the -# cargo ndk invocation, the keystore env for a release build, apksigner/ -# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/ -# build-apk.sh` (the Compose app's own build script) and `app/ -# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success). -# -# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"] -# debug/release default to debug (matches this-machine-android's "the -# emulator stays on debug" rule -- pass `release` explicitly for a phone -# build). --abi defaults to arm64-v8a (a phone/real device); pass -# x86_64 for this checkout's own AVD. --features defaults to -# "transcript-screen bench" -- deliberately *without* `force-gles`, and -# nothing should add it back for the emulator's sake. -# -# **The emulator does not need a GLES build, because it has no hardware -# Vulkan to be steered away from** (docs/RUST.md, "What the emulator -# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader -# in software, its GLES is the host's real GPU through virgl, and iris's -# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on -# `Backends::GL` -- takes an ordinary build there by itself. So the -# emulator and the phone run the *same binary* and differ only in what -# that binary finds, which is the whole point: a build flag that changed -# the backend would mean the thing measured here is not the thing -# shipped. -# -# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile -# time for a backend-isolation measurement (RUST.md's I5, "Where iris's -# frame time goes"), and the desktop is the better place to run it now -# (`run-headless.sh ... --features iris/force-gles`). It was never meant -# to reach a real device, but this script's old default put it in every -# arm64 build regardless, so the P0 bench APK delivered to Iris's phone -# forced GLES there too -- the named hypothesis in RUST.md's P0 box -# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a -# build meant for a phone. -set -eu -cd "$(dirname "$0")" - -BUILD_TYPE="debug" -ABI="arm64-v8a" -FEATURES="transcript-screen bench" -case "${1:-}" in - debug|release) BUILD_TYPE="$1"; shift ;; -esac -while [ $# -gt 0 ]; do - case "$1" in - --abi) ABI="$2"; shift 2 ;; - --features) FEATURES="$2"; shift 2 ;; - *) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;; - esac -done - -SDK_ROOT="$HOME/Android/Sdk" -export ANDROID_HOME="$SDK_ROOT" -export ANDROID_SDK_ROOT="$SDK_ROOT" -NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1) -if [ -z "$NDK_DIR" ]; then - echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2 - exit 1 -fi -export ANDROID_NDK_HOME="$NDK_DIR" - -# Only the ABI asked for goes into the APK. cargo ndk adds its output beside -# whatever earlier builds left here, and Gradle packages every directory it -# finds -- a debug x86_64 emulator build left behind made an arm64 "release" -# 339 MB on 2026-09-06. -rm -rf android-project/app/src/main/jniLibs -# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach. -# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a -# build that switches ABI packages the previous ABI: an `--abi x86_64` -# release APK containing `lib/arm64-v8a/libmain.so` installed fine and -# aborted at startup with `Could not get adapter!: NotFound { -# active_backends: VULKAN }` under libndk_translation -- which reads -# exactly like the phone's own Vulkan problem and is nothing of the kind. -# Scoped to the merge task's directory rather than all of `app/build`, so -# an ABI change costs the native merge and not the whole Gradle build. -rm -rf android-project/app/build/intermediates/merged_native_libs \ - android-project/app/build/intermediates/stripped_native_libs \ - android-project/app/build/intermediates/merged_jni_libs -echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\"" -if [ "$BUILD_TYPE" = "release" ]; then - cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \ - --profile android-release --no-default-features --features "$FEATURES" -else - cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \ - --profile android-dev --no-default-features --features "$FEATURES" -fi - -GRADLE_TASK="assembleDebug" -APK_DIR="android-project/app/build/outputs/apk/debug" -APK_NAME="app-debug.apk" -if [ "$BUILD_TYPE" = "release" ]; then - GRADLE_TASK="assembleRelease" - APK_DIR="android-project/app/build/outputs/apk/release" - APK_NAME="app-release.apk" - # Same key `app/build-apk.sh` (the Compose app) generates once under - # ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work". - export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks" - if [ ! -f "$AI_APP_KEYSTORE" ]; then - echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2 - exit 1 - fi - export AI_APP_KEYSTORE_PASSWORD - AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password") -fi - -(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain) - -APK_PATH="$(pwd)/$APK_DIR/$APK_NAME" -BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1) -echo "--- aapt2 dump badging ---" -"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5 -if [ "$BUILD_TYPE" = "release" ]; then - echo "--- apksigner verify ---" - "${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH" -fi -echo "$APK_PATH" diff --git a/app-rust/src/lib.rs b/app-rust/src/lib.rs deleted file mode 100644 index f173356..0000000 --- a/app-rust/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub mod client; - -#[cfg(feature = "screens")] -pub mod ui; - -#[cfg(all(feature = "screens", not(target_os = "android")))] -pub mod desktop; - -#[cfg(all(feature = "screens", target_os = "android"))] -pub mod android; - -// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`, -// which this toolchain deprecates in favour of `try_update`. The call is -// inside the macro, so there is nothing here to migrate -- the fix is a -// `jni` release, and this allow comes out when one lands. Scoped to the -// module the macro is used in rather than the crate, so a deprecation in -// our own code is still a warning. -#[cfg(feature = "shell")] -#[allow(deprecated)] -pub mod shell; diff --git a/app-rust/src/shell/jcall.rs b/app-rust/src/shell/jcall.rs deleted file mode 100644 index 6798cd7..0000000 --- a/app-rust/src/shell/jcall.rs +++ /dev/null @@ -1,99 +0,0 @@ -use jni::Env; -use jni::errors::Result; -use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned}; -use jni::refs::{Global, LoaderContext}; -use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature}; -use jni::strings::JNIString; -use std::sync::OnceLock; - -static CLASS_LOADER: OnceLock>> = OnceLock::new(); - -pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> { - if CLASS_LOADER.get().is_some() { - return Ok(()); - } - let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?; - let loader_obj = call_method( - env, - &class_obj, - "getClassLoader", - "()Ljava/lang/ClassLoader;", - &[], - )? - .l()?; - let loader = env.cast_local::(loader_obj)?; - let global = env.new_global_ref(&loader)?; - // Lost the race with another entry point calling this concurrently -- - // both loaders name the same app, so either one is fine and there is - // nothing to reconcile. - let _ = CLASS_LOADER.set(global); - Ok(()) -} - -/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`) -/// through the cached app classloader when one has been remembered, and -/// through the ordinary default otherwise -- which is every call made -/// before any entry point has run, and is also correct for a main-thread -/// caller, so there is no case this makes worse. -fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result> { - match CLASS_LOADER.get() { - Some(loader) => { - let binary_name = name.replace('/', "."); - LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true) - } - None => env.find_class(JNIString::new(name)), - } -} - -pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result> { - resolve_class(env, name) -} - -pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef) -> Result> { - Ok(env.new_string(text)?.into()) -} - -pub fn new_object<'local>( - env: &mut Env<'local>, - class: &str, - sig: &str, - args: &[JValue], -) -> Result> { - let sig = RuntimeMethodSignature::from_str(sig)?; - let class = resolve_class(env, class)?; - env.new_object(class, sig.method_signature(), args) -} - -pub fn call_method<'local>( - env: &mut Env<'local>, - obj: &JObject, - method: &str, - sig: &str, - args: &[JValue], -) -> Result> { - let sig = RuntimeMethodSignature::from_str(sig)?; - env.call_method(obj, JNIString::new(method), sig.method_signature(), args) -} - -pub fn call_static_method<'local>( - env: &mut Env<'local>, - class: &str, - method: &str, - sig: &str, - args: &[JValue], -) -> Result> { - let sig = RuntimeMethodSignature::from_str(sig)?; - let class = resolve_class(env, class)?; - env.call_static_method(class, JNIString::new(method), sig.method_signature(), args) -} - -pub fn get_static_field<'local>( - env: &mut Env<'local>, - class: &str, - field: &str, - sig: &str, -) -> Result> { - let sig = RuntimeFieldSignature::from_str(sig)?; - let class = resolve_class(env, class)?; - env.get_static_field(class, JNIString::new(field), sig.field_signature()) -} diff --git a/app-rust/src/shell/mod.rs b/app-rust/src/shell/mod.rs deleted file mode 100644 index 965d782..0000000 --- a/app-rust/src/shell/mod.rs +++ /dev/null @@ -1,95 +0,0 @@ -mod jcall; -mod notify; -mod settings; -mod share; - -use jni::errors::LogErrorAndDefault; -use jni::objects::{JClass, JObject}; -use jni::sys::jint; -use jni::{Env, NativeMethod, native_method}; - -fn ensure_logger() { - static ONCE: std::sync::Once = std::sync::Once::new(); - ONCE.call_once(|| { - #[cfg(target_os = "android")] - android_logger::init_once( - android_logger::Config::default() - .with_max_level(log::LevelFilter::Debug) - .with_tag("android-shell"), - ); - }); -} - -// The parameters are spelled as their Java types, not as `JObject`: the -// macro encodes each argument into the exported symbol's JNI signature -// (and JNI resolves `Java_...` names *by* that signature), so a generic -// `JObject` here would export `(Ljava/lang/Object;...)` against a Java -// method actually declared `(Landroid/app/Activity;...)` -- two different -// symbols that never resolve to each other, silently, with no compiler -// error on either side. `android.app.Activity` etc. have no dedicated -// Rust wrapper in this crate, so they fall back to plain `JObject` in the -// implementation functions below (the "Built-in Types" note in -// `native_method!`'s docs). -const _: NativeMethod = native_method! { - java_type = "com.example.aiapp.shell.MainActivity", - static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (), - error_policy = LogErrorAndDefault, -}; - -fn native_handle_intent<'local>( - env: &mut Env<'local>, - _class: JClass<'local>, - activity: JObject<'local>, - intent: JObject<'local>, -) -> Result<(), jni::errors::Error> { - ensure_logger(); - jcall::remember_class_loader(env, &activity)?; - share::handle_intent(env, &activity, &intent) -} - -const _: NativeMethod = native_method! { - java_type = "com.example.aiapp.shell.NotificationService", - static extern fn native_sync(context: android.content.Context) -> (), - error_policy = LogErrorAndDefault, -}; - -fn native_sync<'local>( - env: &mut Env<'local>, - _class: JClass<'local>, - context: JObject<'local>, -) -> Result<(), jni::errors::Error> { - ensure_logger(); - jcall::remember_class_loader(env, &context)?; - notify::sync(env, &context) -} - -const _: NativeMethod = native_method! { - java_type = "com.example.aiapp.shell.NotificationService", - static extern fn native_on_start_command(service: android.app.Service) -> jint, - error_policy = LogErrorAndDefault, -}; - -fn native_on_start_command<'local>( - env: &mut Env<'local>, - _class: JClass<'local>, - service: JObject<'local>, -) -> Result { - ensure_logger(); - jcall::remember_class_loader(env, &service)?; - Ok(notify::on_start_command(env, service)) -} - -const _: NativeMethod = native_method! { - java_type = "com.example.aiapp.shell.NotificationService", - static extern fn native_on_destroy() -> (), - error_policy = LogErrorAndDefault, -}; - -fn native_on_destroy<'local>( - _env: &mut Env<'local>, - _class: JClass<'local>, -) -> Result<(), jni::errors::Error> { - ensure_logger(); - notify::on_destroy(); - Ok(()) -} diff --git a/app-rust/src/shell/notify.rs b/app-rust/src/shell/notify.rs deleted file mode 100644 index 829d39b..0000000 --- a/app-rust/src/shell/notify.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! Where a notification is said, and the foreground service that keeps -//! the connection open while the app is closed. Ported from -//! `Notifications.kt`'s `NotificationService`, minus the "session on -//! screen" / "hand to the app as a banner" branches: those read -//! process-wide state that only exists because a screen is drawn to -//! register against, and this experiment draws no screen yet (that is -//! E4's job, on iris). So every notification here takes the third branch -//! Kotlin's `show` already had -- the platform's own drawer -- which is -//! also exactly the case E3's pass condition asks for: **a notification -//! arrives with the app closed.** - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use crate::client::api::UreqTransport; -use crate::client::notifications::{SessionNotification, follow_notifications}; -use jni::Env; -use jni::errors::Result; -use jni::objects::{JObject, JValue}; -use jni::sys::{JNI_TRUE, jint}; - -use crate::shell::settings::{self, ServerSettings}; - -const ALERT_CHANNEL: &str = "sessions"; -const ONGOING_CHANNEL: &str = "connection"; -const ONGOING_ID: i32 = 1; -const ALERT_ID: i32 = 2; -const RECONNECT_DELAY: Duration = Duration::from_millis(5_000); - -/// Whether the follow-loop thread is already running. **A deviation from -/// `Notifications.kt`, found by testing rather than planned**: the Kotlin -/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }` -/// on *every* call, with nothing to notice a previous one is still going -- -/// and `sync()` calling `startForegroundService` when the service is -/// already running is an ordinary Android start, not a restart, so -/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls -/// `sync` once itself, then again inside `handle_enrollment` after saving -/// the token) hits exactly this path and was observed opening **two** -/// concurrent connections to `/notifications` from one process -- caught -/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached -/// thread ai-app-notifications` lines for one enrollment. Guarded here -/// rather than left to match Kotlin's behaviour exactly, since duplicating -/// a live connection is a resource leak with no upside; worth carrying the -/// same guard back to `Notifications.kt` separately. -static RUNNING: AtomicBool = AtomicBool::new(false); - -static STOPPING: AtomicBool = AtomicBool::new(false); - -fn static_int(env: &mut Env, class: &str, field: &str) -> Result { - crate::shell::jcall::get_static_field(env, class, field, "I")?.i() -} - -fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result> { - crate::shell::jcall::call_static_method( - env, - "androidx/core/app/NotificationManagerCompat", - "from", - "(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;", - &[JValue::Object(context)], - )? - .l() -} - -fn create_channel( - env: &mut Env, - manager: &JObject, - id: &str, - name: &str, - importance: i32, -) -> Result<()> { - let id_j = crate::shell::jcall::jstr_obj(env, id)?; - let builder = crate::shell::jcall::new_object( - env, - "androidx/core/app/NotificationChannelCompat$Builder", - "(Ljava/lang/String;I)V", - &[JValue::Object(&id_j), JValue::Int(importance)], - )?; - let name_j = crate::shell::jcall::jstr_obj(env, name)?; - crate::shell::jcall::call_method( - env, - &builder, - "setName", - "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;", - &[JValue::Object(&name_j)], - )?; - let channel = crate::shell::jcall::call_method( - env, - &builder, - "build", - "()Landroidx/core/app/NotificationChannelCompat;", - &[], - )? - .l()?; - crate::shell::jcall::call_method( - env, - manager, - "createNotificationChannel", - "(Landroidx/core/app/NotificationChannelCompat;)V", - &[JValue::Object(&channel)], - )?; - Ok(()) -} - -/// Two channels, because they are two different things to be told -- see -/// `Notifications.kt`'s `createChannels` for the reasoning; the names and -/// importances here are copied from it exactly, since a phone that has -/// seen both apps should not learn two different vocabularies for the -/// same fact. -fn create_channels(env: &mut Env, context: &JObject) -> Result<()> { - let manager = notification_manager(env, context)?; - let default = static_int( - env, - "androidx/core/app/NotificationManagerCompat", - "IMPORTANCE_DEFAULT", - )?; - let min = static_int( - env, - "androidx/core/app/NotificationManagerCompat", - "IMPORTANCE_MIN", - )?; - create_channel( - env, - &manager, - ALERT_CHANNEL, - "Sessions needing attention", - default, - )?; - create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?; - Ok(()) -} - -fn new_intent_for<'l>( - env: &mut Env<'l>, - context: &JObject, - class_name: &str, -) -> Result> { - let target_class = crate::shell::jcall::find_class(env, class_name)?; - crate::shell::jcall::new_object( - env, - "android/content/Intent", - "(Landroid/content/Context;Ljava/lang/Class;)V", - &[JValue::Object(context), JValue::Object(&target_class)], - ) -} - -/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s -/// `sessionIntent`, including building the URI through `Uri.Builder` -/// rather than string concatenation, for the same reason: an id needing -/// escaping must survive the round trip. -fn session_intent<'l>( - env: &mut Env<'l>, - context: &JObject, - session_id: &str, -) -> Result> { - let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?; - let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?; - crate::shell::jcall::call_method( - env, - &intent, - "setAction", - "(Ljava/lang/String;)Landroid/content/Intent;", - &[JValue::Object(&action_view)], - )?; - let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?; - let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?; - crate::shell::jcall::call_method( - env, - &builder, - "scheme", - "(Ljava/lang/String;)Landroid/net/Uri$Builder;", - &[JValue::Object(&scheme)], - )?; - let authority = crate::shell::jcall::jstr_obj(env, "session")?; - crate::shell::jcall::call_method( - env, - &builder, - "authority", - "(Ljava/lang/String;)Landroid/net/Uri$Builder;", - &[JValue::Object(&authority)], - )?; - let path = crate::shell::jcall::jstr_obj(env, session_id)?; - crate::shell::jcall::call_method( - env, - &builder, - "appendPath", - "(Ljava/lang/String;)Landroid/net/Uri$Builder;", - &[JValue::Object(&path)], - )?; - let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])? - .l()?; - crate::shell::jcall::call_method( - env, - &intent, - "setData", - "(Landroid/net/Uri;)Landroid/content/Intent;", - &[JValue::Object(&uri)], - )?; - Ok(intent) -} - -fn pending_activity<'l>( - env: &mut Env<'l>, - context: &JObject, - intent: &JObject, -) -> Result> { - let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?; - let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?; - crate::shell::jcall::call_static_method( - env, - "android/app/PendingIntent", - "getActivity", - "(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;", - &[ - JValue::Object(context), - JValue::Int(0), - JValue::Object(intent), - JValue::Int(update_current | immutable), - ], - )? - .l() -} - -fn builder_call<'l>( - env: &mut Env<'l>, - builder: &JObject<'l>, - method: &str, - sig: &str, - args: &[JValue], -) -> Result<()> { - crate::shell::jcall::call_method(env, builder, method, sig, args)?; - Ok(()) -} - -/// The type Android 14+ requires a foreground service to declare, and -/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`. -fn foreground_type(env: &mut Env) -> Result { - let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?; - let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?; - if sdk >= upside_down_cake { - static_int( - env, - "android/content/pm/ServiceInfo", - "FOREGROUND_SERVICE_TYPE_SPECIAL_USE", - ) - } else { - Ok(0) - } -} - -fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result> { - let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?; - let builder = crate::shell::jcall::new_object( - env, - "androidx/core/app/NotificationCompat$Builder", - "(Landroid/content/Context;Ljava/lang/String;)V", - &[JValue::Object(context), JValue::Object(&channel)], - )?; - let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?; - builder_call( - env, - &builder, - "setContentTitle", - "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Object(&title)], - )?; - let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?; - builder_call( - env, - &builder, - "setSmallIcon", - "(I)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Int(icon)], - )?; - builder_call( - env, - &builder, - "setOngoing", - "(Z)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Bool(JNI_TRUE)], - )?; - let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?; - builder_call( - env, - &builder, - "setPriority", - "(I)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Int(priority_min)], - )?; - crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])? - .l() -} - -/// Starts the service if there is a server to connect to, and stops it -/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`. -pub fn sync(env: &mut Env, context: &JObject) -> Result<()> { - let service_intent = - new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?; - if settings::load(env, context)?.is_none() { - crate::shell::jcall::call_method( - env, - context, - "stopService", - "(Landroid/content/Intent;)Z", - &[JValue::Object(&service_intent)], - )?; - return Ok(()); - } - create_channels(env, context)?; - crate::shell::jcall::call_static_method( - env, - "androidx/core/content/ContextCompat", - "startForegroundService", - "(Landroid/content/Context;Landroid/content/Intent;)V", - &[JValue::Object(context), JValue::Object(&service_intent)], - )?; - Ok(()) -} - -pub fn on_start_command(env: &mut Env, service: JObject) -> jint { - match try_start(env, &service) { - Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1), - Ok(false) => { - let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]); - static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2) - } - Err(e) => { - log_error(env, "onStartCommand", &e); - static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2) - } - } -} - -fn try_start(env: &mut Env, service: &JObject) -> Result { - let Some(settings) = settings::load(env, service)? else { - return Ok(false); - }; - let ca = settings::load_pinned_ca(env)?; - let notification = ongoing_notification(env, service)?; - let fg_type = foreground_type(env)?; - crate::shell::jcall::call_static_method( - env, - "androidx/core/app/ServiceCompat", - "startForeground", - "(Landroid/app/Service;ILandroid/app/Notification;I)V", - &[ - JValue::Object(service), - JValue::Int(ONGOING_ID), - JValue::Object(¬ification), - JValue::Int(fg_type), - ], - )?; - - // See `RUNNING`'s doc: a second `onStartCommand` while the loop from - // the first is still going -- the ordinary case for this service, - // since `sync()` is called from more than one place -- must not open - // a second connection. - if RUNNING.swap(true, Ordering::SeqCst) { - return Ok(true); - } - - let vm = env.get_java_vm()?; - let context = env.new_global_ref(service)?; - STOPPING.store(false, Ordering::SeqCst); - std::thread::Builder::new() - .name("ai-app-notifications".to_string()) - .spawn(move || { - let _: jni::errors::Result<()> = vm.attach_current_thread(|env| { - follow_loop(env, &context, settings, &ca); - Ok(()) - }); - }) - .ok(); - Ok(true) -} - -fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) { - while !STOPPING.load(Ordering::SeqCst) { - if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) { - let _ = follow_notifications(&transport, |notification| { - if let Err(e) = show(env, context, ¬ification) { - log_error(env, "show", &e); - } - !STOPPING.load(Ordering::SeqCst) - }); - } - if STOPPING.load(Ordering::SeqCst) { - return; - } - std::thread::sleep(RECONNECT_DELAY); - } -} - -fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> { - let manager = notification_manager(env, context)?; - let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?; - let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?; - let allowed = if sdk < tiramisu { - true - } else { - let permission = - crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?; - let granted = static_int( - env, - "android/content/pm/PackageManager", - "PERMISSION_GRANTED", - )?; - let result = crate::shell::jcall::call_static_method( - env, - "androidx/core/content/ContextCompat", - "checkSelfPermission", - "(Landroid/content/Context;Ljava/lang/String;)I", - &[JValue::Object(context), JValue::Object(&permission)], - )? - .i()?; - result == granted - }; - let enabled = - crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])? - .z()?; - if !allowed || !enabled { - return Ok(()); - } - let intent = session_intent(env, context, ¬ification.session_id)?; - let pending = pending_activity(env, context, &intent)?; - let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?; - let builder = crate::shell::jcall::new_object( - env, - "androidx/core/app/NotificationCompat$Builder", - "(Landroid/content/Context;Ljava/lang/String;)V", - &[JValue::Object(context), JValue::Object(&channel)], - )?; - let title = crate::shell::jcall::jstr_obj(env, ¬ification.title)?; - builder_call( - env, - &builder, - "setContentTitle", - "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Object(&title)], - )?; - let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?; - builder_call( - env, - &builder, - "setContentText", - "(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Object(&text)], - )?; - let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?; - builder_call( - env, - &builder, - "setSmallIcon", - "(I)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Int(icon)], - )?; - builder_call( - env, - &builder, - "setContentIntent", - "(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Object(&pending)], - )?; - builder_call( - env, - &builder, - "setAutoCancel", - "(Z)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Bool(JNI_TRUE)], - )?; - let when = (notification.at * 1000.0) as i64; - builder_call( - env, - &builder, - "setWhen", - "(J)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Long(when)], - )?; - builder_call( - env, - &builder, - "setShowWhen", - "(Z)Landroidx/core/app/NotificationCompat$Builder;", - &[JValue::Bool(JNI_TRUE)], - )?; - let built = crate::shell::jcall::call_method( - env, - &builder, - "build", - "()Landroid/app/Notification;", - &[], - )? - .l()?; - let tag = crate::shell::jcall::jstr_obj(env, ¬ification.session_id)?; - crate::shell::jcall::call_method( - env, - &manager, - "notify", - "(Ljava/lang/String;ILandroid/app/Notification;)V", - &[ - JValue::Object(&tag), - JValue::Int(ALERT_ID), - JValue::Object(&built), - ], - )?; - Ok(()) -} - -pub fn on_destroy() { - STOPPING.store(true, Ordering::SeqCst); - // `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own - // comment): the old thread may still be inside a blocked read when a - // new `onStartCommand` follows immediately, which would spawn a - // second one before the first has actually stopped. Narrower than not - // resetting at all -- a service destroyed and never restarted would - // otherwise wedge `RUNNING` true forever -- and no worse than the - // known gap already accepted above. - RUNNING.store(false, Ordering::SeqCst); -} - -pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) { - let message = format!("android-shell: {where_}: {error}"); - let _ = (|| -> Result<()> { - let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?; - let msg = crate::shell::jcall::jstr_obj(env, &message)?; - crate::shell::jcall::call_static_method( - env, - "android/util/Log", - "e", - "(Ljava/lang/String;Ljava/lang/String;)I", - &[JValue::Object(&tag), JValue::Object(&msg)], - )?; - Ok(()) - })(); -} diff --git a/app-rust/src/shell/settings.rs b/app-rust/src/shell/settings.rs deleted file mode 100644 index 0292f38..0000000 --- a/app-rust/src/shell/settings.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Enrollment: where the backend is, and the Keystore-sealed token to -//! reach it. This crate does not reimplement the Android Keystore AES-GCM -//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin -//! class the production app already uses (see `ServerConfig.kt`), through -//! JNI, for two reasons: that code is shared with Dev Updater and already -//! tested, and the sealed value on a real phone is keyed to the exact -//! Keystore alias that class already uses -- reimplementing the crypto -//! here would either duplicate it or invalidate an existing enrollment. - -use jni::Env; -use jni::errors::Result; -use jni::objects::{JObject, JString, JValue}; - -/// Where the backend is and how to authenticate to it -- the Rust twin of -/// `wg-app-link`'s `ServerSettings` data class, read back field by field -/// rather than kept as a live JNI reference, so it can cross a thread -/// boundary (a `JObject` is tied to one `Env`/thread). -#[derive(Debug, Clone)] -pub struct ServerSettings { - pub host: String, - pub port: i32, - pub token: String, -} - -impl ServerSettings { - pub fn base_url(&self) -> String { - format!("https://{}:{}", self.host, self.port) - } -} - -pub(crate) const SCHEME: &str = "aiappshell"; -const KEY_ALIAS: &str = "aiapp-shell-token-key"; -const STORE_CLASS: &str = "com/example/wgapplink/ServerStore"; -const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings"; - -fn new_store<'l>(env: &mut Env<'l>) -> Result> { - let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?; - let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?; - crate::shell::jcall::new_object( - env, - STORE_CLASS, - "(Ljava/lang/String;Ljava/lang/String;)V", - &[JValue::Object(&scheme), JValue::Object(&alias)], - ) -} - -fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result { - let host = get_string(env, settings_obj, "getHost")?; - let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?; - let token = get_string(env, settings_obj, "getToken")?; - Ok(ServerSettings { host, port, token }) -} - -fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result { - let value = - crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?; - let jstr: JString = env.cast_local::(value)?; - jstr.try_to_string(env) -} - -/// The stored enrollment, or `None` when there is not one -- mirrors -/// `ServerConfig.kt`'s `loadServerSettings`. -pub fn load(env: &mut Env, context: &JObject) -> Result> { - let store = new_store(env)?; - let settings_obj = crate::shell::jcall::call_method( - env, - &store, - "load", - "(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;", - &[JValue::Object(context)], - )? - .l()?; - if settings_obj.is_null() { - return Ok(None); - } - Ok(Some(read_settings(env, &settings_obj)?)) -} - -pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> { - let store = new_store(env)?; - let host = crate::shell::jcall::jstr_obj(env, &settings.host)?; - let token = crate::shell::jcall::jstr_obj(env, &settings.token)?; - let settings_obj = crate::shell::jcall::new_object( - env, - SETTINGS_CLASS, - "(Ljava/lang/String;ILjava/lang/String;)V", - &[ - JValue::Object(&host), - JValue::Int(settings.port), - JValue::Object(&token), - ], - )?; - crate::shell::jcall::call_method( - env, - &store, - "save", - "(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V", - &[JValue::Object(context), JValue::Object(&settings_obj)], - )?; - Ok(()) -} - -pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result> { - let store = new_store(env)?; - let settings_obj = crate::shell::jcall::call_method( - env, - &store, - "parseEnrollmentUri", - "(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;", - &[JValue::Object(uri)], - )? - .l()?; - if settings_obj.is_null() { - return Ok(None); - } - Ok(Some(read_settings(env, &settings_obj)?)) -} - -/// The CA this build pins, generated at build time the same way -/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`) -/// but into a plain Java constant, since this module has no Kotlin of its -/// own to generate into. -pub fn load_pinned_ca(env: &mut Env) -> Result> { - let value = crate::shell::jcall::get_static_field( - env, - "com/example/aiapp/shell/PinnedCa", - "PINNED_CA_PEM", - "Ljava/lang/String;", - )? - .l()?; - let jstr: JString = env.cast_local::(value)?; - Ok(jstr.try_to_string(env)?.into_bytes()) -} diff --git a/app-rust/src/shell/share.rs b/app-rust/src/shell/share.rs deleted file mode 100644 index 4929783..0000000 --- a/app-rust/src/shell/share.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crate::client::api::{ApiClient, UreqTransport}; -use jni::Env; -use jni::errors::Result; -use jni::objects::{JObject, JString, JValue}; - -use crate::shell::notify; -use crate::shell::settings; - -const ACTION_SEND: &str = "android.intent.action.SEND"; -const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE"; -const ACTION_VIEW: &str = "android.intent.action.VIEW"; -const EXTRA_TEXT: &str = "android.intent.extra.TEXT"; - -fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result> { - let value = - crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?; - if value.is_null() { - return Ok(None); - } - let jstr: JString = env.cast_local::(value)?; - Ok(Some(jstr.try_to_string(env)?)) -} - -fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> { - let message = crate::shell::jcall::jstr_obj(env, message)?; - crate::shell::jcall::call_static_method( - env, - "com/example/aiapp/shell/MainActivity", - "toast", - "(Landroid/content/Context;Ljava/lang/String;)V", - &[JValue::Object(context), JValue::Object(&message)], - )?; - Ok(()) -} - -pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { - let action = get_string_method(env, intent, "getAction")?; - if matches!( - action.as_deref(), - Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE) - ) { - return handle_share(env, activity, intent); - } - if action.as_deref() != Some(ACTION_VIEW) { - return Ok(()); - } - let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])? - .l()?; - if uri.is_null() { - return Ok(()); - } - let scheme = get_string_method(env, &uri, "getScheme")?; - if scheme.as_deref() != Some(settings::SCHEME) { - return Ok(()); - } - match get_string_method(env, &uri, "getHost")?.as_deref() { - Some("session") => handle_session_open(env, activity, &uri), - Some("enroll") => handle_enrollment(env, activity, &uri), - _ => Ok(()), - } -} - -fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> { - let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else { - return Ok(()); - }; - toast(env, activity, &format!("Opened session {session_id}")) -} - -fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> { - match settings::parse_enrollment_uri(env, uri)? { - Some(parsed) => { - settings::save(env, activity, &parsed)?; - notify::sync(env, activity)?; - toast( - env, - activity, - &format!("Enrolled with {}", parsed.base_url()), - ) - } - None => toast(env, activity, "Not a valid enrollment code"), - } -} - -fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> { - let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?; - let text = crate::shell::jcall::call_method( - env, - intent, - "getStringExtra", - "(Ljava/lang/String;)Ljava/lang/String;", - &[JValue::Object(&extra_text)], - )? - .l()?; - let text = if text.is_null() { - None - } else { - let jstr: JString = env.cast_local::(text)?; - Some(jstr.try_to_string(env)?) - }; - let Some(text) = text.filter(|t| !t.trim().is_empty()) else { - return toast( - env, - activity, - "Nothing to share -- only shared text is supported so far", - ); - }; - - // Network I/O must not run on the calling thread: `handle_intent` is - // called from `onCreate`/`onNewIntent`, both on the main thread, and a - // blocking socket read there is a `NetworkOnMainThreadException`. So - // the actual send happens on a JNI-attached background thread, the - // same shape `notify::try_start`'s follow loop uses; `toast` from that - // thread is safe because `MainActivity.toast` itself hops back to the - // main looper (see that method). - let vm = env.get_java_vm()?; - let activity_ref = env.new_global_ref(activity)?; - std::thread::spawn(move || { - let _: jni::errors::Result<()> = vm.attach_current_thread(|env| { - share_in_background(env, &activity_ref, text); - Ok(()) - }); - }); - Ok(()) -} - -fn share_in_background(env: &mut Env, activity: &JObject, text: String) { - let outcome = attach_to_a_session(env, activity, &text); - let message = match outcome { - Ok(title) => format!("Shared into \"{title}\""), - Err(message) => message, - }; - let _ = toast(env, activity, &message); -} - -fn attach_to_a_session( - env: &mut Env, - activity: &JObject, - text: &str, -) -> std::result::Result { - let settings = settings::load(env, activity) - .map_err(|e| e.to_string())? - .ok_or_else(|| "Not enrolled yet".to_string())?; - let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?; - let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca) - .map_err(|e| e.to_string())?; - let client = ApiClient::new(transport); - let sessions = client.fetch_sessions().map_err(|e| e.to_string())?; - let target = sessions - .into_iter() - .max_by(|a, b| a.last_activity.total_cmp(&b.last_activity)) - .ok_or_else(|| "No session to share into".to_string())?; - client - .send_message(&target.id, text, &[]) - .map_err(|e| e.to_string())?; - Ok(target.title) -} diff --git a/app-rust/.gitignore b/app/.gitignore similarity index 51% rename from app-rust/.gitignore rename to app/.gitignore index ff375e7..b716a86 100644 --- a/app-rust/.gitignore +++ b/app/.gitignore @@ -1,10 +1,7 @@ android-project/.gradle/ android-project/build/ android-project/app/build/ - - -# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every -# Gradle build -- see RUST.md's I2 for the exact command. +# Rebuilt by build-apk.sh before every Gradle build. android-project/app/src/main/jniLibs/ target/ Cargo.lock.orig diff --git a/app-rust/Cargo.lock b/app/Cargo.lock similarity index 99% rename from app-rust/Cargo.lock rename to app/Cargo.lock index 7d3161f..39eaae2 100644 --- a/app-rust/Cargo.lock +++ b/app/Cargo.lock @@ -175,7 +175,6 @@ dependencies = [ "base64", "event-model", "iris", - "jni 0.22.4", "libc", "log", "pulldown-cmark", diff --git a/app-rust/Cargo.toml b/app/Cargo.toml similarity index 95% rename from app-rust/Cargo.toml rename to app/Cargo.toml index 439d6bd..3efc076 100644 --- a/app-rust/Cargo.toml +++ b/app/Cargo.toml @@ -32,10 +32,9 @@ pulldown-cmark = "0.13.4" base64 = "0.23" log = { version = "0.4.34", features = ["std"] } -# Optional so the Compose shell does not link the renderer. +# UI dependencies stay optional so client-only tests do not link the renderer. iris = { path = "../iris", optional = true } tabs-ui = { path = "../iris/tabs-ui", optional = true } -jni = { version = "0.22", optional = true } libc = { version = "0.2.189", optional = true } tokio = { version = "1.53.1", features = ["rt", "time"], optional = true } @@ -55,7 +54,6 @@ fixture = ["screens"] transcript-screen = ["screens"] tabs-screen = ["screens", "dep:tabs-ui"] bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"] -shell = ["dep:jni"] force-gles = ["screens", "iris/force-gles"] [dev-dependencies] diff --git a/app/android-env.sh b/app/android-env.sh deleted file mode 100755 index e11b2be..0000000 --- a/app/android-env.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/sh -# Android SDK environment for this app's Gradle build: locates the SDK and -# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing -# Rust/NDK-specific belongs here. -# -# Source this directly for one-off commands instead of going through the -# full run-android.sh (which also creates/boots the emulator, builds, -# installs, and launches): -# -# . ./android-env.sh -# ./gradlew :androidApp:assembleDebug -# adb devices -# -# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this -# file is meant to be sourced into whatever shell is already running -- -# including a long-lived one a session reuses for unrelated commands -- and -# changing that shell's error-handling options as a side effect of sourcing -# would be surprising. run-android.sh, which does want strict mode, sets its -# own `set -eu` before sourcing this. - -# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't -# silently follow whatever that happens to be set to elsewhere -- e.g. this -# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a -# root-owned install this user can't write to. Everything needed lives under -# the path below instead, matching Android Studio's own default SDK location -# convention on Linux. -SDK_ROOT="$HOME/Android/Sdk" -ANDROID_HOME="$SDK_ROOT" -ANDROID_SDK_ROOT="$SDK_ROOT" -# ~/.local/bin is where the `android` CLI itself installs to (see its own -# installer); adding it here too means sourcing this script guarantees a -# working `android` command even in a shell that hasn't picked up -# ~/.profile yet. -PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH" -# Pin the AVD directory explicitly so avdmanager (creation) and the emulator -# binary (lookup at start time) are guaranteed to agree on where the AVD -# lives -- left to their own defaults they can resolve different locations -# and disagree on whether it exists. -ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}" -mkdir -p "$ANDROID_AVD_HOME" -export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH - -echo "==> Ensuring required SDK packages are installed in $SDK_ROOT" -# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely -# installs anything missing rather than just probing for it -- still -# best-effort (`|| echo`) so a transient network hiccup doesn't abort a -# script sourcing this under `set -e`. -# -# build-tools is needed twice over: by Gradle for this app's own build, and -# by ../server at runtime for `aapt2` (reading a discovered APK's package -# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline). -android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \ - "platforms/android-37.0" "build-tools/37.0.0" \ - "system-images/android-36/google_apis/x86_64" \ - || echo " (non-fatal: see above)" diff --git a/app/android-project/app/build.gradle b/app/android-project/app/build.gradle new file mode 100644 index 0000000..907b98e --- /dev/null +++ b/app/android-project/app/build.gradle @@ -0,0 +1,63 @@ +plugins { + id("com.android.application") +} + +// build-apk.sh places the Rust cdylib in src/main/jniLibs before Gradle runs. +def benchBuild = System.getenv("AI_APP_BENCH") == "1" + +android { + namespace = "dev.iris.android.demo" + compileSdk = 37 + + defaultConfig { + applicationId = "com.example.aiapp" + // 29, not 26: `iris::android::view`'s touch handler dates each + // sample with `MotionEvent.getEventTimeNanos` and + // `getHistoricalEventTimeNanos`, both API 29, and a missing JNI + // method there is a hard crash on the first touch rather than a + // degraded fling. Raised deliberately rather than guarded at + // runtime: nothing this app is built for runs below 29, and an + // untested fallback path is its own defect. `build-apk.sh`'s + // `cargo ndk -P` is kept at the same number. + minSdk = 29 + // targetSdk 35+ supplies real IME overlap under enforced edge-to-edge. + targetSdk = 37 + versionCode = 1 + versionName = "1.0" + manifestPlaceholders = [appLabel: benchBuild ? "AI Sessions bench" : "AI Sessions"] + } + + // The signing key is machine-local; build-apk.sh creates and supplies it. + def keystore = System.getenv("AI_APP_KEYSTORE") + signingConfigs { + if (keystore != null) { + release { + storeFile = file(keystore) + storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") + keyAlias = "ai-app" + keyPassword = storePassword + } + } + } + + buildTypes { + debug { + if (benchBuild) { + applicationIdSuffix ".bench" + } + } + release { + if (benchBuild) { + applicationIdSuffix ".bench" + } + if (keystore != null) { + signingConfig = signingConfigs.release + } + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/app/android-project/app/src/main/AndroidManifest.xml b/app/android-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6663983 --- /dev/null +++ b/app/android-project/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java b/app/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java similarity index 100% rename from app-rust/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java rename to app/android-project/app/src/main/java/dev/iris/android/demo/DevLogProvider.java diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java b/app/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java similarity index 100% rename from app-rust/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java rename to app/android-project/app/src/main/java/dev/iris/android/demo/IrisView.java diff --git a/app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java b/app/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java similarity index 100% rename from app-rust/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java rename to app/android-project/app/src/main/java/dev/iris/android/demo/MainActivity.java diff --git a/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustInputConnection.java b/app/android-project/app/src/main/java/org/linebender/android/rustview/RustInputConnection.java similarity index 100% rename from app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustInputConnection.java rename to app/android-project/app/src/main/java/org/linebender/android/rustview/RustInputConnection.java diff --git a/app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java b/app/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java similarity index 100% rename from app-rust/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java rename to app/android-project/app/src/main/java/org/linebender/android/rustview/RustView.java diff --git a/app-rust/android-project/build.gradle b/app/android-project/build.gradle similarity index 100% rename from app-rust/android-project/build.gradle rename to app/android-project/build.gradle diff --git a/app-rust/android-project/settings.gradle b/app/android-project/settings.gradle similarity index 100% rename from app-rust/android-project/settings.gradle rename to app/android-project/settings.gradle diff --git a/app/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts deleted file mode 100644 index 9473785..0000000 --- a/app/androidApp/build.gradle.kts +++ /dev/null @@ -1,226 +0,0 @@ -plugins { - alias(libs.plugins.androidApplication) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.ktfmt) -} - -// Formatting is the formatter's. The one setting is which of ktfmt's two -// styles: kotlinlang is the 4-space one, which is what this code already -// is -- picking the 2-space default would have reindented every file to -// say nothing. Everything else stays at ktfmt's defaults, deliberately. -// -// ./gradlew :androidApp:ktfmtFormat to apply -// ./gradlew :androidApp:ktfmtCheck to verify -ktfmt { kotlinLangStyle() } - -// The CA this app pins is baked in at build time from the certificates on -// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`, -// which the server generates on first start. AI_APP_CA overrides the path. -// -// Reading it rather than keeping a pasted copy in the source is what makes -// the trust boundary follow the build: an APK built on the backend host -// pins the host's CA and never sees any other, while one built in the dev -// VM pins that VM's throwaway CA and is only good for its emulator. There -// is no second trust anchor to get wrong, and no stale paste to notice -// three days later. It also means the private key never has to exist -// anywhere near this repo. -val pinnedCaPath: String = - System.getenv("AI_APP_CA") - ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + - "/ai-app/certs/ca.pem" - -abstract class GeneratePinnedCert : DefaultTask() { - /** Where the certificate is looked for, reported in failures. */ - @get:Input abstract val caPath: Property - - /** - * The certificate itself, set only when it exists -- so a missing one produces this task's own - * instructions rather than Gradle's "no such input file", which doesn't say what to run. - */ - @get:InputFile - @get:Optional - @get:PathSensitive(PathSensitivity.NONE) - abstract val caCertificate: RegularFileProperty - - /** Wired by AGP through `addGeneratedSourceDirectory`. */ - @get:OutputDirectory abstract val outputDir: DirectoryProperty - - @TaskAction - fun generate() { - val path = caPath.get() - val ca = File(path) - if (!ca.isFile) { - throw GradleException( - "No CA certificate at $path.\n" + - "Start ai-server once on this machine first -- it generates the CA the " + - "app pins, and the certificate has to exist before an APK can embed it.\n" + - "Set AI_APP_CA=/path/to/ca.pem to build against a different one." - ) - } - val pem = ca.readText().trim() - if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) { - throw GradleException("$path is not a PEM certificate.") - } - val file = outputDir.get().file("PinnedCaCertificate.kt").asFile - file.parentFile.mkdirs() - // The PEM must start immediately after the opening quotes: a - // leading newline makes Android's CertificateFactory stop - // recognising the "-----BEGIN" preamble and try to parse the whole - // thing as DER, which fails with an ASN.1 decode error at runtime - // rather than anywhere near this file. - file.writeText( - """ - |// Generated from $path by the generatePinnedCert task. Do not edit. - |package com.example.aiapp - | - |const val PINNED_CA_PEM = ""${'"'}$pem - |""${'"'} - | - """ - .trimMargin() - ) - } -} - -val generatePinnedCert = - tasks.register("generatePinnedCert") { - val ca = file(pinnedCaPath) - caPath.set(pinnedCaPath) - if (ca.isFile) { - caCertificate.set(ca) - } - } - -android { - namespace = "com.example.aiapp" - compileSdk = 37 - - defaultConfig { - applicationId = "com.example.aiapp" - minSdk = 24 - targetSdk = 37 - versionCode = 1 - versionName = "1.0" - // Read by MainActivity to decide, at startup, whether this is the P0 benchmark build - // (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except - // the `bench` build type below, which overrides it. - buildConfigField("boolean", "FIXTURE_MODE", "false") - } - buildFeatures { - // Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields. - buildConfig = true - // Only for the bench build type's resValue("string", "app_name", ...) below. - resValues = true - } - packaging { - resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } - // The one native library here is AndroidX's, a few hundred kilobytes with its symbols. - // Stripping them needs an NDK the release build would otherwise not use; keeping them - // is declared so AGP stops warning that it could not. - jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" } - } - // A release build must be signed, and the key is per machine rather than per repo: it is - // what the phone recognises the app by, and a secret never lives in a checkout (the mount is - // shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here - // through the environment; without it the release build is unsigned, which is fine for - // everything except installing. - val keystore = System.getenv("AI_APP_KEYSTORE") - signingConfigs { - if (keystore != null) { - create("release") { - storeFile = file(keystore) - storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") - keyAlias = "ai-app" - keyPassword = storePassword - } - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - if (keystore != null) signingConfig = signingConfigs.getByName("release") - } - // P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release - // optimisations so a frame time measured here means what release means everywhere else in - // this project, its own application id so it installs beside a real enrollment rather than - // replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session - // instead of asking to be enrolled. Signed with the same key as release -- it never talks - // to a real backend, so there is no CA of its own to mismatch, and a second keystore would - // be one more secret to keep off this machine's shared mount for no benefit. - create("bench") { - initWith(getByName("release")) - // :link (wg-app-link) has no "bench" build type of its own -- it is a library shared - // with dev-updater and has no reason to know this project invented one -- so this says - // which of its build types to link against instead. - matchingFallbacks += listOf("release") - applicationIdSuffix = ".bench" - // "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents, - // Settings): this resValue overrides res/values/strings.xml's app_name for this - // build type alone, and AndroidManifest.xml's android:label reads @string/app_name - // rather than a literal so a build type can override it without touching the - // manifest. - resValue("string", "app_name", "AI Sessions bench") - buildConfigField("boolean", "FIXTURE_MODE", "true") - if (keystore != null) signingConfig = signingConfigs.getByName("release") - } - } - sourceSets { - // The fixture both bench builds (this one and iris's) open with; see - // app/bench-fixture/README.md. Read directly from its own directory rather than copied - // into androidApp/src -- one file to keep in sync with the generator, not two. - getByName("bench").assets.directories.add("../bench-fixture/assets") - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - // minSdk is 24 and UsageScreen formats its countdown with - // java.time, which the platform only has from 26. Without this it - // is a NoClassDefFoundError on 24 and 25 -- an Error, so the - // catch around that code does not stop it. - isCoreLibraryDesugaringEnabled = true - } -} - -// AGP 9 wants generated sources registered through the variant API rather -// than added to a source set, so the task dependency is carried properly. -androidComponents { - onVariants { variant -> - variant.sources.java?.addGeneratedSourceDirectory( - generatePinnedCert, - GeneratePinnedCert::outputDir, - ) - } -} - -dependencies { - // The link both this app and Dev Updater's need in order to reach a - // machine they were enrolled against: the pinned CA, the enrollment - // store, and the QR capture activity. See wg-app-link's README. - implementation(project(":link")) - // Not a library this code calls: it is what `isCoreLibraryDesugaring - // Enabled` above rewrites java.time against, so API 24 and 25 have it. - coreLibraryDesugaring(libs.desugar.jdk.libs) - - implementation(libs.compose.runtime) - implementation(libs.compose.runtime.tracing) - implementation(libs.compose.foundation) - implementation(libs.compose.material3) - implementation(libs.compose.ui) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.zxing.embedded) - implementation(libs.markdown.renderer) - implementation(libs.androidx.exifinterface) - - // The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what - // lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are - // `kotlin.test`, so the tests name no framework; JUnit is what runs them. - testImplementation(libs.kotlin.test.junit5) - testImplementation(libs.junit.jupiter) - testRuntimeOnly(libs.junit.platform.launcher) -} - -// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task. -tasks.withType().configureEach { useJUnitPlatform() } diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml deleted file mode 100644 index 36ce31d..0000000 --- a/app/androidApp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,123 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt deleted file mode 100644 index d652645..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Ansi.kt +++ /dev/null @@ -1,311 +0,0 @@ -package com.example.aiapp - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration - -/** - * The sixteen colours a terminal program names, and the two it assumes. - * - * Its own palette rather than the syntax one: a program that prints in red has chosen red, where a - * highlighter's colours are this app's reading of somebody else's code. They come out of the same - * Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one - * table -- adding a syntax role to this list would silently move `ls`'s directory blue. - */ -data class AnsiPalette( - /** Indexes 0-7, then 8-15 bright, in the terminal's own order. */ - val colours: List, - /** What uncoloured text is, needed only where a style has to state a colour. */ - val foreground: Color, - /** What the text sits on, needed for reverse video. */ - val background: Color, -) - -/** - * What a tool printed, with its terminal styling applied and everything else taken out. - * - * Bash output arrives exactly as the program wrote it, escape sequences included, and drawn - * verbatim those are line noise in the middle of the thing being read. Stripping them all would be - * the other half-answer -- colour is often the whole of what a diff or a test run is saying. - * - * So the sequences that decide how text *looks* become spans, and every other one is dropped rather - * than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no - * meaning in a scrolling document. - * - * A carriage return is honoured the way a terminal honours it: what was written since the last line - * break is thrown away and the line starts again. That is what makes a progress bar show its final - * state rather than every state it passed through. - * - * Not a composable, and the palette is a parameter, so this can be remembered against the text it - * parsed rather than re-run on every recomposition of the card holding it. - */ -fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString { - // The common case by a long way -- nothing to do, and nothing allocated to find that out. - if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text) - - val runs = mutableListOf() - var sgr = Sgr.PLAIN - var at = 0 - val plain = StringBuilder() - - fun flush() { - if (plain.isNotEmpty()) { - runs.add(Run(plain.toString(), sgr.span(palette))) - plain.clear() - } - } - - while (at < text.length) { - val c = text[at] - when { - c == ESC -> { - flush() - at = - skipEscape(text, at) { params, final -> - if (final == 'm') sgr = sgr.apply(params, palette) - } - } - // A bare carriage return rewrites the line. One before a newline is the other half of a - // Windows line ending: it rewrites nothing, and it is dropped rather than kept, since - // that pair is one line break. - c == '\r' && text.getOrNull(at + 1) != '\n' -> { - flush() - dropLine(runs) - at++ - } - c == '\r' -> at++ - // Everything printable, plus the two control characters that are layout rather than - // terminal commands. A stray bell or backspace goes for the same reason a cursor move - // does. - c >= ' ' || c == '\n' || c == '\t' -> { - plain.append(c) - at++ - } - else -> at++ - } - } - flush() - - return buildAnnotatedString { - runs.forEach { run -> - if (run.style == null) { - append(run.text) - } else { - val pushed = pushStyle(run.style) - append(run.text) - pop(pushed) - } - } - } -} - -/** One stretch of text that shares a style. */ -private class Run(val text: String, val style: SpanStyle?) - -/** Throws away everything written since the last line break, as a carriage return does. */ -private fun dropLine(runs: MutableList) { - while (runs.isNotEmpty()) { - val last = runs.removeAt(runs.size - 1) - val breakAt = last.text.lastIndexOf('\n') - if (breakAt >= 0) { - runs.add(Run(last.text.substring(0, breakAt + 1), last.style)) - return - } - } -} - -private const val ESC = '\u001B' - -private const val BELL = '\u0007' - -/** - * Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte. - * - * One reader for every kind, because the point is to *leave* them all behind: a sequence this did - * not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI - * (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones. - */ -private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int { - val next = text.getOrNull(at + 1) ?: return at + 1 - return when (next) { - '[' -> { - var end = at + 2 - while (end < text.length && text[end] !in CSI_FINAL) end++ - if (end >= text.length) { - // Cut off mid-sequence, which is what a stream that has not finished arriving looks - // like: drop the fragment rather than printing it, and the whole sequence arrives - // with the next delta. - text.length - } else { - onCsi(text.substring(at + 2, end), text[end]) - end + 1 - } - } - ']', - 'P', - 'X', - '^', - '_' -> { - // Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC. - var end = at + 2 - while (end < text.length) { - if (text[end] == BELL) return end + 1 - if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2 - end++ - } - text.length - } - else -> at + 2 - } -} - -/** The bytes that end a CSI sequence. */ -private val CSI_FINAL = '@'..'~' - -/** Everything an SGR sequence can turn on, as the terminal tracks it. */ -private data class Sgr( - val fg: Color?, - val bg: Color?, - val bold: Boolean, - val dim: Boolean, - val italic: Boolean, - val underline: Boolean, - val strike: Boolean, - val reverse: Boolean, -) { - /** Null while nothing is set, so unstyled output costs no spans at all. */ - fun span(palette: AnsiPalette): SpanStyle? { - if (this == PLAIN) return null - val front = if (reverse) bg ?: palette.background else fg - val back = if (reverse) fg ?: palette.foreground else bg - // Dim has to have a colour to dim, so where none was named it dims the ordinary one. - val stated = front ?: palette.foreground.takeIf { dim } - return SpanStyle( - color = - stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified, - background = back ?: Color.Unspecified, - fontWeight = if (bold) FontWeight.Bold else null, - fontStyle = if (italic) FontStyle.Italic else null, - textDecoration = - when { - underline && strike -> - TextDecoration.combine( - listOf(TextDecoration.Underline, TextDecoration.LineThrough) - ) - underline -> TextDecoration.Underline - strike -> TextDecoration.LineThrough - else -> null - }, - ) - } - - /** - * This state with [params] applied -- one `ESC[…m`, which carries any number of them. - * - * A code this does not model is ignored rather than reset from: the program meant something by - * it, and starting again would also drop the codes beside it that are understood. - */ - fun apply(params: String, palette: AnsiPalette): Sgr { - // `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too. - val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 } - var state = this - var at = 0 - while (at < codes.size) { - val code = codes[at] - state = - when (code) { - 0 -> PLAIN - 1 -> state.copy(bold = true) - 2 -> state.copy(dim = true) - 3 -> state.copy(italic = true) - 4 -> state.copy(underline = true) - 7 -> state.copy(reverse = true) - 9 -> state.copy(strike = true) - 21, - 22 -> state.copy(bold = false, dim = false) - 23 -> state.copy(italic = false) - 24 -> state.copy(underline = false) - 27 -> state.copy(reverse = false) - 29 -> state.copy(strike = false) - in 30..37 -> state.copy(fg = palette.colours[code - 30]) - in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8]) - in 40..47 -> state.copy(bg = palette.colours[code - 40]) - in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8]) - 39 -> state.copy(fg = null) - 49 -> state.copy(bg = null) - 38, - 48 -> { - val (colour, last) = extendedColour(codes, at, palette) - at = last - if (code == 38) state.copy(fg = colour) else state.copy(bg = colour) - } - else -> state - } - at++ - } - return state - } - - companion object { - val PLAIN = - Sgr( - fg = null, - bg = null, - bold = false, - dim = false, - italic = false, - underline = false, - strike = false, - reverse = false, - ) - } -} - -/** How much of its colour dim text keeps: enough to read, little enough to recede. */ -private const val DIM_ALPHA = 0.65f - -/** - * The colour named by a `38`/`48` at [at], and the index of that colour's last parameter. - * - * Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of - * that table are the palette's own, so a program asking for "colour 1" through either spelling gets - * the same red. - */ -private fun extendedColour(codes: List, at: Int, palette: AnsiPalette): Pair = - when (codes.getOrNull(at + 1)) { - 5 -> { - val n = codes.getOrNull(at + 2) - if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2 - } - 2 -> { - val r = codes.getOrNull(at + 2) - val g = codes.getOrNull(at + 3) - val b = codes.getOrNull(at + 4) - if (r == null || g == null || b == null) null to at + 1 - else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4 - } - else -> null to at + 1 - } - -/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */ -private fun indexedColour(n: Int, palette: AnsiPalette): Color = - when { - n < 0 -> palette.foreground - n < 16 -> palette.colours[n] - n < 232 -> { - val i = n - 16 - Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6]) - } - n < 256 -> { - val grey = 8 + (n - 232) * 10 - Color(grey, grey, grey) - } - else -> palette.foreground - } - -/** The six levels of each channel in the 256-colour cube, as xterm defines them. */ -private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt deleted file mode 100644 index 22f661c..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ /dev/null @@ -1,1342 +0,0 @@ -package com.example.aiapp - -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import org.json.JSONArray -import org.json.JSONObject - -// The REST half of the backend's surface (see server/src/routes.rs for the table); the SSE half is -// EventStream.kt. All blocking network calls -- invoke from a background dispatcher. Each throws -// ApiException carrying the server's own explanation where it sent one, since those messages are -// written to be read on this screen. - -// Shared with EventStream.kt, which connects the same way but then reads without a deadline. -const val CONNECT_TIMEOUT_MS = 5000 -private const val READ_TIMEOUT_MS = 5000 - -/** - * A request that did not produce what it asked for, carrying the server's own wording where it sent - * some. - * - * [status] is the HTTP status where there was a response at all, and null where the server was - * never reached. Callers that need it need it because the *same* failure is two different things to - * do: a 409 from a write is "somebody else changed this, here are three ways out". Nothing should - * branch on it to decide what to *say* -- the message is what says that. - */ -class ApiException(message: String, val status: Int? = null, cause: Throwable? = null) : - Exception(message, cause) - -/** - * Runs one request against the backend, with the pinned TLS setup, the bearer token, and the - * failure translation every call needs. [readBody] gets the connected, already-status-checked - * connection. - * - * @param readTimeoutMs how long to wait on the response body. The SSE stream doesn't come through - * here -- an event stream has no bounded read time. - */ -fun requestFromServer( - settings: ServerSettings, - path: String, - method: String = "GET", - jsonBody: String? = null, - /** - * A request body written as it is produced -- the upload path. Sent chunked, since what a - * writer will produce is not known up front and the point is that a file never sits whole in - * memory. - */ - streamBody: Pair Unit>? = null, - readTimeoutMs: Int = READ_TIMEOUT_MS, - readBody: (HttpURLConnection) -> T, -): T { - val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection - try { - connection.applyPinnedTls() - connection.requestMethod = method - connection.connectTimeout = CONNECT_TIMEOUT_MS - connection.readTimeout = readTimeoutMs - connection.setRequestProperty("Authorization", "Bearer ${settings.token}") - if (jsonBody != null) { - connection.doOutput = true - connection.setRequestProperty("Content-Type", "application/json") - connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) } - } else if (streamBody != null) { - connection.doOutput = true - connection.setChunkedStreamingMode(0) - connection.setRequestProperty("Content-Type", streamBody.first) - connection.outputStream.use(streamBody.second) - } - if (connection.responseCode !in 200..299) { - val detail = connection.errorStream?.bufferedReader()?.readText()?.trim() - throw ApiException( - when { - connection.responseCode == 401 -> - "The server rejected this device's token. Re-enroll by scanning " + - "the server's QR (or rotate with --rotate-token and scan the new one)." - detail.isNullOrEmpty() -> - "Server returned HTTP ${connection.responseCode} for $path" - else -> detail - }, - status = connection.responseCode, - ) - } - return readBody(connection) - } catch (e: ApiException) { - throw e - } catch (e: IOException) { - // Surfacing the real exception rather than one canned message for every failure mode is - // what lets this be diagnosed on a device with no logcat access. - throw ApiException( - "Couldn't reach the server at ${settings.baseUrl} " + - "(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " + - "this device able to reach that address (WireGuard up)?", - cause = e, - ) - } catch (e: Exception) { - throw ApiException( - "Reached ${settings.baseUrl}$path but couldn't read its response " + - "(${e::class.simpleName}: ${e.message})", - cause = e, - ) - } finally { - connection.disconnect() - } -} - -private fun HttpURLConnection.jsonObject(): JSONObject = - JSONObject(inputStream.bufferedReader().readText()) - -private fun HttpURLConnection.jsonObjects(parse: (JSONObject) -> T): List = - JSONArray(inputStream.bufferedReader().readText()).mapObjects(parse) - -private fun JSONArray.mapObjects(parse: (JSONObject) -> T): List = - (0 until length()).map { parse(getJSONObject(it)) } - -private fun JSONArray.strings(): List = (0 until length()).map { getString(it) } - -private fun String.urlEncoded(): String = java.net.URLEncoder.encode(this, Charsets.UTF_8.name()) - -// One row of GET /sessions. A session names the machine it runs on and which of that machine's -// providers it runs. -data class SessionSummary( - val id: String, - /** - * Id of the machine this session runs on. Only ever used to *address* that machine -- to pick - * this session's row out of the per-machine usage snapshots. Never shown; [setupName] is what a - * reader sees, and holding both invites showing the wrong one. - */ - val setup: String, - /** The machine's current label. This is the one to display; [setup] is never shown. */ - val setupName: String, - val provider: String, - val title: String, - val model: String?, - /** - * Whether the conversation would outlive deleting this session, decided by the server from the - * provider's kind rather than here from its name. - * - * What it licenses is narrow, and the delete dialog is worded to match: the driver keeps its - * own record somewhere this app's delete does not reach. It is not a promise that the file is - * still there, and re-importing is not a restore. - */ - val keepsOwnTranscript: Boolean, - /** How much the session asks before acting; null when it was never set. */ - val permissionMode: String?, - /** - * How hard the model thinks, or null for the CLI's own default. - * - * Null is a level somebody can choose, not only one to start in -- see [EFFORT_LEVELS]. It is - * reported rather than assumed for the same reason [permissionMode] is. - */ - val effort: String?, - /** - * Whether a thinking level does anything here -- a Claude CLI session, not a llama or echo one. - * - * Asked of the server rather than worked out from the provider's name, because this is a - * property of the driver's *kind* and the phone only has the name. - */ - val takesEffort: Boolean, - /** - * Whether this continues a session the machine already had, which changes what deleting means. - */ - val imported: Boolean, - /** - * Whether this session announces itself when it wants attention. - * - * Reported rather than assumed, for the same reason [permissionMode] is: a switch that draws - * itself from a default is one you can turn off while believing you are reading it. - */ - val notify: Boolean, - /** - * Whether this session sends itself a message once its account's usage limit lifts, and what - * that message says. - * - * The message is what the server would actually send, with its own default already filled in, - * so the field shows the words rather than an empty box standing for them. - */ - val autoResume: Boolean, - val autoResumeMessage: String, - /** - * When the server next intends to check whether the limit has lifted, in epoch seconds, or null - * when nothing is waiting. - * - * A time to *ask*, not a time to resume: the server checks the meter at that moment and waits - * again if the limit is still on. Worded that way wherever it is shown, because a promise this - * app cannot keep is worse than no time at all. - */ - val resumeAt: Double?, - /** - * The directory the session works in, or null where it was never given one. - * - * Null is not "the home directory": it is the session never having been told. Shown as unset - * rather than filled in with a guess, so a reader changing it is choosing rather than - * confirming. - */ - val cwd: String?, - /** - * How much context this session is holding, as the server last measured it. - * - * Null where nothing has been measured: a session that has not run a turn, a provider that does - * not report usage, or a clear nobody has run a turn since. That is not zero, and the status - * row says so in words rather than drawing an empty context for a conversation that may be - * full. - */ - val contextTokens: Long?, - /** - * The longest edge an image should have when it reaches this session, or null where the - * provider has no limit. - * - * Null and "a big number" are different answers, and only the first stays true. Decided by the - * server because that is where a provider's kind is known. - */ - val maxImageEdge: Int?, - /** - * Which of `GET /usage`'s snapshots is about this session, and null where nothing meters it. - * - * The rate-limit bar answers a question about an *account*, and what decides which account -- - * if any -- is the provider this session runs, not the machine it runs on. Pairing by machine - * alone drew the Claude CLI's five-hour window under every echo session on a machine that also - * has the CLI: a quota that session cannot spend and could never run down. Decided by the - * server for the same reason [maxImageEdge] is -- it is a fact about the provider's kind, and - * this app has only its name. - */ - val usageProvider: String?, - val status: String, - val lastActivity: Double, - /** - * How many subagents this session has, however their own status now reads. - * - * A directory listing on the server rather than a status read per subagent, so the list stays - * cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server - * that predates subagents, so this app still opens against one. - */ - val subagents: Int, -) - -private fun parseSession(session: JSONObject) = - SessionSummary( - id = session.getString("id"), - setup = session.getString("setup"), - keepsOwnTranscript = session.optBoolean("keepsOwnTranscript", false), - setupName = session.getString("setupName"), - provider = session.getString("provider"), - title = session.getString("title"), - model = session.optString("model").ifEmpty { null }, - permissionMode = session.optString("permissionMode").ifEmpty { null }, - effort = session.optString("effort").ifEmpty { null }, - takesEffort = session.optBoolean("takesEffort", false), - imported = session.optBoolean("imported", false), - notify = session.optBoolean("notify", true), - autoResume = session.optBoolean("autoResume", false), - // The server sends its own default rather than nothing, so an empty answer means an older - // server -- and this app's word for it is the same word. - autoResumeMessage = - session.optString("autoResumeMessage").ifEmpty { DEFAULT_RESUME_MESSAGE }, - resumeAt = if (session.has("resumeAt")) session.getDouble("resumeAt") else null, - cwd = session.optString("cwd").ifEmpty { null }, - contextTokens = - if (session.has("contextTokens")) session.getLong("contextTokens") else null, - maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, - usageProvider = session.optString("usageProvider").ifEmpty { null }, - status = session.getString("status"), - lastActivity = session.getDouble("lastActivity"), - subagents = session.optInt("subagents", 0), - ) - -fun fetchSessions(settings: ServerSettings): List = - requestFromServer(settings, "/sessions") { it.jsonObjects(::parseSession) } - -/** - * One session as the server has it now. - * - * For screens whose controls are *set to* something rather than merely showing it. A screen opened - * from a list row carries the row the list last fetched, which is a snapshot: fine for a title, - * wrong for a switch, since a stale row shows a position that may have been changed since. - */ -fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = - requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } - -/** - * One row of `GET /sessions/{id}/subagents`, oldest first. - * - * A subagent is a second transcript owned by a session -- no process, no controls of its own -- so - * this carries only what a card needs to draw and to open it; see docs/SUBAGENTS.md. [status] is - * "running", "exited" or "unknown": a subagent whose session is not itself running cannot be - * running, and the list says so rather than reporting a state that cannot hold. - */ -data class SubagentSummary( - val id: String, - val title: String, - val status: String, - val created: Double, - val lastActivity: Double, -) - -fun fetchSubagents(settings: ServerSettings, sessionId: String): List = - requestFromServer(settings, "/sessions/$sessionId/subagents") { - it.jsonObjects { row -> - SubagentSummary( - id = row.getString("id"), - title = row.getString("title"), - status = row.getString("status"), - created = row.getDouble("created"), - lastActivity = row.getDouble("lastActivity"), - ) - } - } - -// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's -// config.ron appears here with no app rebuild. -// -// One list rather than two. A provider only exists on a machine that has it installed, so offering -// machines and providers as independent choices would offer pairs that cannot work. -data class Provider(val name: String, val kind: String, val models: List) - -/** - * A machine, and what it can run. [address] is absent for the backend itself. - * - * [id] is stable and [name] is not: renaming a machine keeps its sessions, so everything that - * refers to a setup uses the id and everything a person reads uses the name. - */ -data class Setup( - val id: String, - val name: String, - val address: String?, - val providers: List, -) - -private fun parseProvider(provider: JSONObject) = - Provider( - name = provider.getString("name"), - kind = provider.getString("kind"), - // Omitted entirely when the provider offers none. - models = provider.optJSONArray("models")?.strings().orEmpty(), - ) - -private fun parseSetup(setup: JSONObject) = - Setup( - id = setup.getString("id"), - name = setup.getString("name"), - address = setup.optString("address").ifEmpty { null }, - providers = setup.getJSONArray("providers").mapObjects(::parseProvider), - ) - -fun fetchSetups(settings: ServerSettings): List = - requestFromServer(settings, "/setups") { it.jsonObjects(::parseSetup) } - -/** - * A Claude Code session already on a machine, which can be continued here. - * - * Identified by [id] and never by a path. The server resolves which file that is, so this app has - * no way to ask it to read one. - */ -data class Importable( - val id: String, - val cwd: String, - val title: String, - val modified: Double, - val lines: Int, - /** - * Size of the session file in bytes. Worth a place on the row because it is the only thing - * there that predicts what continuing the session costs, and the line count does not: these - * transcripts embed screenshots as base64, so a single line can be a megabyte. - */ - val bytes: Long, - /** - * Tokens the model was holding at the last turn, or null if no turn has recorded any. It - * disagrees with [bytes] in the direction that matters: most of a large transcript is usually - * history from before a compaction, which the model is no longer given. - */ - val contextTokens: Long?, - /** Whether [title] is a name somebody chose rather than the last thing said in the session. */ - val named: Boolean, - /** - * Whether a Claude Code is running this session right now. - * - * "unknown" is a third answer and not a synonym for "no": the machine may keep no record of - * what is running. The server refuses an import of a "yes"; the row says so before you press - * it. - */ - val inUse: String, - /** - * What this server is doing to the session right now -- "importing" or "deleting" -- or null. - * - * The server's answer rather than the phone's, because the work outlives the screen that asked - * for it: a phone that was asleep never saw the events that said so. - */ - val pending: String?, - /** - * How the last attempt on this row failed, if it did. Kept by the server until something - * replaces it, for the same reason [pending] is the server's to answer. - */ - val error: String?, -) - -/** - * One frame of `GET /setups/{id}/importable/events`: an operation starting, finishing or failing. - * - * [operation] is only set by a start and [message] only by a failure -- the three states are every - * way an operation can be, and each carries exactly what that state knows. - */ -data class ImportableChange( - val session: String, - val state: String, - val operation: String?, - val message: String?, -) - -fun parseImportableChange(payload: String): ImportableChange? = - try { - val frame = JSONObject(payload) - ImportableChange( - session = frame.getString("session"), - state = frame.getString("state"), - operation = frame.optString("operation").takeIf { it.isNotEmpty() }, - message = frame.optString("message").takeIf { it.isNotEmpty() }, - ) - } catch (_: org.json.JSONException) { - // A frame this build does not understand is not a reason to drop the stream: the listing is - // the truth and will say what happened whatever this missed. - null - } - -/** - * What a machine has that could be continued. - * - * The slowest call this app makes, and it was the only expensive one left on the 5 second default - * -- which is how it came to time out against a server answering perfectly well. Listing means - * reading every transcript Claude Code has ever written: about four seconds against a gigabyte of - * them before the tunnel adds anything. A timeout is for a server that has stopped answering. - */ -fun fetchImportable(settings: ServerSettings, setup: String): List = - requestFromServer(settings, "/setups/$setup/importable", readTimeoutMs = 60000) { - it.jsonObjects { session -> - Importable( - id = session.getString("id"), - cwd = session.optString("cwd"), - title = session.optString("title"), - modified = session.optDouble("modified", 0.0), - lines = session.optInt("lines", 0), - bytes = session.optLong("bytes", 0L), - // Absent means nothing has been measured, which is not a context of zero. - contextTokens = - if (session.isNull("contextTokens")) null - else session.optLong("contextTokens").takeIf { it > 0L }, - // Absent means an older backend that cannot answer, which is what "unknown" says. - inUse = session.optString("inUse", "unknown"), - named = session.optBoolean("named", false), - pending = session.optString("pending").takeIf { it.isNotEmpty() }, - error = session.optString("error").takeIf { it.isNotEmpty() }, - ) - } - } - -/** - * How to reach a machine. Deliberately carries no command: the server discovers what a machine can - * run by asking it, so this app has no way to introduce something to run. - * - * [identityFile] is a path on the *backend*, not a key -- private keys do not travel. - */ -data class SshDetails( - val address: String, - val port: Int? = null, - val identityFile: String? = null, - /** - * Where files attached from here land on that machine; null for the session's own directory. - */ - val attachmentsDir: String? = null, - /** - * Where that machine keeps its GGUF models; null for the same place the backend keeps its own - * (`~/.local/share/ai-app/models`, read on that machine). A llama.cpp session serves the file - * from the machine it runs on, so this is where its models are looked for and listed. - */ - val modelsDir: String? = null, -) - -private fun SshDetails.toJson() = - JSONObject().put("address", address).apply { - if (port != null) put("port", port) - if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) - if (!attachmentsDir.isNullOrBlank()) put("attachmentsDir", attachmentsDir) - if (!modelsDir.isNullOrBlank()) put("modelsDir", modelsDir) - } - -/** What a machine turns out to have, without saving anything. */ -fun probeSetup(settings: ServerSettings, ssh: SshDetails?): List = - requestFromServer( - settings, - "/setups/probe", - method = "POST", - jsonBody = JSONObject().apply { if (ssh != null) put("ssh", ssh.toJson()) }.toString(), - readTimeoutMs = 40000, - ) { - it.jsonObjects(::parseProvider) - } - -fun addSetup(settings: ServerSettings, name: String, ssh: SshDetails?): Setup = - requestFromServer( - settings, - "/setups", - method = "POST", - jsonBody = - JSONObject() - .put("name", name) - .apply { if (ssh != null) put("ssh", ssh.toJson()) } - .toString(), - readTimeoutMs = 40000, - ) { - parseSetup(it.jsonObject()) - } - -/** Renames a machine, and optionally asks it again what it has. */ -fun updateSetup( - settings: ServerSettings, - id: String, - name: String? = null, - rediscover: Boolean = false, -): Setup = - requestFromServer( - settings, - "/setups/${id.urlEncoded()}", - method = "PUT", - jsonBody = - JSONObject() - .apply { - if (name != null) put("name", name) - if (rediscover) put("rediscover", true) - } - .toString(), - readTimeoutMs = 40000, - ) { - parseSetup(it.jsonObject()) - } - -fun deleteSetup(settings: ServerSettings, id: String) { - requestFromServer(settings, "/setups/${id.urlEncoded()}", method = "DELETE") {} -} - -/** - * Spawns a session and returns it as the list would show it. [setup] names the machine and - * [provider] one of the things that machine offers. - */ -fun spawnSession( - settings: ServerSettings, - setup: String, - provider: String, - title: String, - model: String? = null, - cwd: String? = null, - permissionMode: String? = null, - /** Null for whatever the server's default is; see [fetchDefaultEffort]. */ - effort: String? = null, - params: Map = emptyMap(), - /** Continue this Claude Code session instead of starting an empty one. */ - import: String? = null, -): SessionSummary = - requestFromServer( - settings, - "/sessions", - method = "POST", - jsonBody = - JSONObject() - .put("setup", setup) - .put("provider", provider) - .put("title", title) - .apply { - if (!model.isNullOrBlank()) put("model", model) - if (!cwd.isNullOrBlank()) put("cwd", cwd) - if (!permissionMode.isNullOrBlank()) put("permissionMode", permissionMode) - if (!effort.isNullOrBlank()) put("effort", effort) - if (!import.isNullOrBlank()) put("import", import) - if (params.isNotEmpty()) { - put("params", JSONObject(params.toMap())) - } - } - .toString(), - readTimeoutMs = 30000, - ) { connection -> - parseSession(connection.jsonObject()) - } - -fun sendMessage( - settings: ServerSettings, - sessionId: String, - text: String, - attachmentIds: List = emptyList(), -) { - requestFromServer( - settings, - "/sessions/$sessionId/message", - method = "POST", - jsonBody = - JSONObject() - .put("text", text) - .put("attachmentIds", JSONArray(attachmentIds)) - .toString(), - ) {} -} - -/** - * Takes back a message the session has not read yet, named by the id its `messageQueued` carried. - * - * Throws rather than returning an outcome, because both ways of failing are things the reader has - * to be told: 409 means the session was already given it, and 404 means nothing is waiting under - * that id. The bubble disappearing arrives on the event stream, so every device drops it. - */ -fun unqueueMessage(settings: ServerSettings, sessionId: String, messageId: String) { - requestFromServer( - settings, - "/sessions/$sessionId/unqueue", - method = "POST", - jsonBody = JSONObject().put("messageId", messageId).toString(), - ) {} -} - -/** - * Moves a session to a different working directory. - * - * The server checks the directory is there and refuses if it is not -- a mistyped path accepted - * here would surface much later, as a session that would not start. - * - * Its process is **stopped**, because a working directory is settled when the process is spawned. - * The next thing said to the session starts it again in the new one. - */ -fun setSessionCwd(settings: ServerSettings, sessionId: String, cwd: String) { - requestFromServer( - settings, - "/sessions/$sessionId/cwd", - method = "POST", - jsonBody = JSONObject().put("cwd", cwd).toString(), - readTimeoutMs = 30000, - ) {} -} - -/** - * Uploads one attachment, streamed by [write]; the returned id goes into [sendMessage]. [name] is - * what the server keeps a file under and tells the session; for an image it is ignored, since the - * model is shown the picture rather than told its name. - */ -fun uploadAttachment( - settings: ServerSettings, - sessionId: String, - mime: String, - name: String, - write: (java.io.OutputStream) -> Unit, -): String { - val boundary = "----aiapp-${System.currentTimeMillis()}" - // The header is a line: a quote or a line break in the name would end it early. - val safeName = name.replace(Regex("[\"\r\n]"), "_") - val head = - ("--$boundary\r\n" + - "Content-Disposition: form-data; name=\"file\"; filename=\"$safeName\"\r\n" + - "Content-Type: $mime\r\n\r\n") - .encodeToByteArray() - val tail = "\r\n--$boundary--\r\n".encodeToByteArray() - return requestFromServer( - settings, - "/sessions/$sessionId/attachments", - method = "POST", - streamBody = - "multipart/form-data; boundary=$boundary" to - { out -> - out.write(head) - write(out) - out.write(tail) - }, - // Long: a trace is hundreds of megabytes, and the server copies it on to a remote machine - // before answering. - readTimeoutMs = 600000, - ) { connection -> - connection.jsonObject().getString("id") - } -} - -/** - * One entry of a directory on the machine a setup names. - * - * [kind] is the *target's* where the entry is a symlink, so a link to a directory descends; [link] - * still says it is one. Neither is worked out here -- the machine answers both. - */ -data class DirEntry( - val name: String, - val kind: String, - val size: Long, - val modified: Long, - val link: Boolean, -) { - val isDirectory: Boolean - get() = kind == "directory" -} - -/** A directory's entries, and the path the machine resolved the request to. */ -data class Listing(val path: String, val entries: List) - -/** - * What reading a file produced. - * - * Four cases, because they are four different things to draw and none is an error the screen can - * shrug off: content, something that is not text, something too big to have sent, and (as - * [ApiException]) the machine's own refusal. A file with nothing in it is [FileContent.Text] with - * an empty string, which is what it is. - */ -sealed class FileContent { - abstract val path: String - abstract val size: Long - abstract val modified: Long - - data class Text( - override val path: String, - override val size: Long, - override val modified: Long, - /** What a write is given back, to prove the file is still the one that was read. */ - val sha256: String, - val content: String, - ) : FileContent() - - data class Binary( - override val path: String, - override val size: Long, - override val modified: Long, - ) : FileContent() - - data class TooBig( - override val path: String, - override val size: Long, - override val modified: Long, - ) : FileContent() -} - -/** What a file is after a write, so the editor's precondition is fresh without a second read. */ -data class Written(val size: Long, val modified: Long, val sha256: String) - -/** Everything in [path] on the machine [setup] names, and what [path] resolved to. */ -fun fetchDir(settings: ServerSettings, setup: String, path: String): Listing = - requestFromServer( - settings, - "/setups/${setup.urlEncoded()}/dir?path=${path.urlEncoded()}", - readTimeoutMs = 30000, - ) { connection -> - val body = connection.jsonObject() - Listing( - path = body.getString("path"), - entries = - body.getJSONArray("entries").mapObjects { entry -> - DirEntry( - name = entry.getString("name"), - kind = entry.getString("kind"), - size = entry.optLong("size"), - modified = entry.optLong("modified"), - link = entry.optBoolean("link", false), - ) - }, - ) - } - -/** One file's content, or which of the reasons there is none to show. */ -fun fetchFile(settings: ServerSettings, setup: String, path: String): FileContent = - requestFromServer( - settings, - "/setups/${setup.urlEncoded()}/file?path=${path.urlEncoded()}", - // A megabyte over the tunnel, and a `stat` plus a `sha256sum` on the far machine before any - // of it moves. Well clear of that rather than just above it. - readTimeoutMs = 60000, - ) { connection -> - val body = connection.jsonObject() - val at = body.getString("path") - val size = body.optLong("size") - val modified = body.optLong("modified") - when (val kind = body.getString("kind")) { - "text" -> - FileContent.Text( - at, - size, - modified, - body.getString("sha256"), - body.getString("content"), - ) - "binary" -> FileContent.Binary(at, size, modified) - "tooBig" -> FileContent.TooBig(at, size, modified) - // A backend that has learned a fifth answer. Reported rather than guessed at: picking - // the nearest of the four would draw something confident about a state never seen. - else -> - throw ApiException( - "The server described this file as \"$kind\", which this app does not know how to show." - ) - } - } - -/** - * Replaces a file's contents, but only while it still hashes to [ifSha256]. - * - * The refusal is a 409 carrying the server's wording, which is what the conflict dialog shows -- an - * agent editing the same file while somebody reads it is the ordinary case here. - */ -fun writeFile( - settings: ServerSettings, - setup: String, - path: String, - content: String, - ifSha256: String, -): Written = - requestFromServer( - settings, - "/setups/${setup.urlEncoded()}/file", - method = "PUT", - jsonBody = - JSONObject() - .put("path", path) - .put("content", content) - .put("ifSha256", ifSha256) - .toString(), - readTimeoutMs = 60000, - ) { connection -> - val body = connection.jsonObject() - Written(body.optLong("size"), body.optLong("modified"), body.getString("sha256")) - } - -/** Creates an empty file. Refused, with the machine's own words, if the name is already taken. */ -fun createFile(settings: ServerSettings, setup: String, path: String) { - requestFromServer( - settings, - "/setups/${setup.urlEncoded()}/file", - method = "POST", - jsonBody = JSONObject().put("path", path).toString(), - readTimeoutMs = 30000, - ) {} -} - -/** Creates a directory, with the same refusal as [createFile]. */ -fun createDir(settings: ServerSettings, setup: String, path: String) { - requestFromServer( - settings, - "/setups/${setup.urlEncoded()}/dir", - method = "POST", - jsonBody = JSONObject().put("path", path).toString(), - readTimeoutMs = 30000, - ) {} -} - -fun fetchSessionFile(settings: ServerSettings, sessionId: String, name: String): ByteArray = - requestFromServer(settings, "/sessions/$sessionId/files/$name", readTimeoutMs = 30000) { - it.inputStream.readBytes() - } - -// One rate-limit window, rendered as a labeled bar on the usage screen. -data class UsageWindow( - /** - * The API's own word for which window this is -- "session" for the five-hour one. The label - * beside it is written for a person to read, so matching on it would select nothing the day its - * wording changes. - */ - val kind: String, - val label: String, - val percent: Double, - val resetsAt: String?, - val active: Boolean, -) - -data class UsageSnapshot( - val provider: String, - /** Stable id of the machine these numbers belong to. */ - val setup: String, - /** That machine's current label. */ - val setupName: String, - /** - * What came back: "ok", "notLoggedIn", "unreachable" or "failed". - * - * Four rather than a flag, because the screen has to treat them differently. "notLoggedIn" is a - * machine somebody chose not to put an account on -- a fact, not a fault. Collapsing them made - * a healthy setup read as broken. - */ - val state: String, - /** Why, for the two states that are faults. Absent otherwise. */ - val detail: String?, - val windows: List, -) - -/** The backend caches; refreshing more often than its poll interval just re-reads the cache. */ -fun fetchUsage(settings: ServerSettings): List = - requestFromServer(settings, "/usage", readTimeoutMs = 30000) { connection -> - connection.jsonObjects { snapshot -> - UsageSnapshot( - provider = snapshot.getString("provider"), - setup = snapshot.optString("setup"), - setupName = snapshot.optString("setupName"), - // Unknown to an older backend, and unknown is not "fine": defaulting to "ok" would - // draw an empty card as a healthy one. - state = snapshot.optString("state").ifEmpty { "failed" }, - detail = snapshot.optString("detail").ifEmpty { null }, - windows = - snapshot.getJSONArray("windows").mapObjects { window -> - UsageWindow( - kind = window.optString("kind").ifEmpty { "unknown" }, - label = window.getString("label"), - percent = window.getDouble("percent"), - resetsAt = window.optString("resetsAt").ifEmpty { null }, - active = window.getBoolean("active"), - ) - }, - ) - } - } - -/** - * Answers one question with everything that was chosen. - * - * A list even when one thing was picked, because that is the shape of the answer rather than a - * special case of it. What a provider makes of several answers is decided on the server. - */ -fun answerQuestion( - settings: ServerSettings, - sessionId: String, - questionId: String, - answers: List, -) { - requestFromServer( - settings, - "/sessions/$sessionId/answer", - method = "POST", - jsonBody = - JSONObject() - .put("questionId", questionId) - .put("answers", JSONArray(answers)) - .toString(), - ) {} -} - -fun interruptSession(settings: ServerSettings, sessionId: String) { - requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {} -} - -/** - * Ends the process behind a session, leaving the session and its transcript. - * - * Not a delete and not an interrupt: the conversation stays where it is and [startSession] picks it - * back up. The server reports what it could not do rather than answering the same way either way. - */ -fun stopSession(settings: ServerSettings, sessionId: String) { - requestFromServer(settings, "/sessions/$sessionId/stop", method = "POST") {} -} - -/** Starts the process again on the conversation it left. See [stopSession]. */ -fun startSession(settings: ServerSettings, sessionId: String) { - requestFromServer(settings, "/sessions/$sessionId/start", method = "POST") {} -} - -/** - * Asks the machine to delete Claude Code sessions, and returns as soon as it has accepted the lot. - * - * The transcript *is* the session, so this ends any chance of resuming those conversations. The - * caller confirms first; see ImportScreen. - * - * The work runs on the server, so this returning is not the same as it being done -- what says that - * is each row's own state. That is the point: leaving the screen used to cancel the delete. - * - * One request for the whole batch, which is what makes a handover all-or-nothing. One per row meant - * a batch could half-arrive, and the rows that were missed looked exactly like rows not picked. - */ -fun deleteImportable(settings: ServerSettings, setup: String, sessionIds: List) { - requestFromServer( - settings, - "/setups/$setup/importable/delete", - method = "POST", - jsonBody = JSONObject().put("sessions", JSONArray(sessionIds)).toString(), - ) {} -} - -/** - * Continues Claude Code sessions in the background, returning once the server has accepted them. - * - * Separate from [spawnSession] because the two are asked different questions. That one means "start - * this and take me to it", so it waits and answers with the session. This is the import list's - * batch: several at once, nobody waiting on any particular one, and the result arrives as a row - * changing -- which is what lets the screen be left. - */ -fun startImport( - settings: ServerSettings, - setup: String, - sessionIds: List, - provider: String, - permissionMode: String? = null, - model: String? = null, -) { - val body = - JSONObject().apply { - put("sessions", JSONArray(sessionIds)) - put("provider", provider) - permissionMode?.let { put("permissionMode", it) } - model?.let { put("model", it) } - } - requestFromServer( - settings, - "/setups/$setup/importable/import", - method = "POST", - jsonBody = body.toString(), - ) {} -} - -/** - * A page of a session's transcript, oldest first within the page. - * - * One request instead of one stream frame per event. The SSE stream is the right shape for live - * events and the wrong one for a backlog: opening an imported session replayed hundreds of frames - * before anything was readable, which looked exactly like the app loading top-down. - * - * [before] pages backwards for history somebody scrolls to; absent means the newest page. - */ -fun fetchTranscript( - settings: ServerSettings, - address: TranscriptAddress, - before: Long? = null, - limit: Int = 80, - // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of - // a delta-heavy conversation is a page of the screen rather than a fraction of one message. The - // scroll-back pager wants this; the anchor restore does not. Ignored by the server for the - // newest window, where the live cursor needs real seqs. - coalesce: Boolean = false, - // Return nothing at or below this seq, stopping the page here instead of at [limit]. The phone - // passes the end of the run it already holds cached, so a page never overlaps that copy -- an - // overlap it cannot store, since a coalesced event cannot be cut inside its own delta run. - after: Long? = null, -): List> { - val query = buildString { - append("?limit=").append(limit) - if (before != null) append("&before=").append(before) - if (coalesce) append("&coalesce=true") - if (after != null) append("&after=").append(after) - } - return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection -> - val body = JSONArray(connection.inputStream.bufferedReader().readText()) - // The text as well as the event: the transcript cache stores the one and the fold needs the - // other, and they have to be the same line. - (0 until body.length()).map { - val line = body.getJSONObject(it).toString() - line to parseSeqEvent(line) - } - } -} - -/** - * Renames a session. - * - * The name is the backend's own -- it is what the list shows and it exists before any process does - * -- so this settles it rather than asking. Where the thing running the session has a name of its - * own, the backend passes it on, which is what makes a session the same session in Claude Code's - * picker. - */ -fun renameSession(settings: ServerSettings, sessionId: String, title: String) { - requestFromServer( - settings, - "/sessions/$sessionId/title", - method = "POST", - jsonBody = JSONObject().put("title", title).toString(), - ) {} -} - -/** Switches a running session's model; the CLI changes it in place. */ -fun setSessionModel(settings: ServerSettings, sessionId: String, model: String) { - requestFromServer( - settings, - "/sessions/$sessionId/model", - method = "POST", - jsonBody = JSONObject().put("model", model).toString(), - ) {} -} - -/** - * The permission modes the Claude CLI accepts, in the order they give up asking. "manual" asks for - * everything (each ask arrives on the phone as a question card); the others are the CLI's own - * escalating levels of autonomy. - * - * One list for every screen that offers them -- spawn, import, and the session's own picker -- - * because three copies had already drifted: the import screen was missing "plan". - */ -val PERMISSION_MODES = listOf("manual", "acceptEdits", "auto", "bypassPermissions", "plan") - -/** - * What a new session's thinking level is when nothing chose one, or null for the CLI's own. - * - * Held by the server rather than by this phone, because a second device would otherwise spawn - * sessions at a level the first one's owner never picked. - */ -fun fetchDefaultEffort(settings: ServerSettings): String? = - requestFromServer(settings, "/defaults") { - it.jsonObject().optString("effort").ifEmpty { null } - } - -/** Sets what new sessions start at. Nothing already running changes. */ -fun setDefaultEffort(settings: ServerSettings, level: String?) { - requestFromServer( - settings, - "/defaults", - method = "POST", - jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(), - ) {} -} - -/** - * How hard the model thinks, as `claude --effort` takes them, cheapest first. - * - * Not offered alongside the model and the permission mode on the session's own bar, because it does - * not behave like them: the CLI has a control request for those two and none for this (checked - * against 2.1.258), so a level is settled when the process is launched. Changing it therefore stops - * the process, which is what the working directory beside it in this dialog does, and why it is - * here rather than on a bar whose other controls take effect mid-turn. - */ -val EFFORT_LEVELS = listOf("low", "medium", "high", "xhigh", "max") - -/** What the picker shows, and sends as null, for a session that has chosen no level. */ -const val DEFAULT_EFFORT = "default" - -/** - * Records how hard a session thinks and **stops its process**, since the level is read when the - * process is launched. The next message, or Start, runs one that has it. - * - * [level] is null for the CLI's own default. - */ -fun setSessionEffort(settings: ServerSettings, sessionId: String, level: String?) { - requestFromServer( - settings, - "/sessions/$sessionId/effort", - method = "POST", - jsonBody = JSONObject().put("effort", level ?: JSONObject.NULL).toString(), - ) {} -} - -/** Switches how much a running session asks before acting, also in place. */ -fun setSessionPermissionMode(settings: ServerSettings, sessionId: String, mode: String) { - requestFromServer( - settings, - "/sessions/$sessionId/permission-mode", - method = "POST", - jsonBody = JSONObject().put("mode", mode).toString(), - ) {} -} - -/** Turns this session's notifications on or off. Stored on the backend -- see `SessionConfig`. */ -/** - * What an auto-resume says when nothing else was typed. Mirrors the server's own default, so a - * cleared field shows the word that would actually be sent instead of going blank. - */ -const val DEFAULT_RESUME_MESSAGE = "continue" - -/** - * Turns auto-resume on or off and sets what it would say, in one request because they are one - * decision -- see the server's `/sessions/{id}/auto-resume`. - */ -fun setSessionAutoResume( - settings: ServerSettings, - sessionId: String, - autoResume: Boolean, - message: String?, -) { - requestFromServer( - settings, - "/sessions/$sessionId/auto-resume", - method = "POST", - jsonBody = - JSONObject() - .put("autoResume", autoResume) - // Empty means the server's default rather than a session poked with nothing to - // read, which is the same rule the server applies to the field. - .put("message", message?.trim()?.ifEmpty { null } ?: JSONObject.NULL) - .toString(), - ) {} -} - -fun setSessionNotify(settings: ServerSettings, sessionId: String, notify: Boolean) { - requestFromServer( - settings, - "/sessions/$sessionId/notify", - method = "POST", - jsonBody = JSONObject().put("notify", notify).toString(), - ) {} -} - -/** - * Asks the session to run one of its own commands. - * - * Sent as typed. The server turns the two it understands into its own operations -- a compaction, a - * rename, which is also what the settings screen sends -- and passes anything else to whatever runs - * the session. Either way it waits for the turn to end if one is in flight, and says so on the - * event stream, which is where the waiting bubble comes from. - */ -fun runCommand(settings: ServerSettings, sessionId: String, text: String) { - requestFromServer( - settings, - "/sessions/$sessionId/command", - method = "POST", - jsonBody = JSONObject().put("text", text).toString(), - ) {} -} - -/** - * Asks the session to summarise its own history and carry on from the summary. - * - * Nothing comes back here: a compaction takes a minute or two, and what it is doing arrives on the - * event stream like everything else -- a `compacting` status while it runs, then how much context - * it recovered. A call that waited would be a second, worse account of the same thing. - */ -fun compactSession(settings: ServerSettings, sessionId: String) { - requestFromServer(settings, "/sessions/$sessionId/compact", method = "POST") {} -} - -/** - * Removes a session, and optionally the machine's own transcript of the same conversation. - * - * [deleteForeign] is the delete this app cannot otherwise reach: Claude Code keeps its own record - * under `~/.claude/projects`, and leaving it is what makes an ordinary delete recoverable. The - * server does both halves, and does the unrecoverable one first, so a machine it cannot reach - * leaves the session exactly where it was rather than half-deleted. - */ -fun deleteSession(settings: ServerSettings, sessionId: String, deleteForeign: Boolean = false) { - val query = if (deleteForeign) "?deleteForeign=true" else "" - requestFromServer(settings, "/sessions/$sessionId$query", method = "DELETE") {} -} - -// Models: what this backend has downloaded, what it is downloading, and what HuggingFace offers. -// Browsing is proxied by the server rather than done here, because this app trusts exactly one -// certificate and has no general internet trust to spend on huggingface.co. - -data class LocalModel(val key: String, val repo: String, val file: String, val bytes: Long) - -/** - * A download in flight or finished. [total] is null when the server never said how big the file is - * -- which must render as "not known", never as a bar at some invented position. - */ -data class Download( - val key: String, - val run: Long, - val repo: String, - val file: String, - val state: String, - val done: Long, - val total: Long?, - val error: String?, -) - -data class Models(val local: List, val downloads: List) - -data class RemoteRepo(val id: String, val downloads: Long, val likes: Long) - -data class RemoteFile(val path: String, val bytes: Long, val have: Boolean) - -private fun parseDownload(o: JSONObject) = - Download( - key = o.getString("key"), - run = o.getLong("run"), - repo = o.getString("repo"), - file = o.getString("file"), - state = o.getString("state"), - done = o.getLong("done"), - // Absent rather than zero when unknown; see the field's comment. - total = if (o.has("total")) o.getLong("total") else null, - error = if (o.has("error")) o.getString("error") else null, - ) - -/** - * The models on one machine, which is the list a llama.cpp session there can choose from. - * - * Not [fetchModels], which is what the *backend* has downloaded. A session serves its model from - * the machine it runs on, so for a machine reached over ssh those are two different lists -- and - * offering the backend's would name files that are not there, turning a choice that cannot work - * into a session that fails when it tries to load one. - */ -fun fetchSetupModels(settings: ServerSettings, setupId: String): List = - requestFromServer(settings, "/setups/${setupId.urlEncoded()}/models") { connection -> - JSONArray(connection.inputStream.bufferedReader().readText()).mapObjects { m -> - LocalModel( - key = m.getString("key"), - repo = m.getString("repo"), - file = m.getString("file"), - bytes = m.getLong("bytes"), - ) - } - } - -fun fetchModels(settings: ServerSettings): Models = - requestFromServer(settings, "/models") { connection -> - val body = JSONObject(connection.inputStream.bufferedReader().readText()) - Models( - local = - body.getJSONArray("local").mapObjects { m -> - LocalModel( - key = m.getString("key"), - repo = m.getString("repo"), - file = m.getString("file"), - bytes = m.getLong("bytes"), - ) - }, - downloads = body.getJSONArray("downloads").mapObjects(::parseDownload), - ) - } - -fun searchModels(settings: ServerSettings, query: String): List = - requestFromServer(settings, "/models/search?q=${query.urlEncoded()}") { connection -> - connection.jsonObjects { r -> - RemoteRepo( - id = r.getString("id"), - downloads = r.getLong("downloads"), - likes = r.getLong("likes"), - ) - } - } - -fun fetchRepoFiles(settings: ServerSettings, repo: String): List = - requestFromServer(settings, "/models/files?repo=${repo.urlEncoded()}") { connection -> - connection.jsonObjects { f -> - RemoteFile( - path = f.getString("path"), - bytes = f.getLong("bytes"), - have = f.getBoolean("have"), - ) - } - } - -fun startDownload(settings: ServerSettings, repo: String, file: String): Download = - requestFromServer( - settings, - "/models/download", - method = "POST", - jsonBody = JSONObject().put("repo", repo).put("file", file).toString(), - ) { connection -> - parseDownload(JSONObject(connection.inputStream.bufferedReader().readText())) - } - -fun cancelDownload(settings: ServerSettings, key: String) { - requestFromServer( - settings, - "/models/cancel", - method = "POST", - jsonBody = JSONObject().put("key", key).toString(), - ) {} -} - -fun deleteModel(settings: ServerSettings, key: String) { - requestFromServer( - settings, - "/models/delete", - method = "POST", - jsonBody = JSONObject().put("key", key).toString(), - ) {} -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt deleted file mode 100644 index 6d55ab2..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.example.aiapp - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.example.wgapplink.localNetworkAllowed -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root - * and the back button the only other way between them. - * - * Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of - * them a step down from another -- and what is left here is only what genuinely is a step down: one - * session, spawning one, and settings. - */ -private sealed class Screen { - /** - * The session list, with a subagent's own transcript over it when [subagent] is set. - * - * A layer on this screen rather than a screen of its own, for the same reason [Session.files] - * is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept - * in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was - * disposed and recreated on every return, which lost that state -- an expanded card collapsed - * itself the moment its own subagent's view was closed. - */ - data class Main(val subagent: SubagentTarget? = null) : Screen() - - /** - * One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and - * docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent - * is opened from the session list's card rather than from inside the session it belongs to. - */ - data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary) - - /** - * One session, with the file explorer over it when [files] is set. - * - * The explorer is a layer on this screen rather than a screen of its own, so the session under - * it stays composed: its event stream keeps flowing, its scroll position and draft stay put, - * and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re- - * created on every return, refetching the transcript over the tunnel. - */ - data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen() - - data object Spawn : Screen() - - data object Settings : Screen() -} - -/** - * A session a notification tap asked to open, before it is a screen. - * - * The notification names an id and nothing else, so opening it means fetching the session first. - * [serial] tells two taps on the same session's notification apart, since they are two requests and - * would otherwise compare equal. - */ -data class SessionOpenRequest(val sessionId: String, val serial: Int) - -/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */ -private data class FailedOpen(val request: SessionOpenRequest, val message: String) - -/** - * [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re- - * reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null. - * - * [openRequest] is the session a notification tap asked for, likewise from MainActivity. - * - * [shareRequest] is what another app shared in, likewise. It is held here until a session takes it, - * because the share arrives before anyone has said which session it is for. - */ -@Composable -fun AppRoot( - settingsVersion: Int, - openRequest: SessionOpenRequest?, - shareRequest: ShareRequest? = null, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } - var screen by remember { mutableStateOf(Screen.Main()) } - // A notification tap this could not follow, and why. Null both before one is asked for and - // after one succeeds, since success is a screen rather than a message. - var failedOpen by remember { mutableStateOf(null) } - // Bumped whenever another screen changes something the list shows, so returning to it - // refetches. - var reloadToken by remember { mutableIntStateOf(0) } - // Cleared by the session screen that attached it, not when a newer request arrives: a share - // must be attached exactly once, and only the screen that did it knows that it has. - var share by remember { mutableStateOf(null) } - LaunchedEffect(shareRequest) { - if (shareRequest != null) { - share = shareRequest - // A session already open takes it. Otherwise the list is where the choice is made, - // whatever screen was showing: Spawn and Settings have nowhere to put a file. - if (screen !is Screen.Session) screen = Screen.Main() - } - } - - // A standing condition rather than a per-request failure, so it is stated once here instead of - // appended to every error it might cause. Without this the app is simply unreachable and every - // screen blames the server or the tunnel for it. - if (!localNetworkAllowed(context)) { - Text( - "This app is not allowed to reach local network addresses, so it cannot " + - "connect to the backend at all. Grant \"local network\" in Android's app " + - "settings; until then every screen here will look like the server is down.", - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(16.dp), - ) - } - - val current = settings - if (current == null) { - // Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity - // and recomposes from the top. - Box(Modifier.imePadding()) { - SettingsScreen( - existing = null, - onSaved = { saved -> - settings = saved - screen = Screen.Main() - }, - onBack = null, - ) - } - return - } - - // The one way back, whichever screen is showing and whether it was reached by the system back - // gesture or a screen's own Back button. Every leaf screen can have changed something the list - // shows, so it always refetches. - val goToMain = { - reloadToken++ - screen = Screen.Main() - } - if (screen !is Screen.Main) { - BackHandler(onBack = goToMain) - } - - // Turning a notification into the screen it points at. The id has to be resolved to a session - // first, because that is what SessionScreen is given -- and unlike a list row, there is nothing - // here to seed it from. - // - // A failure is reported rather than swallowed: somebody deliberately tapped a notification, so - // an app that opens to the session list with no explanation looks like the tap missed. - val open: suspend (SessionOpenRequest) -> Unit = { request -> - failedOpen = null - try { - val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) } - screen = Screen.Session(session) - } catch (e: ApiException) { - failedOpen = FailedOpen(request, e.message ?: "Unknown error") - } - } - LaunchedEffect(openRequest) { openRequest?.let { open(it) } } - - val failed = failedOpen - if (failed != null) { - AlertDialog( - onDismissRequest = { failedOpen = null }, - title = { Text("Couldn't open that session") }, - text = { Text(failed.message) }, - confirmButton = { - TextButton(onClick = { scope.launch { open(failed.request) } }) { - Text("Try again") - } - }, - dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } }, - ) - } - - // Every screen but the session takes the keyboard as bottom padding here. The session screen - // deliberately does not: resizing a whole screen on every frame of the keyboard animation is - // the cost that made it lag, so it moves only its composer and transcript. - when (val here = screen) { - is Screen.Main -> - Box(Modifier.imePadding()) { - MainScreen( - settings = current, - reloadToken = reloadToken, - share = share, - onOpen = { screen = Screen.Session(it) }, - onOpenSubagent = { summary, subagent -> - screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent)) - }, - onSpawn = { screen = Screen.Spawn }, - onImported = { imported -> - reloadToken++ - screen = Screen.Session(imported) - }, - onSettings = { screen = Screen.Settings }, - ) - // Its own back handler is registered after MainScreen's, so it is the one the - // platform asks first while a subagent is open -- the same rule the files - // explorer's handler follows over its session, below. - here.subagent?.let { target -> - BackHandler { screen = here.copy(subagent = null) } - // Its own opaque background: this screen was always the sole content under - // the theme's own Surface before, so it never had to paint one -- stacked over - // the list here, the space between its own cards let the list underneath show - // through without this. The same fix FilesScreen needed over its session. - Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - key(target.summary.id, target.subagent.id) { - SessionScreen( - settings = current, - summary = target.summary, - onBack = { screen = here.copy(subagent = null) }, - onFiles = {}, - subagent = target.subagent, - ) - } - } - } - } - is Screen.Session -> - // Keyed on the id, because a different session is a different screen rather than this - // one showing other rows. SessionScreen remembers a transcript, an open stream, a draft - // and a scroll position, and without the key Compose keeps all of it across the change - // and merges two conversations -- which crashes the list on the first duplicate row - // key. Only reachable since a notification can move straight from one session to - // another. - key(here.summary.id) { - // A Box so the explorer can be drawn *over* the session rather than instead of it. - // No imePadding here, for the reason above -- the explorer adds its own. - Box { - SessionScreen( - settings = current, - summary = here.summary, - onBack = goToMain, - onFiles = { screen = here.copy(files = it) }, - share = share, - onShareTaken = { share = null }, - ) - // Its own back handler is registered after this screen's, so it is the one the - // platform asks first, and it steps back inside itself before closing. - here.files?.let { target -> - FilesScreen( - settings = current, - target = target, - onClose = { screen = here.copy(files = null) }, - ) - } - } - } - is Screen.Spawn -> - Box(Modifier.imePadding()) { - SpawnScreen( - settings = current, - onSpawned = { spawned -> - reloadToken++ - screen = Screen.Session(spawned) - }, - onBack = goToMain, - ) - } - is Screen.Settings -> - Box(Modifier.imePadding()) { - SettingsScreen( - existing = current, - onSaved = { saved -> - settings = saved - goToMain() - }, - onBack = goToMain, - ) - } - } - - // Last, so it draws over the screen above rather than under it: these are stacked in the Box - // the activity puts around this, and that Box paints in the order it was given. A session - // wanting attention is not a fact about the page somebody happens to be on. Tapping one is the - // same act as tapping a notification, so it goes through the same `open`. - SessionAlerts(onOpen = { request -> scope.launch { open(request) } }) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt deleted file mode 100644 index 6bf3d11..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt +++ /dev/null @@ -1,392 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp - -/** One question's answer on its way back, so a card can hand over several at once. */ -data class QuestionAnswer(val questionId: String, val answers: List) - -/** - * What the reader has settled on for one question, before any of it is sent. - * - * Held here rather than inferred from the transcript, which is what made picking an option feel - * broken: the mark used to appear only when the answer had crossed the tunnel and come back as an - * event, so the card sat unchanged for most of a second after a tap. - * - * Picked options and typed words are one field each because they are alternatives rather than - * parts: typing puts the picks away and picking puts the words away, so there is never a draft that - * means two things. - */ -data class Draft(val picked: Set = emptySet(), val other: String = "") { - val settled: Boolean - get() = picked.isNotEmpty() || other.isNotBlank() - - /** - * What goes back, in the order the options were offered rather than the order they were tapped: - * the reader is answering a list, and it should read back as that list. - */ - fun answers(options: List): List = - if (other.isNotBlank()) listOf(other.trim()) - else options.map { it.label }.filter { it in picked } -} - -/** - * Every question one tool call is waiting on, one at a time. - * - * All of it comes from the question events themselves. None of it is read out of the call's own - * input, which is one provider's JSON: parsing that here would put that provider's schema in the - * app, where no other provider can reach it and where it drifts the first time the schema moves. - * - * One question on screen with arrows to the others, rather than all of them stacked. A card asking - * three questions with four options and a description each is several screens tall, so the reader - * scrolls past the question they are answering to reach the button that sends it. Paged, each - * question is a screen and the count says how many are left. - * - * Nothing is sent until Submit. Answering is one act even when it is several questions: the tool - * asked them together, and sending each as it was tapped meant the reader could not change their - * mind about the first after reading the third. - */ -@Composable -fun AskUserQuestionBody( - asks: List, - onAnswer: (List, onSettled: () -> Unit) -> Unit, -) { - // Seeded from what was already answered, so a card the reader comes back to shows their answers - // rather than an empty draft over them. - var drafts by - remember(asks.map { it.id }) { - mutableStateOf( - asks.associate { ask -> - ask.id to - Draft( - picked = - ask.answers - .filter { a -> ask.options.any { it.label == a } } - .toSet(), - other = - ask.answers - .firstOrNull { a -> ask.options.none { it.label == a } } - .orEmpty(), - ) - } - ) - } - var at by remember(asks.map { it.id }) { mutableIntStateOf(0) } - var sending by remember(asks.map { it.id }) { mutableStateOf(false) } - if (asks.isEmpty()) return - val showing = asks[at.coerceIn(0, asks.size - 1)] - val outstanding = asks.filter { it.answers.isEmpty() } - - Column(Modifier.fillMaxWidth()) { - if (asks.size > 1) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - "Question ${at + 1} of ${asks.size}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - // Disabled at the ends rather than absent, so the pair keeps its place and the - // reader can see there is nothing further that way. - MarkButton("Previous question", { at-- }, enabled = at > 0) { - Chevron(Pointing.Left, colour = LocalContentColor.current) - } - MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) { - Chevron(Pointing.Right, colour = LocalContentColor.current) - } - } - } - Spacer(Modifier.height(4.dp)) - AskedQuestion( - showing, - draft = drafts[showing.id] ?: Draft(), - onDraft = { drafts = drafts + (showing.id to it) }, - ) - if (outstanding.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - // Greyed until every question has an answer, because the tool is waiting on all of - // them: a submit that sent two of three would leave the third asked and the card - // looking dealt with. - val ready = outstanding.all { drafts[it.id]?.settled == true } - Button( - onClick = { - sending = true - onAnswer( - outstanding.map { ask -> - QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options)) - } - ) { - // Back to a button whatever happened. A refusal is reported by the screen - // around this, and the draft is still here to send again -- a spinner that - // never stops would be the only sign of a failure this card cannot - // describe. - sending = false - } - }, - enabled = ready && !sending, - modifier = Modifier.fillMaxWidth(), - ) { - if (sending) { - // In the button rather than beside it, so the row does not change height at the - // moment it is pressed. - CircularProgressIndicator( - Modifier.height(18.dp).width(18.dp), - strokeWidth = 2.dp, - color = LocalContentColor.current, - ) - } else { - Text( - if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit" - ) - } - } - } - } -} - -/** - * One question: what is being asked, what can be answered, and what was. - * - * The same body wherever a question appears -- on the call that asked it, or as a card of its own - * when nothing did. Two renderings of it would be two places for an answer to go missing. - * - * [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here - * sends anything. An answered question ignores both and draws what was answered. - */ -@Composable -fun AskedQuestion( - ask: TranscriptItem.QuestionCard, - draft: Draft, - onDraft: (Draft) -> Unit, -) { - Column(Modifier.fillMaxWidth()) { - ask.header?.let { header -> - // Its own line rather than beside the question, because it is a label *for* the - // question and the question is the thing to read. - Text( - header.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text(ask.prompt, style = MaterialTheme.typography.bodyLarge) - Spacer(Modifier.height(8.dp)) - // An answered question keeps its options and marks the one that was taken, rather than - // replacing them with a line repeating it. The options are what the question *was*, and - // dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says - // very little without the three it was chosen over. Marked in the same purple that says - // "picked" while the question is open, so it is one appearance learned once. - val answered = ask.answers.isNotEmpty() - // What is marked: what was answered once there is an answer, and what the finger has chosen - // until then. - val marked = if (answered) ask.answers.toSet() else draft.picked - // Null once the question is answered: the options stay and stop being pressable. - val onPick: ((String) -> Unit)? = - if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) } - if (ask.options.all { it.description == null && it.preview == null }) { - // Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words - // do not need a card each. - AnswerOptions(ask.options, marked.toList(), onPick) - } else { - ask.options.forEach { option -> - OptionCard(option, selected = option.label in marked) { - onPick?.invoke(option.label) - } - } - } - // What was answered in the reader's own words, which no option can mark. Only ever the - // answers that match nothing offered, so a question answered by picking says it by the - // mark. - val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } } - if (inWords.isNotEmpty()) { - Text( - "Answered: ${inWords.joinToString(", ")}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp), - ) - } - if (!answered) { - OtherAnswer(draft.other) { onDraft(Draft(other = it)) } - } - } -} - -/** - * [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces - * rather than accumulates, and either way picking puts any typed words away -- see [Draft]. - */ -private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft = - when { - !multiSelect -> Draft(picked = setOf(label)) - label in draft.picked -> Draft(picked = draft.picked - label) - else -> Draft(picked = draft.picked + label) - } - -/** - * One option: what it is called, what it means, and what it would produce. - * - * Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was - * indistinguishable from the card behind it -- three paragraphs of text where three things to press - * should have been. A border is one cue and it is unambiguous. - */ -@Composable -private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) { - OutlinedCard( - onClick = onPick, - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - colors = - CardDefaults.outlinedCardColors( - containerColor = - if (selected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surface - ), - // Picked shows in the border as well as the fill, because the fill alone is a colour - // difference somebody has to have seen the unpicked version to notice. - border = - BorderStroke( - if (selected) 2.dp else 1.dp, - if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, - ), - ) { - Column(Modifier.padding(12.dp)) { - Text(option.label, style = MaterialTheme.typography.titleSmall) - option.description?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 2.dp), - ) - } - option.preview?.let { Preview(it) } - } - } -} - -/** - * An option's worked example, shown as written. - * - * On its own surface, because it is a different kind of thing from the sentence above it: that - * describes the option, this is a sample of what the option produces, and monospace alone reads as - * a description that happens to be in code font. - */ -@Composable -private fun Preview(preview: String) { - Surface( - color = MaterialTheme.colorScheme.surfaceContainerLowest, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) { - Text( - preview, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - // Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of - // the thing being previewed. - softWrap = false, - modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()), - ) - } -} - -/** - * The choice the asker always leaves open, and the app has to as well. - * - * Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words - * rather than pick. Leaving it out narrows a question that was never that narrow. - */ -@Composable -private fun OtherAnswer(text: String, onText: (String) -> Unit) { - // No Send of its own: this is one more way to answer the question, and the card's Submit is - // what sends it. A second send button beside the field made the shorter half of the card look - // like the one that finishes it. - OutlinedTextField( - value = text, - onValueChange = onText, - label = { Text("Other") }, - singleLine = true, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - ) -} - -/** - * Bare options, wrapped rather than in a row. - * - * A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question - * with four options showed the first one or two and dropped the rest off the side of the screen. - * That reads as those having been the only choices. - */ -@Composable -fun AnswerOptions( - options: List, - /** What is chosen: the answer once there is one, and what the finger has marked until then. */ - answers: List = emptyList(), - /** Null once the question is answered -- the buttons stay, and stop being buttons. */ - onPick: ((String) -> Unit)?, -) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), - ) { - options.forEach { option -> - val taken = option.label in answers - OutlinedButton( - onClick = { onPick?.invoke(option.label) }, - // Disabled rather than removed, so an answered question still shows what it - // offered. Material dims a disabled button's own border and label, which would take - // the mark with it -- both are stated here instead. - enabled = onPick != null, - border = - BorderStroke( - if (taken) 2.dp else 1.dp, - if (taken) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outlineVariant, - ), - colors = - ButtonDefaults.outlinedButtonColors( - disabledContentColor = - if (taken) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ), - ) { - Text(option.label) - } - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt deleted file mode 100644 index b53d82e..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachment.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -/** - * Whether [ref] names an image the server stored as one -- `.`, with an extension - * from the list it writes -- rather than a file kept under its own name. Mirrors the server's - * `media` table, which is the one other place the list lives. - */ -fun isImageRef(ref: String): Boolean = ref.substringAfterLast('.', "") in IMAGE_EXTENSIONS - -private val IMAGE_EXTENSIONS = setOf("png", "jpg", "gif", "webp") - -/** - * The name a file was attached under: the ref less the hex the server put before it. The hex has no - * dash in it, so the first one is the boundary however many the name has. - */ -fun attachmentName(ref: String): String = ref.substringAfter('-', ref) - -/** - * One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A - * file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all - * of it. - */ -@Composable -fun Attachment( - settings: ServerSettings, - sessionId: String, - ref: String, - onOpenImage: (String) -> Unit, -) { - if (isImageRef(ref)) SessionImage(settings, sessionId, ref, onOpenImage) - else - FileName( - attachmentName(ref), - Modifier.clip(MaterialTheme.shapes.extraSmall) - .background(rawSurface) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) -} - -/** - * A file's name, one line, in the face names are read in. Overlong names lose their middle: a name - * is identified by both ends -- what it is at the front, what kind at the back. - */ -@Composable -fun FileName(name: String, modifier: Modifier = Modifier) { - Text( - name, - modifier = modifier, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt deleted file mode 100644 index 2a00cf2..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt +++ /dev/null @@ -1,171 +0,0 @@ -package com.example.aiapp - -import android.content.ContentResolver -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix -import android.net.Uri -import android.provider.OpenableColumns -import androidx.exifinterface.media.ExifInterface -import java.io.ByteArrayOutputStream -import kotlin.math.max - -/** - * Getting a picked photo to a session, at a size the session can actually take. - * - * A phone camera produces twelve megapixels and several megabytes. The Claude API resizes anything - * larger than 1568px on its long edge before looking at it and refuses images past a much higher - * bound outright, so a photo sent straight off the camera roll was uploaded whole over the tunnel - * to be either thrown away or rejected -- which is what "sending an image is broken" was. - * - * Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the - * expensive part on a phone is the upload, not the decode. What the limit *is* comes from the - * server, per session, because that is where a provider's requirements are known. - */ -suspend fun uploadPickedImage( - context: Context, - settings: ServerSettings, - sessionId: String, - uri: Uri, - maxEdge: Int?, -): String { - val (bytes, mime) = readForUpload(context, uri, maxEdge) - return uploadAttachment(settings, sessionId, mime, "image") { it.write(bytes) } -} - -/** - * Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage] - * and is shrunk; anything else goes whole, under the name the other app or the file chooser gave - * it, because the session is told that name rather than shown the bytes. - */ -suspend fun uploadPicked( - context: Context, - settings: ServerSettings, - sessionId: String, - uri: Uri, - maxEdge: Int?, -): String { - val resolver = context.contentResolver - val mime = resolver.getType(uri) - if (mime != null && mime.startsWith("image/")) { - return uploadPickedImage(context, settings, sessionId, uri, maxEdge) - } - // Opened before the request starts, so a provider that refuses says so here and not from inside - // the connection; then streamed, since a trace is bigger than this process should hold at once. - val source = openSource(resolver, uri) - val name = displayName(resolver, uri) - return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out -> - try { - source.use { it.copyTo(out, COPY_BUFFER) } - } catch (e: java.io.IOException) { - // Either side of the copy can fail; the message names the file, which is the part the - // reader can do something about. - throw ApiException("couldn't send $name: ${e.message}", cause = e) - } - } -} - -private const val COPY_BUFFER = 64 * 1024 - -/** - * A stream of [uri], or the refusal as the kind the composer reports beside the message. - * - * A share arrives with whatever access the other app granted, and a provider that refuses says so - * with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both - * are things the reader can act on. - */ -private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream = - try { - resolver.openInputStream(uri) - ?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there") - } catch (e: SecurityException) { - throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it") - } catch (e: java.io.IOException) { - throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}") - } - -/** Everything at [uri]; an image is decoded whole anyway, so it is read whole. */ -private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray = - openSource(resolver, uri).use { it.readBytes() } - -/** - * The name a document provider shows for [uri]. The last path segment is the fallback because a - * provider's own id for a file is usually a number, which says nothing to the session. - */ -private fun displayName(resolver: ContentResolver, uri: Uri): String { - resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> - val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (column >= 0 && cursor.moveToFirst()) - cursor.getString(column)?.let { - return it - } - } - return uri.lastPathSegment ?: "file" -} - -/** - * The bytes to upload and what they are, scaled down only if they need to be. - * - * An image already inside the limit is uploaded exactly as it came, rather than decoded and re- - * encoded to the same size: a round trip through JPEG loses a little every time. This is also the - * path a provider with no limit always takes. - */ -private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair { - val resolver = context.contentResolver - val mime = resolver.getType(uri) ?: "image/jpeg" - val original = readAll(resolver, uri) - if (maxEdge == null) return original to mime - - val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeByteArray(original, 0, original.size, bounds) - val longest = max(bounds.outWidth, bounds.outHeight) - // outWidth is -1 when the bytes are not an image this device can decode. Sent on untouched: - // this function's job is the size, and refusing something the server might understand is a - // decision it has no business making. - if (longest <= 0 || longest <= maxEdge) return original to mime - - // Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the - // full twelve megapixels only to shrink it is how this runs out of memory on the images it most - // needs to handle. - val decode = - BitmapFactory.Options().apply { - inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge)) - } - val decoded = - BitmapFactory.decodeByteArray(original, 0, original.size, decode) ?: return original to mime - val scale = maxEdge.toFloat() / max(decoded.width, decoded.height) - val matrix = Matrix() - if (scale < 1f) matrix.postScale(scale, scale) - // The camera writes which way up the picture is into EXIF rather than rotating the pixels, and - // re-encoding drops the tag -- so a portrait photo would arrive at the model on its side. - // Applied to the same matrix as the scale, so it costs no second copy of the bitmap. - matrix.postRotate(exifRotation(original)) - val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) - val out = ByteArrayOutputStream() - // JPEG whatever came in: this is a photograph being made smaller, which is what JPEG is for, - // and a PNG of a resampled photo is several times the size for no visible difference. - scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out) - return out.toByteArray() to "image/jpeg" -} - -/** How far to turn the picture so it is the way up it was taken. */ -private fun exifRotation(bytes: ByteArray): Float = - try { - when ( - ExifInterface(bytes.inputStream()) - .getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - ) { - ExifInterface.ORIENTATION_ROTATE_90 -> 90f - ExifInterface.ORIENTATION_ROTATE_180 -> 180f - ExifInterface.ORIENTATION_ROTATE_270 -> 270f - else -> 0f - } - } catch (_: java.io.IOException) { - // No EXIF, or none this can read. Upright is the assumption every image without the tag is - // displayed under anyway. - 0f - } - -/** High enough that resampling is what the reader notices, not the encoder. */ -private const val JPEG_QUALITY = 90 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt deleted file mode 100644 index 07407f6..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchFixture.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import java.util.concurrent.CopyOnWriteArrayList - -/** - * P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the - * backend, so the `bench` build type can drive a real session screen -- the real - * [TranscriptSource], the real fold, the real paging -- with no server and no network permission. - * - * Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else - * in this build compiles it in but never calls it, since Kotlin has no per-build-type source set - * that both [MainActivity] (which every variant compiles) and this can share without one. - * - * The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through - * ordinary `java.net.URL`, exactly as they would talk to a real server. A - * [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every - * `https://` connection to that host and answers from this object's in-memory event log instead of - * opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen, - * the fold, uniqueItems) never learns the difference. - */ -object BenchFixture { - const val FIXTURE_HOST = "bench.fixture.invalid" - const val FIXTURE_PORT = 1 - - /** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */ - private const val BACKLOG_COUNT = 3202 - - val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench") - - /** The session id every bench run opens; nothing else in this build ever mints one. */ - const val SESSION_ID = "bench-fixture-session" - - /** - * The whole transcript, seq order, growing as [pushLive] is called during the streaming phase. - * Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a - * live frame agree on what has "already happened" -- the same thing a real server's own - * transcript file guarantees. - */ - private val log = CopyOnWriteArrayList>() - - /** The events not yet appended to [log] -- the streaming phase's own source. */ - private var streamTail: List> = emptyList() - - private val images = mutableMapOf() - - @Volatile private var loaded = false - - /** - * Parses the bundled fixture once. Safe to call more than once; only the first does anything. - */ - @Synchronized - fun ensureLoaded(context: Context) { - if (loaded) return - val lines = - context.assets.open("transcript.jsonl").bufferedReader().readLines().filter { - it.isNotBlank() - } - val parsed = lines.map { it to parseSeqEvent(it) } - log.addAll(parsed.take(BACKLOG_COUNT)) - streamTail = parsed.drop(BACKLOG_COUNT) - for (name in listOf("bench1.png", "bench2.png")) { - images[name] = context.assets.open(name).readBytes() - } - loaded = true - } - - /** The events the streaming phase has left to send. */ - fun remainingStreamEvents(): Int = streamTail.size - - /** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */ - fun pushNextLiveEvent(): Boolean { - val next = streamTail.firstOrNull() ?: return false - streamTail = streamTail.drop(1) - log.add(next) - return true - } - - /** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */ - @Synchronized - fun resetToBacklog(context: Context) { - loaded = false - log.clear() - ensureLoaded(context) - } - - fun fileBytes(name: String): ByteArray? = images[name] - - /** - * Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays. - */ - fun linesAfter(after: Long): List = - log.filter { it.second.seq > after }.map { it.first } - - /** - * One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores - * `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and - * what the benchmark exercises is the fold and the paging, not the server's row-joining, which - * client-core's own port tracks separately (CLIENT_CORE.md). - */ - fun page(before: Long?, limit: Int, after: Long?): List { - val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L) - val candidates = log.filter { - it.second.seq < upper && (after == null || it.second.seq > after) - } - return candidates.takeLast(limit).map { it.first } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt deleted file mode 100644 index d9ff4c9..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchNetwork.kt +++ /dev/null @@ -1,181 +0,0 @@ -package com.example.aiapp - -import java.io.ByteArrayInputStream -import java.io.IOException -import java.io.InputStream -import java.io.PipedInputStream -import java.io.PipedOutputStream -import java.net.HttpURLConnection -import java.net.URL -import java.net.URLStreamHandler -import java.net.URLStreamHandlerFactory -import java.security.Principal -import java.security.cert.Certificate -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.SSLPeerUnverifiedException -import org.json.JSONArray - -/** - * Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more - * than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once - * per process, and a second real call throws -- so this guards it rather than relying on every - * caller to remember. - * - * Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's - * ordinary handler, so this only ever changes behaviour for the one host the bench build invents. - */ -@Synchronized -fun installFixtureNetworkOnce() { - if (installed) return - installed = true - URL.setURLStreamHandlerFactory( - URLStreamHandlerFactory { protocol -> - if (protocol != "https") null - else - object : URLStreamHandler() { - override fun openConnection(url: URL): HttpURLConnection = - if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url) - else - // The bench build makes no other https call -- this factory is - // installed only in FIXTURE_MODE (MainActivity) -- so there is - // deliberately no delegate to a platform handler here: once a - // URLStreamHandlerFactory is installed there is no supported way to - // ask the JDK for its own default handler back, and re-entering this - // same factory for the fallback would recurse forever rather than - // reach one. - throw java.io.IOException( - "bench build's fixture network has no route to https host " + - "${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served" - ) - } - } - ) -} - -private var installed = false - -/** - * Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of - * [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call - * `connect`/`disconnect`, set a handful of request properties they never need answered, and read - * `responseCode` and `inputStream`. - */ -private class FixtureConnection(url: URL) : HttpsURLConnection(url) { - private var input: InputStream? = null - private var writer: Thread? = null - - override fun connect() { - if (input != null) return - input = route(url.path, url.query) - } - - override fun disconnect() { - writer?.interrupt() - try { - input?.close() - } catch (_: IOException) {} - } - - override fun usingProxy() = false - - override fun getResponseCode(): Int { - connect() - return 200 - } - - override fun getInputStream(): InputStream { - connect() - return input!! - } - - override fun getErrorStream(): InputStream? = null - - // Nothing here reads any of these; implemented only because HttpsURLConnection declares them - // abstract. A fixture never negotiates real TLS, so each says exactly that rather than - // fabricating a plausible-looking certificate. - override fun getCipherSuite() = "none (bench fixture, no TLS)" - - override fun getLocalCertificates(): Array? = null - - override fun getServerCertificates(): Array = - throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") - - override fun getPeerPrincipal(): Principal = - throw SSLPeerUnverifiedException("bench fixture connection presents no certificate") - - override fun getLocalPrincipal(): Principal? = null - - /** - * [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug. - */ - private fun route(path: String, query: String?): InputStream { - val params = - (query ?: "") - .split("&") - .filter { it.contains('=') } - .associate { - val (k, v) = it.split("=", limit = 2) - k to java.net.URLDecoder.decode(v, "UTF-8") - } - return when { - path.endsWith("/transcript") -> { - val lines = - BenchFixture.page( - before = params["before"]?.toLongOrNull(), - limit = params["limit"]?.toIntOrNull() ?: 80, - after = params["after"]?.toLongOrNull(), - ) - val body = JSONArray(lines.map { org.json.JSONObject(it) }) - ByteArrayInputStream(body.toString().toByteArray()) - } - path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L) - path.contains("/files/") -> { - val name = path.substringAfterLast("/files/") - val bytes = - BenchFixture.fileBytes(name) - ?: throw IOException("bench fixture has no file named $name") - ByteArrayInputStream(bytes) - } - else -> throw IOException("bench fixture has no route for $path") - } - } - - /** - * A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for - * anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's - * backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business - * rather than something worth a condition variable for. - */ - private fun openEventsStream(after: Long): InputStream { - val pipeIn = PipedInputStream(1 shl 16) - val pipeOut = PipedOutputStream(pipeIn) - var sent = after - val thread = Thread { - try { - while (!Thread.currentThread().isInterrupted) { - val fresh = BenchFixture.linesAfter(sent) - for (line in fresh) { - pipeOut.write("data: $line\n\n".toByteArray()) - pipeOut.flush() - sent = org.json.JSONObject(line).getLong("seq") - } - Thread.sleep(50) - } - } catch (_: InterruptedException) { - // disconnect() -- the ordinary way this ends. - } catch (_: IOException) { - // The reader side (Sse) closed its end. - } finally { - try { - pipeOut.close() - } catch (_: IOException) {} - } - } - .also { - it.isDaemon = true - it.start() - } - writer = thread - return pipeIn - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt deleted file mode 100644 index 6a9b1f7..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BenchRun.kt +++ /dev/null @@ -1,325 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import android.os.BatteryManager -import android.os.Process -import android.view.View -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.ui.focus.FocusRequester -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable - * system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop - * and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced - * here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build - * (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason - * [BenchFixture]'s doc comment gives. - * - * **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the - * scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the - * same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not - * two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1), - * **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and - * **keyboard** (five show/hide cycles). The exact constants below are also written into - * `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical - * spec -- changing a number here without updating that box makes the two apps measure different - * things while looking like the same benchmark. - */ -object BenchRun { - /** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */ - private const val CYCLES = 6 - private const val SWIPE_PX = 900f - private const val SWIPE_MS = 200 - private const val SWIPE_PAUSE_MS = 500L - - /** - * Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy` - * -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can - * never exceed the distance it is told to cover in the time it is given, while a real fling - * decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard, - * fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above - * the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast - * end of what the platform's fling decay produces rather than the gentle one v1 measured. - */ - private const val FLING_VELOCITY_PX_S = 12_000f - - private const val FLING_COUNT = 8 - private const val FLING_SETTLE_CAP_MS = 3_000L - private const val FLING_PAUSE_MS = 300L - - /** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */ - private const val STREAM_EVENTS_PER_SEC = 20 - private const val STREAM_SECONDS = 20 - - /** - * Type phase (v2): sentences built from long, multisyllabic words so the composer actually - * wraps across lines rather than fitting one, and long enough (600 chars) that the composer's - * own height grows over several frames, pushing the transcript above it upward the same way a - * real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris - * half types the identical content. - */ - const val TYPE_TEXT = - "Benchmarking this transcript screen requires unusually long, multisyllabic words so " + - "wrapping and reflow are properly exercised: internationalization, " + - "counterproductiveness, disproportionately, incomprehensibility, " + - "deinstitutionalization, uncharacteristically, overenthusiastically, " + - "misunderstanding, straightforwardness, telecommunications, and interdisciplinary " + - "collaboration all push a narrow composer field to wrap across several lines while " + - "the transcript above is pushed upward by the growing keyboard-adjacent box, which " + - "is exactly what a real reader typing a long message sees happening now!!!" - - private const val TYPE_CHAR_DELAY_MS = 50L - - /** - * Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the - * transition is ever actually observed rather than being a one-off fluke either way. - */ - private const val KEYBOARD_CYCLES = 5 - private const val KEYBOARD_SHOW_WAIT_MS = 1_000L - private const val KEYBOARD_HIDE_WAIT_MS = 1_000L - - /** - * Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines - * P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery - * current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport` - * resets them, so the two accountings cover the same stretch of work. - */ - suspend fun run( - context: Context, - scope: CoroutineScope, - listState: LazyListState, - flingBehavior: FlingBehavior, - composerFocus: FocusRequester, - setComposerText: (String) -> Unit, - view: View, - ): List { - FrameStats.reset() - DebugStats.reset() - val cpuStartMs = Process.getElapsedCpuTime() - - val battery = BatterySampler(context) - // Launched in the caller's scope rather than a fresh coroutineScope{} here, which would - // suspend this function until the sampler job ended -- and it only ends when told to. - val samplerJob = scope.launch { - while (isActive) { - battery.sample() - delay(1000) - } - } - - val travel = runFlingPhase(listState, flingBehavior) - val sent = runStreamPhase() - runTypePhase(listState, composerFocus, setComposerText, view) - val keyboard = runKeyboardPhase(context, view) - - samplerJob.cancel() - val cpuMs = Process.getElapsedCpuTime() - cpuStartMs - val rssLine = peakRssLine() - val batteryLine = battery.finish() - - return listOf( - " fling: $FLING_COUNT flings out + $FLING_COUNT back at" + - " ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel", - " scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " + - "streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events", - " type: ${TYPE_TEXT.length} characters inserted then deleted, one per" + - " ${TYPE_CHAR_DELAY_MS}ms", - keyboard, - " process CPU time over this run: ${cpuMs}ms", - rssLine, - batteryLine, - ) - } - - /** - * Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older - * messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here - * matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout` - * pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport - * toward higher indices, i.e. away from the newest end and toward older content), the same sign - * the pre-v2 swipe loop below already used for its first two swipes. - */ - private suspend fun runFlingPhase( - listState: LazyListState, - flingBehavior: FlingBehavior, - ): String { - FrameStats.markPhase("fling") - listState.scrollToItem(0) - val start = position(listState) - repeat(FLING_COUNT) { - listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } } - waitForSettle(listState) - delay(FLING_PAUSE_MS) - } - val outward = position(listState) - repeat(FLING_COUNT) { - listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } } - waitForSettle(listState) - delay(FLING_PAUSE_MS) - } - val back = position(listState) - return "start=$start outward=$outward end=$back" - } - - private fun position(listState: LazyListState) = - "idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px" - - /** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */ - private suspend fun waitForSettle(listState: LazyListState) { - val startedAt = System.currentTimeMillis() - while ( - listState.isScrollInProgress && - System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS - ) { - delay(16) - } - } - - /** - * Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way - * stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back - * arrives off-screen and the report would show nothing happened. - */ - private suspend fun runStreamPhase(): Int { - FrameStats.markPhase("stream") - var sent = 0 - val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS - while (sent < total && BenchFixture.remainingStreamEvents() > 0) { - BenchFixture.pushNextLiveEvent() - sent++ - delay(1000L / STREAM_EVENTS_PER_SEC) - } - // Lets the last few deltas land and draw before the next phase starts. - delay(300) - return sent - } - - /** - * Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types - * [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke - * updates, and deletes it the same way -- this is what exercises wrapping and the transcript - * being pushed upward, not a single big write. - */ - private suspend fun runTypePhase( - listState: LazyListState, - composerFocus: FocusRequester, - setComposerText: (String) -> Unit, - view: View, - ) { - FrameStats.markPhase("type") - listState.scrollToItem(0) - composerFocus.requestFocus() - showIme(view.context, view) - // Lets focus and the keyboard's opening animation land before typing starts, so the frames - // this phase records are the wrap/reflow it is measuring, not the keyboard opening. - delay(300) - var typed = "" - for (ch in TYPE_TEXT) { - typed += ch - setComposerText(typed) - delay(TYPE_CHAR_DELAY_MS) - } - delay(200) - while (typed.isNotEmpty()) { - typed = typed.dropLast(1) - setComposerText(typed) - delay(TYPE_CHAR_DELAY_MS) - } - } - - /** - * Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat] - * path a real IME toggle goes through, reporting how many of each were actually confirmed by - * [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES: - * never present an inferred value as a measured one. If the platform never shows it even once, - * this says so in words rather than reporting a phase with no keyboard in it. - */ - private suspend fun runKeyboardPhase(context: Context, view: View): String { - FrameStats.markPhase("keyboard") - var shown = 0 - var hidden = 0 - repeat(KEYBOARD_CYCLES) { - showIme(context, view) - delay(KEYBOARD_SHOW_WAIT_MS) - if (imeVisible(view)) shown++ - hideIme(context, view) - delay(KEYBOARD_HIDE_WAIT_MS) - if (!imeVisible(view)) hidden++ - } - return if (shown == 0) { - " keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)" - } else { - " keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" + - " (confirmed via isImeVisible)" - } - } - - private fun controller(context: Context, view: View): WindowInsetsControllerCompat? { - val window = context.activity()?.window ?: return null - return WindowInsetsControllerCompat(window, view) - } - - private fun showIme(context: Context, view: View) { - controller(context, view)?.show(WindowInsetsCompat.Type.ime()) - } - - private fun hideIme(context: Context, view: View) { - controller(context, view)?.hide(WindowInsetsCompat.Type.ime()) - } - - private fun imeVisible(view: View): Boolean = - ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false - - /** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */ - private fun peakRssLine(): String { - val kb = - try { - File("/proc/self/status") - .readLines() - .firstOrNull { it.startsWith("VmHWM:") } - ?.trim() - ?.removePrefix("VmHWM:") - ?.trim() - ?.removeSuffix("kB") - ?.trim() - ?.toLongOrNull() - } catch (_: Exception) { - null - } - return " peak RSS: " + - (kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)") - } -} - -/** - * Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of - * a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most - * emulators -- and that is reported as "unavailable" rather than folded into an average with the - * real samples, which would silently understate every number after it. See UI_RULES: never present - * an inferred value as a measured one. - */ -private class BatterySampler(context: Context) { - private val manager = context.getSystemService(BatteryManager::class.java) - private val samples = mutableListOf() - - fun sample() { - val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) - if (value != null && value != Int.MIN_VALUE) samples.add(value) - } - - fun finish(): String { - if (samples.isEmpty()) return " battery current: unavailable on this device" - val meanUa = samples.sum() / samples.size - return " battery current: mean ${meanUa}µA over ${samples.size} samples" + - " (min ${samples.min()}, max ${samples.max()})" - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt deleted file mode 100644 index a2e72f9..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Bubble.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.OutlinedButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp - -// The composer's row of settings and pickers, and the menus they open. One file because the outline -// and the corner are one appearance: a control shaped like this opens a surface shaped like this. - -/** - * A bordered pill: a control that can be seen without being pressed. - * - * The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at - * all until they are touched. Three bare words under the message field read as a caption about the - * field rather than as three things to press. The outline says "control" without the weight of a - * filled button, which is reserved for the two that act on the session. - */ -@Composable -fun BubbleButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - content: @Composable () -> Unit, -) { - OutlinedButton( - onClick = onClick, - enabled = enabled, - shape = BubbleShape, - // A text button's padding rather than a filled button's 24dp: these sit three across under - // the message field, and the wider padding is what decides whether the row fits. - contentPadding = ButtonDefaults.TextButtonContentPadding, - modifier = modifier, - ) { - content() - } -} - -/** Fully round ends, so the control reads as a bubble rather than as a box. */ -val BubbleShape: Shape = RoundedCornerShape(percent = 50) - -/** - * The corner on a menu one of these opens. - * - * A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding - * ends that tall would bow its sides. - */ -val BubbleMenuShape: Shape = RoundedCornerShape(20.dp) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt deleted file mode 100644 index 6dfad69..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt +++ /dev/null @@ -1,95 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp - -/** - * An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name - * of the operation over it. - * - * One composable rather than a pattern each list repeats, because "this row is busy" has to look - * the same in the import list and the session list or the appearance becomes a per-screen dialect. - * - * [label] names the operation and `null` means none is running. One parameter rather than a boolean - * beside a string, which can disagree. It is a *word* because a spinner alone cannot say which - * operation this is -- deleting and importing are different in kind. - * - * It does **not** make the row inert; the caller disables its own click handling while it passes a - * label. That was the other way round at first -- an overlay consuming pointer events -- and it - * swallowed the drag along with the tap, so a list could not be scrolled while anything in it was - * busy. - */ -@Composable -fun BusyItem(label: String?, content: @Composable () -> Unit) { - Box { - Box(Modifier.busy(label != null)) { content() } - if (label != null) { - Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.width(8.dp)) - // Full strength, over content that is not: the operation is the one thing on - // this row that is still current, and it has to read against a card whose own - // text is still visible behind it. - Text( - label, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } - } - } -} - -/** - * How an item looks while it is being acted on: darker, and nearly grey. - * - * Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a - * busy row read as one more thing that could not be tapped. Draining the colour is what says the - * row is *suspended* -- the status word and everything else that means something by its colour stop - * meaning it for as long as the operation runs, which is exactly true. - * - * Not all the way to grey: a row with no colour left is hard to find again in a list. - */ -private fun Modifier.busy(busy: Boolean): Modifier = - if (!busy) this - else - this.graphicsLayer { alpha = 0.5f } - .drawWithContent { - drawIntoCanvas { canvas -> - canvas.saveLayer( - Rect(Offset.Zero, size), - Paint().apply { - colorFilter = - ColorFilter.colorMatrix( - ColorMatrix().apply { setToSaturation(0.2f) } - ) - }, - ) - drawContent() - canvas.restore() - } - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt deleted file mode 100644 index 088312a..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.unit.dp - -/** Which way a [Chevron] points. */ -enum class Pointing { - Up, - Down, - Left, - Right, -} - -/** - * A chevron, pointing whichever of the four ways is asked for. - * - * Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font - * may simply not have, and the reader who gets an empty box instead is never the one who wrote it. - * - * One composable for all four directions rather than one per axis that differ by which coordinate - * gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one - * direction. The shape is written once in its own coordinates, and [Pointing] is only a table of - * how those map onto the box. - * - * It draws no label of its own, so every caller owes it a `contentDescription`. - */ -@Composable -fun Chevron( - pointing: Pointing, - modifier: Modifier = Modifier, - colour: Color = MaterialTheme.colorScheme.onSurfaceVariant, -) { - val sideways = pointing == Pointing.Left || pointing == Pointing.Right - Canvas( - modifier - .width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN) - .height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH) - ) { - val inset = 2.dp.toPx() - val wide = size.width - inset - val tall = size.height - inset - fun at(across: Float, along: Float) = - when (pointing) { - Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along)) - Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along)) - Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across)) - Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across)) - } - val stroke = 2.dp.toPx() - drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round) - drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round) - } -} - -private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction - -/** How far the chevron opens, across the direction it points. */ -private val CHEVRON_SPAN = 20.dp - -/** How far it reaches in the direction it points. */ -private val CHEVRON_DEPTH = 10.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt deleted file mode 100644 index 072f4e7..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.isTraversalGroup -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import com.mikepenz.markdown.compose.LocalMarkdownColors -import com.mikepenz.markdown.compose.LocalMarkdownDimens -import com.mikepenz.markdown.compose.LocalMarkdownPadding -import com.mikepenz.markdown.model.State -import org.intellij.markdown.MarkdownElementTypes -import org.intellij.markdown.MarkdownTokenTypes -import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.ast.findChildOfType -import org.intellij.markdown.ast.getTextInNode - -/** - * A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing - * sits on, scrolling sideways rather than wrapping. - * - * The renderer's own fence drew the same block in plain text. The scanner that colours a tool - * call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool - * wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than - * wrongly coloured. - * - * Finding the code is still the library's: which children of the node are the fence markers, the - * language word and the code between them is its knowledge of the parser. - */ -@Composable -fun CodeFence( - content: String, - node: ASTNode, - style: TextStyle, - replies: ParsedReplies, - streaming: Boolean = false, -) { - val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return - CodeBlockText(code, language, style, replies, streaming) -} - -/** An indented code block, which is a fence with no language word. */ -@Composable -fun CodeBlock( - content: String, - node: ASTNode, - style: TextStyle, - replies: ParsedReplies, - streaming: Boolean = false, -) { - val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return - CodeBlockText(code, language, style, replies, streaming) -} - -/** - * The code inside a fence or indented block, and the highlighter's language for its info word. - * - * Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and - * the whole point here is that [warm] can run this on a background thread and highlight the same - * string the drawing will ask for. Two extractions would be two keys, and the warmed answer would - * be silently missed at every fence. - * - * Null for a fence too short to hold anything -- an unterminated one still arriving. - */ -fun fenceContent(content: String, node: ASTNode): Pair? { - val word = - node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString() - val language = fenceLanguage(word) - if (node.type == MarkdownElementTypes.CODE_BLOCK) { - val start = node.children.firstOrNull()?.startOffset ?: return null - val end = node.children.lastOrNull()?.endOffset ?: return null - return content.substring(start, end).replaceIndent() to language - } - if (node.children.size < 3) return null - val start = node.children[2].startOffset - val fenceCount = if (word != null && node.children.size > 3) 3 else 2 - val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset - return content.substring(start, end).replaceIndent() to language -} - -/** - * Plain while [streaming], coloured once the block is finished; see [MarkdownRoot]. - * - * The renderer's own block, less what nothing here needs: the same background, corner, padding and - * sideways scroll, without the shadow, the border and the empty pointer handler it also carried. - */ -@Composable -private fun CodeBlockText( - code: String, - language: Language?, - style: TextStyle, - replies: ParsedReplies, - streaming: Boolean, -) { - val colors = LocalMarkdownColors.current - val dimens = LocalMarkdownDimens.current - val padding = LocalMarkdownPadding.current - Box( - Modifier.fillMaxWidth() - .padding(vertical = 8.dp) - .background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize)) - .semantics { isTraversalGroup = true } - ) { - BasicText( - // No language while the block is still being written, which is what draws it plain. - replies.highlighted(code, language.takeUnless { streaming }), - style = style, - modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock), - ) - } -} - -/** - * The highlighter's language for a fence's info word, or null for one it has no rules for. - * - * The aliases are what people actually write after the backticks: the file extension as often as - * the name. A word not here gets no colour rather than the nearest language's, because a fence - * coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot - * see. - */ -fun fenceLanguage(name: String?): Language? = - FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null] - -/** - * The highlighter's language for a *file*, from its name. - * - * The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people - * write after the backticks. One table rather than two, so a language added for fences is a - * language added for files and neither can be the one somebody forgot. - * - * The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A - * leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name - * with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain. - */ -fun fileLanguage(name: String): Language? { - val dot = name.lastIndexOf('.') - if (dot < 1) return null - return fenceLanguage(name.substring(dot + 1)) -} - -private val FENCE_LANGUAGES: Map = - mapOf( - "kotlin" to Language.KOTLIN, - "kt" to Language.KOTLIN, - "kts" to Language.KOTLIN, - "rust" to Language.RUST, - "rs" to Language.RUST, - "sh" to Language.SHELL, - "bash" to Language.SHELL, - "shell" to Language.SHELL, - "zsh" to Language.SHELL, - "console" to Language.SHELL, - "python" to Language.PYTHON, - "py" to Language.PYTHON, - "javascript" to Language.JAVASCRIPT, - "js" to Language.JAVASCRIPT, - "jsx" to Language.JAVASCRIPT, - "typescript" to Language.TYPESCRIPT, - "ts" to Language.TYPESCRIPT, - "tsx" to Language.TYPESCRIPT, - "java" to Language.JAVA, - "c" to Language.C, - "h" to Language.C, - "cpp" to Language.CPP, - "c++" to Language.CPP, - "cc" to Language.CPP, - "hpp" to Language.CPP, - "csharp" to Language.CSHARP, - "cs" to Language.CSHARP, - "c#" to Language.CSHARP, - "go" to Language.GO, - "golang" to Language.GO, - "swift" to Language.SWIFT, - "dart" to Language.DART, - "ruby" to Language.RUBY, - "rb" to Language.RUBY, - "php" to Language.PHP, - "perl" to Language.PERL, - "pl" to Language.PERL, - "coffeescript" to Language.COFFEESCRIPT, - "coffee" to Language.COFFEESCRIPT, - "ron" to Language.RON, - "toml" to Language.TOML, - "fish" to Language.FISH, - "json" to Language.JSON, - "markdown" to Language.MARKDOWN, - "md" to Language.MARKDOWN, - ) - -/** - * Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole - * tree rather than the top level: a fence inside a list item or a quote is drawn the same way and - * costs the same to lex. - */ -fun fences(parse: State): List> { - val success = parse as? State.Success ?: return emptyList() - val out = ArrayList>() - fun walk(node: ASTNode) { - if ( - node.type == MarkdownElementTypes.CODE_FENCE || - node.type == MarkdownElementTypes.CODE_BLOCK - ) { - fenceContent(success.content, node)?.let { if (it.second != null) out += it } - return - } - node.children.forEach(::walk) - } - walk(success.node) - return out -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt deleted file mode 100644 index f8da571..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt +++ /dev/null @@ -1,151 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -/** - * Something a session can be asked to do to itself, rather than something to say to it. - * - * These are the two this app understands, and understanding them is what lets it show them: a - * suggestion while one is being typed, a name in the settings screen that sends one, and a bubble - * that stays up while the session is too busy to run it. Anything else beginning with "/" is passed - * through, because a dialect's own vocabulary grows without this list. - */ -data class SessionCommand( - /** With the slash, as it is typed and as it is sent. */ - val name: String, - /** One line, in the suggestion list: what it does, not how. */ - val summary: String, - /** What follows the name, named for the reader, or null when nothing does. */ - val argument: String?, -) { - /** What to put in the box when this is picked: ready to send, or ready to be finished. */ - fun typed(): String = if (argument == null) name else "$name " -} - -val SESSION_COMMANDS = - listOf( - SessionCommand( - "/compact", - "Summarise the conversation so far and carry on from the summary", - null, - ), - SessionCommand( - "/clear", - "Start fresh: drop the conversation from the session's context, keeping it on screen", - null, - ), - SessionCommand("/rename", "Change what this session is called", "name"), - ) - -/** - * The commands worth offering for what has been typed so far. - * - * Only for a line that starts with a slash and has not yet become a whole command with an argument - * -- once there is something after "/rename ", the reader is writing the name and a list of - * commands underneath it is in the way. - */ -fun suggestedCommands(input: String): List { - if (!input.startsWith("/") || input.contains(' ')) return emptyList() - return SESSION_COMMANDS.filter { it.name.startsWith(input) } -} - -/** - * The commands matching what is being typed, above the box they are being typed into. - * - * Above rather than over: a list that covers the transcript hides what the command is about, and - * the reader is usually looking at the thing they mean to act on. - */ -@Composable -fun CommandSuggestions( - commands: List, - onPick: (SessionCommand) -> Unit, - modifier: Modifier = Modifier, -) { - if (commands.isEmpty()) return - Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) { - Column(Modifier.padding(vertical = 4.dp)) { - commands.forEach { command -> - Row( - Modifier.fillMaxWidth() - .clickable { onPick(command) } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - // The command in the colour commands are, so the suggestion and the bubble - // it becomes are visibly the same thing. - if (command.argument == null) command.name - else "${command.name} <${command.argument}>", - style = MaterialTheme.typography.titleSmall, - color = commandColor, - ) - Spacer(Modifier.width(12.dp)) - Text( - command.summary, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -/** - * A command, where the reader put it: at their end of the conversation. - * - * Blue rather than the colour of something they said, because they did not say it to the model -- - * it is an instruction to the session, and the reply to it is the session changing rather than - * anything appearing here. - * - * [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a - * reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes. - */ -@Composable -fun CommandBubble(text: String, waiting: Boolean = false) { - Box(Modifier.fillMaxWidth()) { - Card( - colors = CardDefaults.cardColors(containerColor = commandColor), - modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), - ) { - Column(Modifier.padding(12.dp)) { - // Stated beside the fill rather than inherited: a semantic colour has to carry its - // own contrast, because the surface under it will not change to rescue it. - Text(text, color = MaterialTheme.colorScheme.inverseOnSurface) - if (waiting) { - Spacer(Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator( - modifier = Modifier.width(12.dp).height(12.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.inverseOnSurface, - ) - Spacer(Modifier.width(6.dp)) - Text( - "waiting for this turn to end", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.inverseOnSurface, - ) - } - } - } - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt deleted file mode 100644 index 0b199b9..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.example.aiapp - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * The mark a compaction leaves in the transcript. - * - * A divider rather than something anybody said: everything above it is out of the session's context - * now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider], - * which a clear also uses, so the two marks cannot drift apart. - * - * Blue is [commandColor]: the session acting on itself rather than working on what was asked of it. - */ -@Composable -fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) { - TranscriptDivider(compactionSummary(item), commandColor, modifier) -} - -/** - * What to say about a compaction: the two sizes, and nothing else. - * - * The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer - * to why the wait was worth it. When they were not reported this says only that a compaction - * happened, rather than filling in a plausible number. - */ -fun compactionSummary(item: TranscriptItem.CompactedNote): String { - val pre = item.preTokens - val post = item.postTokens - return if (pre != null && post != null) { - "Compacted • ${tokens(pre)} → ${tokens(post)} tok" - } else { - "Compacted" - } -} - -/** - * A token count as a reader reads one. - * - * Shared with the status row rather than formatted at each: the divider and the row report the same - * quantity about the same moment, and one grouping its thousands while the other did not read as - * two different measurements. - */ -fun tokens(count: Long): String = "%,d".format(count) - -/** - * What the working indicator says while a compaction is running. - * - * Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a - * compaction has begun and then says nothing until it has finished, so any bar or estimate here - * would be this screen's guess wearing a measurement's clothes. - * - * [seconds] is null when this device did not see the compaction start, which is what opening a - * session that is already compacting looks like. That case says only "compacting": a number counted - * from the moment the screen opened would be wrong in the direction that matters. - */ -fun compactingLabel(seconds: Long?): String = - when { - seconds == null -> "compacting" - seconds < 60 -> "compacting ${seconds}s" - else -> "compacting ${seconds / 60}m ${seconds % 60}s" - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt deleted file mode 100644 index 8ef3037..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import java.io.File -import java.io.PrintWriter -import java.io.StringWriter -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * The last crash, kept so the debug button can hand it over. - * - * The alternative is asking somebody to reproduce a crash with the phone plugged into a computer - * and `logcat` running, which is the one thing nobody has set up at the moment it happens. This - * costs one file write on a process that is already dying, and it turns "it crashes when I open - * that chat" into the frame it crashed in. - * - * Kept until it is read rather than cleared on the next launch: the app restarts before anybody can - * ask about it, so a log that lives for one session is a log that is never read. - */ -private const val CRASH_FILE = "last-crash.txt" - -/** - * How much of a stack is kept. - * - * This is pasted into a conversation, so it has a budget like any other output written for a - * reader. The top of a stack is what identifies a crash and the bottom is framework plumbing. - */ -private const val CRASH_LIMIT = 4000 - -/** - * Records uncaught exceptions, then lets the platform do what it was going to do. - * - * Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and - * ends the process, and an app that swallows that instead sits there in an unknown state. - */ -fun installCrashLog(context: Context) { - val app = context.applicationContext - val previous = Thread.getDefaultUncaughtExceptionHandler() - Thread.setDefaultUncaughtExceptionHandler { thread, error -> - runCatching { File(app.filesDir, CRASH_FILE).writeText(describe(thread, error)) } - previous?.uncaughtException(thread, error) - } -} - -private fun describe(thread: Thread, error: Throwable): String { - val when_ = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date()) - val stack = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString() - val kept = - if (stack.length <= CRASH_LIMIT) stack - else stack.take(CRASH_LIMIT) + "\n ... ${stack.length - CRASH_LIMIT} more characters" - return "$when_ on thread ${thread.name}\n$kept" -} - -/** The last crash, or null if there has not been one since it was last read. */ -fun lastCrash(context: Context): String? = - File(context.applicationContext.filesDir, CRASH_FILE).takeIf { it.exists() }?.readText() - -/** Forgets the last crash, once somebody has taken a copy of it. */ -fun clearCrash(context: Context) { - File(context.applicationContext.filesDir, CRASH_FILE).delete() -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt deleted file mode 100644 index baa683d..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.example.aiapp - -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.core.content.getSystemService -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicLong - -/** - * Counters and timers for the work the transcript does, for the readout behind the debug button. - * - * Here because the emulator cannot answer the question this is for. Its own scroll sits at the same - * frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost - * is under the floor of what it can measure. Counts do not have that problem: how many times a row - * was composed, or a reply parsed, is the same number on any machine, and it is the number that - * says whether the work is proportional to what is on screen or to everything ever loaded. - * - * Always on rather than behind a build flag. What is measured is an atomic increment on paths that - * already allocate lists and parse markdown, and a counter that is only compiled into the build - * nobody is holding when it is slow is not an instrument. - */ -object DebugStats { - private val counts = ConcurrentHashMap() - private val nanos = ConcurrentHashMap() - private val worst = ConcurrentHashMap() - - private fun at(map: ConcurrentHashMap, name: String) = - map.computeIfAbsent(name) { AtomicLong() } - - fun count(name: String, by: Long = 1) { - at(counts, name).addAndGet(by) - } - - /** Keeps [name] at the largest value it has been given, for a high-water mark. */ - fun atLeast(name: String, value: Long) { - val slot = at(counts, name) - while (true) { - val had = slot.get() - if (value <= had || slot.compareAndSet(had, value)) break - } - } - - /** Records one occurrence of [name] that took [elapsed] nanoseconds. */ - fun record(name: String, elapsed: Long) { - count(name) - at(nanos, name).addAndGet(elapsed) - val slot = at(worst, name) - while (true) { - val had = slot.get() - if (elapsed <= had || slot.compareAndSet(had, elapsed)) break - } - } - - fun timed(name: String, body: () -> T): T { - val started = System.nanoTime() - try { - return body() - } finally { - record(name, System.nanoTime() - started) - } - } - - fun reset() { - counts.clear() - nanos.clear() - worst.clear() - } - - /** One line per counter: how many, how long in total, and the worst single one. */ - fun lines(): List = - counts.keys.sorted().map { name -> - val n = counts[name]?.get() ?: 0 - val total = nanos[name]?.get() ?: 0 - if (total == 0L) " $name: $n" - else - " $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," + - " ${ms(worst[name]?.get() ?: 0)}ms worst" - } - - /** How long everything named [name] took in total, or zero if it never happened. */ - fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0 - - private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0) -} - -/** - * How much of the frame's draw phase is this app's own work, and how much is not. - * - * The draw phase is where Compose's measurement lands as well as its recording -- the platform - * calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three - * different things is high. The transcript times its own measure, placement and recording, and this - * is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout, - * which grows with how many nodes are alive rather than how many are on screen. - * - * Per frame rather than in total, because the budget it has to fit in is per frame. The recordings - * are not themselves per-frame, so these are shares of an average frame. - */ -fun drawAccounting(drawNanos: Long, frames: Int): List { - if (frames == 0 || drawNanos == 0L) return emptyList() - val measure = DebugStats.nanosOf("measure: the whole transcript") - val place = DebugStats.nanosOf("place: the whole transcript") - // The rows and blocks record *inside* this one, so adding them too would count them twice. - val record = DebugStats.nanosOf("draw: the whole transcript") - val ours = measure + place + record - val rest = (drawNanos - ours).coerceAtLeast(0) - fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames) - return listOf( - " draw phase ${per(drawNanos)}ms per frame, of which:", - " the transcript: ${per(ours)}ms" + - " (measure ${per(measure)}, place ${per(place)}, record ${per(record)})", - " everything else: ${per(rest)}ms" + - " (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})", - ) -} - -/** - * Everything the debug button copies: what the device is, what the transcript is holding, where the - * frames went, and what the app did to produce them. - * - * Written for somebody to paste into a conversation, so it is plain text with the units on every - * number -- a report whose reader has to ask what the columns mean costs another round trip. - */ -fun debugReport( - device: String, - transcript: List, - frames: List, - accounting: List, - crash: String?, - /** - * P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on - * every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section - * heading only appears when there is something to put under it, so an ordinary copy from the - * render-report button reads exactly as it did before this existed. - */ - extra: List = emptyList(), - /** - * Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) -- - * fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below - * still cover in full. Empty on every path but the scripted bench run, same reasoning as - * [extra]. - */ - phaseFrames: List = emptyList(), -): String = buildString { - appendLine("ai-app render report") - appendLine(device) - appendLine() - // First, because a crash outranks every timing below it and the reader should not have to - // scroll past two screens of counters to find out the app fell over. - if (crash != null) { - appendLine("last crash:") - crash.trimEnd().lines().forEach { appendLine(" $it") } - appendLine() - } - appendLine("transcript:") - transcript.forEach { appendLine(it) } - appendLine() - if (phaseFrames.isNotEmpty()) { - appendLine("per phase:") - phaseFrames.forEach { appendLine(it) } - appendLine() - } - appendLine("frames:") - frames.forEach { appendLine(it) } - appendLine() - if (accounting.isNotEmpty()) { - appendLine("where the draw phase went:") - accounting.forEach { appendLine(it) } - appendLine() - } - appendLine("work since this was last copied:") - val work = DebugStats.lines() - if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) } - if (extra.isNotEmpty()) { - appendLine() - appendLine("bench:") - extra.forEach { appendLine(it) } - } -} - -/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */ -fun Context.copyToClipboard(label: String, text: String) { - getSystemService()?.setPrimaryClip(ClipData.newPlainText(label, text)) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt deleted file mode 100644 index 57600e1..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle - -/** - * A line across the transcript saying what left the session's context. - * - * Centred between two rules, because it is a divider rather than something anybody said. Two things - * produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a - * reader scrolling back, both mean "the session no longer has what is above this", and which of the - * two it was is said by the words and the colour. - * - * The rules take [color] too, so the whole divider reads as one mark of one kind. - */ -@Composable -fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.fillMaxWidth().padding(vertical = 8.dp), - ) { - HorizontalDivider(Modifier.weight(1f), color = color) - Text(text, style = MaterialTheme.typography.bodySmall, color = color) - HorizontalDivider(Modifier.weight(1f), color = color) - } -} - -/** - * The mark a clear leaves. - * - * Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a - * compaction it summarises nothing and measures nothing. Everything above stays on screen and stays - * scrollable -- the reader can see that, which is why this does not say it. - */ -@Composable -fun ClearedRow(modifier: Modifier = Modifier) { - TranscriptDivider("Context cleared", clearedColor, modifier) -} - -/** - * The mark running out of quota leaves. - * - * The same red the usage bar takes when a window is spent, because it is the same fact in a second - * place: colour by consequence, so "there is nothing left to spend" is learned once. - * - * A time rather than a countdown. The row is folded once and never re-measured, so a span would go - * stale on screen the moment it was drawn; and this is when the *account* said it would reset, - * which is not a promise about when the session picks back up. A limit the session was told no - * reset time for says nothing about one -- that state has its own words rather than a plausible - * number. - */ -@Composable -fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) { - TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier) -} - -/** - * What the row says. Split out so the wording is testable without a screen, since the two states it - * has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair - * that reads the same when it goes wrong. - * - * [zone] is a parameter rather than read here so a test says the same thing wherever it runs. - */ -fun limitSummary(resetsAt: Double?, zone: ZoneId): String { - val at = resetsAt?.let { - try { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - .withZone(zone) - .format(Instant.ofEpochSecond(it.toLong())) - } catch (_: Exception) { - null - } - } - return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at" -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt deleted file mode 100644 index 75f7c0c..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import androidx.core.content.edit - -private const val DRAFTS = "session-drafts" - -/** - * A message typed into a session and not sent yet. - * - * On this device rather than on the backend, which is where this app otherwise keeps state so that - * every device sees it. A draft is the case that rule is not about: it is the contents of a text - * box on the phone somebody is holding, and half a sentence surfacing on another device would be a - * surprise. What has been *sent* is the server's. - * - * Kept per session id: one shared box would hand a message meant for one session to whichever was - * opened next. - */ -fun loadDraft(context: Context, sessionId: String): String = - context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty() - -/** - * Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep. - * - * The path out is emptying the box, which is what sending does. A session *deleted* while it held a - * draft does leave its key behind: pruning those means a pass over the live session list, and the - * residue is a few bytes per session ever abandoned mid-sentence. - */ -fun saveDraft(context: Context, sessionId: String, text: String) { - context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit { - if (text.isEmpty()) remove(sessionId) else putString(sessionId, text) - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt deleted file mode 100644 index 30db84c..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Durations.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.example.aiapp - -/** - * A span of milliseconds, written the way somebody reads it. - * - * A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two - * halves, because a short span and a long one are read for different things. Under a minute the - * question is "roughly how long", so only the largest unit is shown and a fraction carries the rest - * -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something - * in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero. - * - * Sub-second precision is dropped past a minute: nothing that takes days is measured in - * milliseconds. - */ -fun formatMillis(ms: Long): String { - if (ms < 0) return "-" + formatMillis(-ms) - if (ms < 1000) return "${ms}ms" - if (ms < 60_000) { - val tenths = (ms + 50) / 100 - val whole = tenths / 10 - val rest = tenths % 10 - return if (rest == 0L) "${whole}s" else "$whole.${rest}s" - } - val seconds = ms / 1000 - val parts = - listOf( - "d" to seconds / 86_400, - "h" to seconds / 3600 % 24, - "m" to seconds / 60 % 60, - "s" to seconds % 60, - ) - return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" } -} - -/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */ -fun formatMillisText(text: String): String = - text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt deleted file mode 100644 index 6f30cb2..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.aiapp - -/** - * The frame name the server uses to say a cursor was too far behind to continue from. Must match - * `send_backlog` in the backend's routes.rs. - */ -private const val RESET_EVENT = "reset" - -/** - * The SSE half of the API: one long-lived GET per open session screen, replaying the transcript - * after a cursor and then following it live. - * - * The connection and its framing belong to [Sse]; what stays here is what this stream's frames - * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it - * saw as the new cursor. - */ -class EventStream(settings: ServerSettings, private val address: TranscriptAddress) { - private val stream = Sse(settings) - - fun close() = stream.close() - - /** - * Streams events after [after] into [onEvent] until the stream drops. - * - * [onReset] fires when the server answers that the cursor is too far behind to continue from: - * everything already displayed is stale and the events that follow are a fresh window, so the - * caller drops what it holds and rebuilds. It arrives before those events, so a caller that - * clears on it stays in order. - */ - fun run( - after: Long, - onOpen: () -> Unit, - onReset: () -> Unit, - // The frame's own text as well as the event parsed from it: the transcript cache stores the - // one and the screen folds the other, and they have to be the same line. - onEvent: (raw: String, event: SeqEvent) -> Unit, - ) { - stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data -> - // A named frame carries no payload and a data frame has no name. - if (name == RESET_EVENT) onReset() - else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt deleted file mode 100644 index 8580867..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt +++ /dev/null @@ -1,317 +0,0 @@ -package com.example.aiapp - -import org.json.JSONObject - -// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from -// this stream (replayed from the transcript by cursor, then live), so there is no separate "load -// history" shape to keep in sync with it. - -/** One transcript line: the event plus its resume cursor and time. */ -data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent) - -/** - * One choice offered in answer to a question. - * - * More than a label because the reader is deciding rather than confirming. Both are absent on a - * permission, whose Allow and Deny mean exactly what they say. - */ -data class QuestionOption(val label: String, val description: String?, val preview: String?) - -sealed class SessionEvent { - data class UserMessage( - val text: String, - /** - * The [MessageQueued] this resolves, or null when it never waited. - * - * Matched on rather than the text, because the same message sent twice is two waiting - * bubbles and clearing whichever one matched first would leave the wrong one on screen. - */ - val id: String?, - /** - * What was attached to it, by the ref the files route serves: images, and any file, told - * apart by [isImageRef]. - * - * On the message rather than beside it: these arrived as separate image events until - * 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent - * it, and left this app deciding from adjacency which message an image went with. - */ - val attachments: List, - ) : SessionEvent() - - /** - * A message the server has accepted and the session has not read yet. - * - * From the server, not from this app's memory of what it sent. The pending bubble used to be - * screen state, so leaving the session drew nothing waiting while the message was still queued - * -- and nothing waiting is what "there is nothing" looks like. - * - * Resolved by the [UserMessage] carrying the same id. - */ - data class MessageQueued(val id: String, val text: String, val attachments: List) : - SessionEvent() - - /** - * A queued message taken back before the session read it. - * - * Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects - * replays both, and without this one it would put back a bubble for a message that is never - * coming. - */ - data class MessageDropped(val id: String) : SessionEvent() - - data class AssistantText(val delta: String) : SessionEvent() - - data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent() - - data class ToolUpdate(val id: String, val output: String) : SessionEvent() - - data class ToolEnd(val id: String, val output: String) : SessionEvent() - - data class Image( - val ref: String, - /** The tool call whose result carried it, or null for a person's own attachment. */ - val about: String?, - ) : SessionEvent() - - data class Question( - val id: String, - val prompt: String, - /** A few words naming what the question is about, when the asker offered one. */ - val header: String?, - val options: List, - /** Whether several options may be chosen at once. */ - val multiSelect: Boolean, - /** The tool call this is permission for, or null when it is not about one. */ - val about: String?, - ) : SessionEvent() - - /** Everything chosen for one question, in the order it was offered. */ - data class Answered(val id: String, val answers: List) : SessionEvent() - - /** - * A message another agent sent this session. - * - * Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would - * claim they had. It is also the explanation for a session that starts working on something - * this device never asked for. - */ - data class PeerMessage( - val from: String, - val text: String, - /** - * Where the turn this started begins, when the server could say. - * - * The live Claude Code path only learns a turn was somebody else's when the turn ends, so - * the event arrives below everything it caused; this is what puts it back above it. Null - * for a message read out of a session file, and for one that started no turn. - */ - val turnStart: Long? = null, - ) : SessionEvent() - - /** - * A command the session was asked to run on itself and cannot run yet. Resolved by - * [CommandSent] with the same id; a command that ran straight away has only that one. - */ - data class CommandQueued(val id: String, val text: String) : SessionEvent() - - /** The same command, handed to the session. */ - data class CommandSent(val id: String, val text: String) : SessionEvent() - - data class Status(val state: String) : SessionEvent() - - /** - * What the session is set to, as the session itself reports it. - * - * Either field alone: the two are confirmed separately. Asking for a change is not having one, - * so this -- not the request -- is what the pickers show. - */ - data class Settings(val model: String?, val permissionMode: String?) : SessionEvent() - - /** - * What a turn cost, and how much the model was holding when it ended. - * - * [context] is prompt plus both cache figures. Carried on the event rather than summed by the - * reader, because it is not a sum: a conversation's context drops at a compaction and a clear, - * so adding turns up would report a figure the session stopped being true of. Null where the - * dialect did not say, which leaves the context unmeasured rather than unchanged. - */ - data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent() - - /** - * A compaction that finished, and how much context it recovered. - * - * The counts are nullable because the server sends them only when it was told them: a zero here - * would read as "recovered nothing" and a made-up number would read as a measurement. - */ - data class Compacted( - val preTokens: Long?, - val postTokens: Long?, - /** What asked for it, in the CLI's own word; `auto` is the one worth naming. */ - val trigger: String?, - ) : SessionEvent() - - /** - * The conversation was cleared. Everything above this is still here to read and is no longer in - * the session's context. An object rather than a class because what it means is entirely its - * position in the transcript. - */ - data object Cleared : SessionEvent() - - /** - * The session stopped because its account's usage limit was reached. - * - * Its own event rather than an [Error] carrying the CLI's sentence, because it is a state - * rather than something that went wrong -- and because the raw sentence is `Claude AI usage - * limit reached|1788546972`, which is not readable by the person it is shown to. - * - * [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts - * on it; what this draws it as is a time, not a countdown, because nothing here re-measures it. - */ - data class LimitReached(val resetsAt: Double?) : SessionEvent() - - data class Error(val message: String) : SessionEvent() - - /** - * An event type this app build doesn't know -- a newer server. Kept rather than thrown so one - * new event kind degrades to a placeholder row instead of killing the stream. - */ - data class Unknown(val type: String) : SessionEvent() -} - -/** - * A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since - * the server omits the field rather than sending an empty list. - */ -private fun JSONObject.stringList(name: String): List { - val array = optJSONArray(name) ?: return emptyList() - return (0 until array.length()).map { array.getString(it) } -} - -fun parseSeqEvent(json: String): SeqEvent { - val body = JSONObject(json) - val event = - when (val type = body.getString("type")) { - "userMessage" -> - SessionEvent.UserMessage( - body.getString("text"), - body.optString("id").ifEmpty { null }, - body.stringList("attachments"), - ) - "messageQueued" -> - SessionEvent.MessageQueued( - body.getString("id"), - body.getString("text"), - body.stringList("attachments"), - ) - "messageDropped" -> SessionEvent.MessageDropped(body.getString("id")) - "assistantText" -> SessionEvent.AssistantText(body.getString("delta")) - "toolStart" -> - SessionEvent.ToolStart( - id = body.getString("id"), - tool = body.getString("tool"), - // Kept as raw JSON text: the input shape is the tool's own business, and the UI - // only ever shows it verbatim. - input = body.get("input").toString(), - ) - "toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output")) - "toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output")) - "image" -> - SessionEvent.Image( - ref = body.getString("ref"), - about = body.optString("about").ifEmpty { null }, - ) - "question" -> - SessionEvent.Question( - id = body.getString("id"), - prompt = body.getString("prompt"), - header = body.optString("header").ifEmpty { null }, - options = - body.getJSONArray("options").let { options -> - (0 until options.length()).map { at -> - val option = options.getJSONObject(at) - QuestionOption( - label = option.getString("label"), - description = option.optString("description").ifEmpty { null }, - preview = option.optString("preview").ifEmpty { null }, - ) - } - }, - multiSelect = body.optBoolean("multiSelect", false), - about = body.optString("about").ifEmpty { null }, - ) - "answered" -> - SessionEvent.Answered( - body.getString("id"), - body.getJSONArray("answers").let { answers -> - (0 until answers.length()).map { answers.getString(it) } - }, - ) - "peerMessage" -> - SessionEvent.PeerMessage( - body.getString("from"), - body.getString("text"), - if (body.has("turnStart")) body.getLong("turnStart") else null, - ) - "commandQueued" -> - SessionEvent.CommandQueued(body.getString("id"), body.getString("text")) - "commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text")) - "status" -> SessionEvent.Status(body.getString("state")) - "settings" -> - SessionEvent.Settings( - model = body.optString("model").ifEmpty { null }, - permissionMode = body.optString("permissionMode").ifEmpty { null }, - ) - "usageDelta" -> - SessionEvent.UsageDelta( - body.getLong("tokens"), - if (body.has("context")) body.getLong("context") else null, - ) - "compacted" -> - SessionEvent.Compacted( - preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null, - postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null, - trigger = body.optString("trigger").ifEmpty { null }, - ) - "cleared" -> SessionEvent.Cleared - "limitReached" -> - SessionEvent.LimitReached( - if (body.has("resetsAt")) body.getDouble("resetsAt") else null - ) - "error" -> SessionEvent.Error(body.getString("message")) - else -> SessionEvent.Unknown(type) - } - return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) -} - -/** - * Whether [state] is one the session is doing work in -- the states a turn is still open under. - * - * One predicate because two readers have to agree on the list: the session screen's working - * indicator, and the fold's decision that the newest reply is finished. Two copies would drift the - * first time the server grows a state, and the drift would be a reply that never splits or one - * split mid-stream. - */ -fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting" - -/** - * The context after [event], given what it was before. - * - * The same rule the server folds with, because the screen has to keep up between page loads: the - * summary it opened with is a measurement from before this stream started. - * - * The two that lower it are the point. A clear takes the conversation away and a compaction - * replaces it with a summary, so a figure measured before either stopped being true at that moment - * -- and carrying it forward is how a session that had just been cleared went on reporting the - * context it no longer had. - * - * Null is "we don't know", which each of them can reach. - */ -fun contextAfter(current: Long?, event: SessionEvent): Long? = - when (event) { - // Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn - // -- which every context figure is -- rather than unknown. - is SessionEvent.UsageDelta -> event.context ?: current - is SessionEvent.Compacted -> event.postTokens - is SessionEvent.Cleared -> null - else -> current - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt deleted file mode 100644 index a467296..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileEditor.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign - -/** - * The largest file this app will open in the editor, in bytes. - * - * Measured on the emulator 2026-09-04, in a debug build, on generated Rust: - * - * | file | lines | scan per keystroke | worst frame record | typing | - * |--------|--------|--------------------|--------------------|-------------------| - * | 32 kB | 917 | 10ms | 183ms | sluggish, correct | - * | 128 kB | 3,633 | 40ms | 2,027ms | characters lost | - * | 1 MB | 28,660 | -- | -- | stops responding | - * - * The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file - * costs 40ms a keystroke, which is survivable, while laying the same text out in one - * `BasicTextField` costs two seconds. So switching highlighting off above a size -- what - * EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single - * text field pays that cost. A line-by-line editor is the way past this. - * - * 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays - * the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not. - */ -const val EDIT_LIMIT = 32L * 1024 - -/** - * The same file, editable, in the same face and colours it was being read in. - * - * `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement - * that colours a field's own text rather than replacing the field with something that only looks - * like one: the transformation returns the text unchanged and the scanner's spans as styles, so - * [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook - * for styles at all. - * - * The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is - * sized against. - * - * The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because - * there are no rows here -- the field is one text object. It lines up for the same reason the - * viewer's does: nothing wraps, so a logical line is a visual line. - */ -@Composable -fun FileEditor( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - language: Language?, - modifier: Modifier = Modifier, -) { - val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface) - val scroll = rememberScrollState() - val count = value.text.removeSuffix("\n").count { it == '\n' } + 1 - val gutter = gutterWidth(count, style) - val numbers = remember(count) { (1..count).joinToString("\n") } - val transformation = - remember(language) { - VisualTransformation { text -> - TransformedText(highlight(text.text, language), OffsetMapping.Identity) - } - } - Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) { - Text( - numbers, - style = style, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.End, - softWrap = false, - modifier = Modifier.width(gutter), - ) - // The same gap the viewer puts between its numbers and its code, so switching between - // reading and editing does not move the text sideways under the reader. - Spacer(Modifier.width(GUTTER_GAP)) - Box(Modifier.horizontalScroll(scroll)) { - BasicTextField( - value = value, - onValueChange = onValueChange, - textStyle = style, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - visualTransformation = transformation, - ) - } - } -} - -/** - * What to do about a file that changed on the machine while it was open here. - * - * Three ways out rather than one, and each says what it costs, because there is no answer this app - * can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both - * versions are somebody's work. - */ -@Composable -fun ConflictDialog( - message: String, - busy: Boolean, - onOverwrite: () -> Unit, - onReload: () -> Unit, - onCancel: () -> Unit, -) { - AlertDialog( - onDismissRequest = onCancel, - // The server's own sentence as the title, rather than a heading of this app's above it - // saying the same thing twice: there is one statement of what happened and it comes from - // the side that found out. - title = { Text(message.replaceFirstChar { it.uppercase() }) }, - text = { - Text( - "Overwrite keeps what you typed and loses the other change. " + - "Reload keeps the other change and loses what you typed. " + - "Cancel leaves both alone and keeps you here." - ) - }, - confirmButton = { - TextButton(onClick = onOverwrite, enabled = !busy) { - Text(if (busy) "Saving..." else "Overwrite") - } - }, - dismissButton = { - Row { - TextButton(onClick = onReload, enabled = !busy) { Text("Reload") } - TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") } - } - }, - ) -} - -/** Leaving an editor with edits in it, which is the one way to lose them by accident. */ -@Composable -fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) { - AlertDialog( - onDismissRequest = onCancel, - title = { Text("Leave without saving?") }, - text = { - Text( - "The edits you have made here will be lost. They have not been written to the machine." - ) - }, - confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } }, - dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } }, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt deleted file mode 100644 index e9129d6..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.example.aiapp - -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString - -/** - * A file split into lines, with the highlighter's colours already worked out for each one. - * - * The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread: - * scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread - * and again on every recomposition. - * - * Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text - * layout is linear in the text. That means each row needs *its* colours, and the scanner answers in - * offsets into the whole file -- so the spans are bucketed here, once, in one pass. - */ -class FileLines -private constructor( - /** The text of each line, without its newline. */ - val lines: List, - /** Per line, the spans that fall in it, with offsets relative to that line's start. */ - private val spans: List>, - /** - * The longest line, in character columns -- what the viewer sizes every row to. - * - * Every row has to be the *same* width or they scroll sideways by different amounts; see - * [FileViewer]. Columns rather than measured pixels because the face is monospace, so one - * number and one character's advance give the width of the widest line without measuring twenty - * thousand strings. - */ - val columns: Int, -) { - val size: Int - get() = lines.size - - /** - * One line, coloured. Built when the row is composed rather than up front: a file has far more - * lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the - * lazy list exists to avoid. - */ - fun line(index: Int): AnnotatedString { - val text = lines[index] - val here = spans[index] - if (here.isEmpty()) return AnnotatedString(text) - val palette = catppuccinSyntax() - return buildAnnotatedString { - append(text) - here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) } - } - } - - companion object { - /** - * [text] scanned as [language] and cut into lines. - * - * Exactly one trailing newline is dropped before splitting, so a file that ends the way - * text files are supposed to end has the number of lines its author would count -- `wc -l` - * agrees. Without that, every well-formed file gained a phantom empty last line. An empty - * file is one empty line numbered 1, which is what it is. - */ - fun of(text: String, language: Language?): FileLines = - // Timed, and always, for the reason everything else here is: the cost of opening a - // large file is the number that decides whether the server's size limit is right, and - // an instrument that is only in the build nobody is running answers nothing. - DebugStats.timed("file scanned and cut into lines") { - val body = text.removeSuffix("\n") - val lines = body.split('\n') - val scanned = if (language == null) emptyList() else spansOf(body, language) - FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf)) - } - - /** - * How many columns a line occupies. - * - * A tab counts as eight rather than one, and deliberately upwards: this decides how far the - * viewer can scroll, and over-estimating leaves a little empty space past the longest line - * where under-estimating makes the end of that line unreachable. - */ - private fun columnsOf(line: String): Int { - var count = 0 - for (character in line) count += if (character == '\t') 8 else 1 - return count - } - - /** - * The scanner's spans, in file offsets, as spans per line in line offsets. - * - * One walk down both lists, which is what the scanner's guarantee buys: its spans come out - * ordered, non-overlapping and inside the text. A span crossing a line break is cut at each - * break and appears in each line it covers, because a row is drawn on its own and cannot - * inherit a colour from the row above. - */ - private fun bucket(lines: List, spans: List): List> { - val out = ArrayList>(lines.size) - var lineStart = 0 - var next = 0 - for (line in lines) { - val lineEnd = lineStart + line.length - var here: ArrayList? = null - // Spans that ended before this line begins are behind the walk for good. - while (next < spans.size && spans[next].end <= lineStart) next++ - var at = next - while (at < spans.size && spans[at].start < lineEnd) { - val span = spans[at] - val start = maxOf(span.start, lineStart) - lineStart - val end = minOf(span.end, lineEnd) - lineStart - if (end > start) { - (here ?: ArrayList().also { here = it }).add( - Span(start, end, span.kind) - ) - } - at++ - } - out.add(here ?: emptyList()) - // The newline itself, which is in the text and not in any line. - lineStart = lineEnd + 1 - } - return out - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt deleted file mode 100644 index d60c5dc..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ /dev/null @@ -1,242 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.overscroll -import androidx.compose.foundation.rememberOverscrollEffect -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */ -@Composable -fun codeStyle(): TextStyle = - MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) - -/** - * [content] scanned off the main thread, then drawn. - * - * Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660 - * lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first - * written, that is 460ms of frozen screen at the size the server is willing to send -- long enough - * that the accessibility tree cannot be read, which is what "the app has stopped" looks like. - * - * Keyed on the text and the language, so re-reading the same file does not rescan it. - */ -@Composable -fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) { - var lines by remember(content, language) { mutableStateOf(null) } - LaunchedEffect(content, language) { - lines = withContext(Dispatchers.Default) { FileLines.of(content, language) } - } - when (val ready = lines) { - null -> CircularProgressIndicator(Modifier.padding(8.dp)) - else -> FileViewer(ready, modifier) - } -} - -/** - * A file, one line per row, coloured by the same scanner that colours a reply's code fences. - * - * A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a - * twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is - * that each row needs its own colours, which is what [FileLines] works out once and off this - * thread. - * - * Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a - * block and a long line does not silently become three -- which would put the gutter's numbers - * against the wrong text. - * - * **Every row is given the same content width**, and that is what makes the shared scroll state - * behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset - * into *its own* range -- `content width - viewport` -- so with rows of their natural widths a - * short line's range is zero and it never moves while a long one beside it does. Each row also - * writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever - * row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on - * 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range - * looks like. - * - * The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box - * around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise, - * so only the line under the finger stretched. Only possible because every row now has the same - * range. - * - * The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the - * numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width - * is measured from the digit count of the line count in the style it is drawn in. - * - * Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and - * copying it gives the code rather than the code with a number in front of every line. - */ -@Composable -fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { - val style = codeStyle() - val scroll = rememberScrollState() - val overscroll = rememberOverscrollEffect() - val rows = rememberLazyListState() - val gutter = gutterWidth(lines.size, style) - val content = contentWidth(lines.columns, style) - Box(modifier.fillMaxSize()) { - // One container around the whole file rather than one per line, so a selection can run - // across lines -- the same arrangement the transcript uses. - SelectionContainer { - // The stretch is drawn here, once, over everything this box holds; the rows below only - // feed it. `clipToBounds` because a stretch draws outside the box it came from. - Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) { - LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) { - items(lines.size) { index -> - Row(verticalAlignment = Alignment.Top) { - // Where the numbers go, drawn from outside this box. - Spacer(Modifier.width(gutter + GUTTER_GAP)) - Text( - lines.line(index), - style = style, - softWrap = false, - // The scroll outside the width: the scrolling node's viewport is - // what the row has room for, and its content is the whole file's - // widest line. The shared effect is given to every row and rendered - // by none of them -- see the box above. - modifier = - Modifier.horizontalScroll(scroll, overscroll).width(content), - ) - } - } - } - } - } - LineGutter(rows, gutter, style) - } -} - -/** - * The line numbers, drawn beside the file rather than in it. - * - * They have to be outside the box the stretch is rendered on, or they bend with the text; and they - * have to stay exactly level with the lines they number. Those two pull in opposite directions. - * - * A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come - * from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during - * measurement, so this composes from the answer the list has just produced rather than one it read - * a frame ago. A `Column` translated by the scroll position could not: the translation would be - * current while the set of numbers was a composition behind, so during a fling the numbers would - * slide against their lines. - * - * The list is measured before this is -- they are siblings in a `Box` and it is declared first. - * - * `onSurfaceVariant`, because a number is not part of the file. The background is painted because - * the stretch can carry the text sideways under this column, and a digit with a smear of code - * behind it reads as a rendering fault. - */ -@Composable -private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) { - val colour = MaterialTheme.colorScheme.onSurfaceVariant - val surface = rawSurface - SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) { - constraints -> - val visible = rows.layoutInfo.visibleItemsInfo - val numbers = visible.map { item -> - subcompose(item.index) { - Text( - (item.index + 1).toString(), - style = style, - color = colour, - textAlign = TextAlign.End, - maxLines = 1, - ) - } - .first() - .measure(Constraints.fixedWidth(constraints.maxWidth)) - } - layout(constraints.maxWidth, constraints.maxHeight) { - numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) } - } - } -} - -/** - * How wide the widest line number is, measured rather than guessed. - * - * `9` repeated, because digits in a monospace face are all one width -- what matters is how many - * there are. Measuring in the style the numbers are drawn in is what makes this survive a font - * size, a density or a display scale nobody here chose. - */ -@Composable -fun gutterWidth(lineCount: Int, style: TextStyle): Dp { - val measurer = rememberTextMeasurer() - val density = LocalDensity.current - val digits = maxOf(1, lineCount.toString().length) - return remember(digits, style, density) { - with(density) { - measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp() - } - } -} - -/** - * How wide to make every row: the widest line in the file, in this style. - * - * One character measured rather than the line itself, because the face is monospace and measuring - * the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already - * has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of - * rounding. - * - * Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary - * one: a minified file is a single line of a hundred thousand characters, and laying that out as - * one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be - * reached, which is the tolerable half of that trade. - */ -@Composable -private fun contentWidth(columns: Int, style: TextStyle): Dp { - val measurer = rememberTextMeasurer() - val density = LocalDensity.current - return remember(columns, style, density) { - val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f - with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() } - } -} - -/** - * The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far - * past any line anybody reads. - */ -private const val MAX_CONTENT_PX = 100_000f - -/** - * The space between the numbers and the code. A gap, not an alignment: the two are already aligned - * by the row, and this is only so the digits and the first character are not touching. - */ -val GUTTER_GAP = 8.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt deleted file mode 100644 index c4bfe25..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FilesScreen.kt +++ /dev/null @@ -1,688 +0,0 @@ -package com.example.aiapp - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Which machine's files to show, and where to start. - * - * A **setup**, not a session: a filesystem is a property of a machine, and a session only says - * where it was working. That is what makes a second way in -- from the setups tab -- one more - * caller rather than any new code here. - */ -data class FilesTarget(val setup: String, val setupName: String, val start: String) - -/** Where the explorer is: in a directory, or in one file. */ -private sealed class Spot(val path: String) { - class Dir(path: String) : Spot(path) - - class Doc(path: String) : Spot(path) -} - -/** - * The files on the machine a session runs on: browse them, read one, change one. - * - * Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps - * flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to - * viewer, viewer to the directory it came from, directory to the one above -- and only closes from - * where it opened. - * - * Every directory that has been visited is kept for as long as this is open; the refresh glyph is - * how one gets asked again on purpose, and creating something refetches the directory it was - * created in. - */ -@Composable -fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) { - val scope = rememberCoroutineScope() - var stack by remember { mutableStateOf(listOf(Spot.Dir(target.start))) } - val listings = remember { mutableStateMapOf>() } - var creating by remember { mutableStateOf(false) } - // Edit mode and whether anything has been typed live here rather than in the pane below, - // because they are what back has to know about -- and back arrives from two places, the arrow - // and the platform's own gesture, which must mean the same thing. - var editing by remember { mutableStateOf(false) } - var dirty by remember { mutableStateOf(false) } - var askUnsaved by remember { mutableStateOf(false) } - - val here = stack.last() - - fun go(spot: Spot) { - editing = false - dirty = false - stack = stack + spot - } - - fun back() { - when { - editing && dirty -> askUnsaved = true - editing -> editing = false - stack.size > 1 -> { - stack = stack.dropLast(1) - editing = false - dirty = false - } - else -> onClose() - } - } - - suspend fun load(path: String, again: Boolean) { - if (!again && listings[path] is LoadState.Loaded) return - listings[path] = LoadState.Loading - listings[path] = - try { - withContext(Dispatchers.IO) { - LoadState.Loaded(fetchDir(settings, target.setup, path)) - } - } catch (e: ApiException) { - LoadState.failed(e) - } - } - - BackHandler(onBack = ::back) - - Box( - Modifier.fillMaxSize() - .background(MaterialTheme.colorScheme.background) - // The session under this deliberately takes no keyboard inset, so the explorer adds its - // own -- otherwise the editor types under the keyboard. - .imePadding() - ) { - Column(Modifier.fillMaxSize()) { - when (val spot = here) { - is Spot.Dir -> { - val state = listings[spot.path] ?: LoadState.Loading - // The resolved path once there is one: a directory opened as `~` is called what - // it turned out to be, not what it was asked for. - val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path - FilesHeader( - title = baseName(at), - path = at, - machine = target.setupName, - onBack = ::back, - ) { - GlyphButton( - REFRESH_GLYPH, - "Refresh this directory", - { scope.launch { load(spot.path, again = true) } }, - enabled = state !is LoadState.Loading, - ) - GlyphButton( - PLUS_GLYPH, - "Create here", - { creating = true }, - enabled = state is LoadState.Loaded, - ) - } - LaunchedEffect(spot.path) { load(spot.path, again = false) } - DirectoryBody(state, onOpen = ::go) - } - is Spot.Doc -> - DocPane( - settings = settings, - target = target, - path = spot.path, - name = baseName(spot.path), - editing = editing, - onEditing = { editing = it }, - onDirty = { dirty = it }, - onBack = ::back, - ) - } - } - } - - if (askUnsaved) { - UnsavedDialog( - onDiscard = { - askUnsaved = false - editing = false - dirty = false - }, - onCancel = { askUnsaved = false }, - ) - } - - val dir = here as? Spot.Dir - val listing = (listings[dir?.path] as? LoadState.Loaded)?.value - if (creating && dir != null && listing != null) { - CreateDialog( - settings = settings, - setup = target.setup, - directory = listing.path, - onDismiss = { creating = false }, - onCreated = { path, isDirectory -> - creating = false - scope.launch { - // The directory it was created in is the one thing that changed, so that is - // what gets asked again -- not the whole stack. - load(dir.path, again = true) - // A new file has nothing to look at, so it opens where it can be filled in. - if (!isDirectory) { - go(Spot.Doc(path)) - editing = true - } - } - }, - ) - } -} - -/** - * The row every view in here has at the top: back, what this is, and what acts on it. - * - * The path is truncated in the middle when it will not fit, because both ends carry something the - * reader needs -- the machine and the top of the tree at one end, the file at the other -- and it - * is the longest paths, the ones being read most closely, that get cut. - */ -@Composable -private fun FilesHeader( - title: String, - path: String, - machine: String, - onBack: () -> Unit, - actions: @Composable () -> Unit, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - GlyphButton(BACK_GLYPH, "Back", onBack) - Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1) - Text( - "$machine · $path", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - ) - } - Row { actions() } - } -} - -/** - * What is in a directory. - * - * A listing that failed says why, in the machine's own words, where the rows would be -- never an - * empty list, which is what "there is nothing here" looks like and is the one wrong answer that - * looks like a right one. - */ -@Composable -private fun ColumnScope.DirectoryBody(state: LoadState, onOpen: (Spot) -> Unit) { - when (state) { - is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp)) - is LoadState.Error -> - Text( - state.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(16.dp), - ) - is LoadState.Loaded -> { - val listing = state.value - val sorted = remember(listing) { sortForDisplay(listing.entries) } - LazyColumn(Modifier.weight(1f).fillMaxWidth()) { - parentOf(listing.path)?.let { parent -> - item("..") { - EntryRow( - glyph = FOLDER_GLYPH, - name = "..", - trailing = null, - onClick = { onOpen(Spot.Dir(parent)) }, - ) - } - } - if (sorted.isEmpty()) { - item("empty") { - Text( - "Nothing here", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(16.dp), - ) - } - } - uniqueItems(sorted, key = { it.name }) { entry -> - val path = join(listing.path, entry.name) - EntryRow( - glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH, - name = entry.name, - trailing = trailingOf(entry), - onClick = { - onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path)) - }, - ) - } - } - } - } -} - -/** - * What a row says after the name, or nothing. - * - * A symlink says so instead of giving a size, because the size a listing reports for one is the - * length of the path it points at -- a number that looks exactly like a file size and is about - * something else. `other` covers a fifo, a device, and a link whose target is gone: the row still - * appears, because a directory that hid what it held would be lying about being empty. - */ -private fun trailingOf(entry: DirEntry): String? = - when { - entry.link -> "link" - entry.isDirectory -> null - entry.kind == "file" -> humanSize(entry.size) ?: "0 B" - else -> "other" - } - -@Composable -private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), - ) { - Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(12.dp)) - Text( - name, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, - modifier = Modifier.weight(1f), - ) - trailing?.let { - Spacer(Modifier.width(8.dp)) - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -/** - * One file: read, and edited behind the pencil. - * - * Its own composable so that everything about one file -- what came back, what has been typed, and - * whether a save is out -- is remembered under that file's path and thrown away when the reader - * moves to another. What is *not* here is edit mode itself: back has to know about it. - */ -@Composable -private fun ColumnScope.DocPane( - settings: ServerSettings, - target: FilesTarget, - path: String, - name: String, - editing: Boolean, - onEditing: (Boolean) -> Unit, - onDirty: (Boolean) -> Unit, - onBack: () -> Unit, -) { - val scope = rememberCoroutineScope() - var state by remember(path) { mutableStateOf>(LoadState.Loading) } - var draft by remember(path) { mutableStateOf(TextFieldValue()) } - var saving by remember(path) { mutableStateOf(false) } - var saveError by remember(path) { mutableStateOf(null) } - var conflict by remember(path) { mutableStateOf(null) } - // The editor's own vertical scroll, hoisted so the gutter and the text move together: they are - // two composables in one row, and a scroll inside either would leave the other behind. - val editScroll = rememberScrollState() - val language = remember(name) { fileLanguage(name) } - val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text - // Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so - // this is decided before anything is typed rather than discovered by a keyboard that stops - // answering. - val editable = loaded != null && loaded.size <= EDIT_LIMIT - - suspend fun fetch() { - state = LoadState.Loading - state = - try { - val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) } - if (got is FileContent.Text) draft = TextFieldValue(got.content) - LoadState.Loaded(got) - } catch (e: ApiException) { - LoadState.failed(e) - } - onDirty(false) - } - - LaunchedEffect(path) { fetch() } - - val changed = loaded != null && draft.text != loaded.content - LaunchedEffect(changed) { onDirty(changed) } - - /** Writes the draft back, [against] being the digest it is allowed to replace. */ - fun save(against: String) { - if (saving) return - saving = true - saveError = null - scope.launch { - try { - val written = - withContext(Dispatchers.IO) { - writeFile(settings, target.setup, path, draft.text, against) - } - state = - LoadState.Loaded( - FileContent.Text( - path, - written.size, - written.modified, - written.sha256, - draft.text, - ) - ) - conflict = null - onDirty(false) - onEditing(false) - } catch (e: ApiException) { - // The one refusal that is a question rather than a message: somebody else's edit is - // on the machine, and which of the two survives is not this app's to decide. - if (e.status == 409) conflict = e.message ?: "It changed on the machine." - else saveError = e.message - } finally { - saving = false - } - } - } - - FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) { - if (editing) { - if (saving) { - GlyphSpinner("Saving") - } else { - GlyphButton( - SAVE_GLYPH, - "Save", - { loaded?.let { save(it.sha256) } }, - // Disabled rather than hidden while there is nothing to write: a button that - // comes and goes makes its own absence the signal. - enabled = changed, - ) - } - } else { - GlyphButton( - REFRESH_GLYPH, - "Read this file again", - { scope.launch { fetch() } }, - enabled = state !is LoadState.Loading, - ) - GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable) - } - } - - saveError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - // Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it - // is disabled -- and a reader who cannot edit a file they can plainly read will otherwise - // conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button - // never gets. - if (loaded != null && !editable) { - Text( - "Too big to edit here (${humanSize(loaded.size)}; the limit is " + - "${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) { - when (val current = state) { - is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp)) - is LoadState.Error -> - Text( - current.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(8.dp), - ) - is LoadState.Loaded -> - when (val file = current.value) { - is FileContent.Text -> - if (editing) { - FileEditor( - draft, - { draft = it }, - language, - Modifier.verticalScroll(editScroll), - ) - } else { - ScannedFile(file.content, language) - } - // Said in words, with the measurement that makes it make sense. Neither of - // these is an empty file and neither is an error, so neither may look like one. - is FileContent.Binary -> - Note( - "This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show." - ) - is FileContent.TooBig -> - Note( - "This file is ${humanSize(file.size)}, which is more than the server will " + - "send. Nothing was read, so nothing here is a sample of it." - ) - } - } - } - - conflict?.let { message -> - ConflictDialog( - message = message, - busy = saving, - onOverwrite = { - // Re-read only to learn what it hashes to *now*, which is the digest an overwrite - // has to be allowed against. The content is deliberately thrown away: overwriting - // is the choice to lose it. - scope.launch { - val fresh = - try { - withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) } - } catch (e: ApiException) { - saveError = e.message - conflict = null - return@launch - } - if (fresh is FileContent.Text) save(fresh.sha256) - else { - saveError = - "It is no longer a text file, so this app will not write over it." - conflict = null - } - } - }, - onReload = { - conflict = null - scope.launch { fetch() } - }, - onCancel = { conflict = null }, - ) - } -} - -/** A sentence where the file's content would be, for the two states that have no content. */ -@Composable -private fun Note(text: String) { - Text( - text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(8.dp), - ) -} - -/** - * Naming one thing in the directory that is open. - * - * A name and a switch, not a name and a body: the editor is where content is typed, and a modal - * with a text area in it is a second editor to keep in step with the first. A created file opens - * straight into edit mode, because an empty file is not something to look at. - */ -@Composable -private fun CreateDialog( - settings: ServerSettings, - setup: String, - directory: String, - onDismiss: () -> Unit, - onCreated: (String, Boolean) -> Unit, -) { - val scope = rememberCoroutineScope() - var name by remember { mutableStateOf("") } - var isDirectory by remember { mutableStateOf(false) } - var busy by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - - fun create() { - val chosen = name.trim() - if (busy || chosen.isEmpty()) return - busy = true - error = null - val path = join(directory, chosen) - scope.launch { - try { - withContext(Dispatchers.IO) { - if (isDirectory) createDir(settings, setup, path) - else createFile(settings, setup, path) - } - onCreated(path, isDirectory) - } catch (e: ApiException) { - // Beside the button that caused it: this dialog is the only thing on screen that - // knows something was being created, and the reason is usually the name itself. - error = e.message - busy = false - } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Create in ${baseName(directory)}") }, - text = { - Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - enabled = !busy, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Directory", modifier = Modifier.weight(1f)) - Switch( - checked = isDirectory, - onCheckedChange = { isDirectory = it }, - enabled = !busy, - ) - } - Text( - "A name that is already taken is refused rather than replaced.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - error?.let { - Spacer(Modifier.height(8.dp)) - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - }, - confirmButton = { - TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) { - Text(if (busy) "Creating..." else "Create") - } - }, - dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } }, - ) -} - -/** - * Directories first, then by name ignoring case, and stably. - * - * Sorted here rather than by the machine: presentation order is a display decision, and `find` - * answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a - * repository they are half of what matters. - */ -internal fun sortForDisplay(entries: List): List = - entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() })) - -/** - * The directory above [path], or null at the root. - * - * A string operation on a path the *machine* resolved, which is what makes it safe: every listing - * answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason - * about, and this app never has to resolve one. - */ -internal fun parentOf(path: String): String? { - val trimmed = path.trimEnd('/') - if (trimmed.isEmpty()) return null - val cut = trimmed.lastIndexOf('/') - return when { - cut < 0 -> null - cut == 0 -> "/" - else -> trimmed.substring(0, cut) - } -} - -/** What a path names: its last segment, with `/` naming itself. */ -internal fun baseName(path: String): String { - val trimmed = path.trimEnd('/') - return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/') -} - -internal fun join(directory: String, name: String): String = - if (directory.endsWith("/")) "$directory$name" else "$directory/$name" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt deleted file mode 100644 index 547498f..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.example.aiapp - -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.os.Build -import android.os.Handler -import android.os.HandlerThread -import android.view.FrameMetrics -import android.view.Window -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext - -/** - * How long each frame took, and which phase of it, taken from the platform rather than from a frame - * counter of our own. - * - * The point of splitting it up is that "the scroll is laggy" has two completely different causes - * and one appearance. If the layout-and-measure and draw figures are small and the total is large, - * the time is going into rasterising and compositing, and no amount of doing less work per row will - * move it. If they are large, the work per row is the problem and it is ours to fix. - * - * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, - * broken into the parts the UI thread is responsible for and the parts after it. - * - * One of these for the app, like [DebugStats], because the two are read as one report and - * [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session - * and the counters were not, so a report copied after visiting two sessions divided every session's - * work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window. - */ -object FrameStats { - private val total = ArrayList() - private val waited = ArrayList() - private val input = ArrayList() - private val animation = ArrayList() - private val layout = ArrayList() - private val draw = ArrayList() - private val sync = ArrayList() - private val issue = ArrayList() - private val swap = ArrayList() - private val gpu = ArrayList() - private var since = System.currentTimeMillis() - - /** - * Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an - * index into [total] and a wall-clock time -- not a second recorder, just a mark on this one, - * so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced. - */ - private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long) - - private val phaseMarks = ArrayList() - - /** Call at the start of each named phase of a scripted run; see [BenchRun]. */ - @Synchronized - fun markPhase(name: String) { - phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis()) - } - - @Synchronized - fun add(metrics: FrameMetrics) { - // The first frame after a window opens includes inflating it and is nobody's scroll. - if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return - if (total.size >= CAP) return - total += metrics.getMetric(FrameMetrics.TOTAL_DURATION) - // How long the frame waited for the UI thread to be free before it could start. Reported - // because the phases otherwise do not add up to the total, and the gap is the interesting - // part: the frame being held up by work that is not the frame's. - waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION) - input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION) - animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION) - layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION) - draw += metrics.getMetric(FrameMetrics.DRAW_DURATION) - sync += metrics.getMetric(FrameMetrics.SYNC_DURATION) - issue += metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION) - swap += metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - gpu += metrics.getMetric(FrameMetrics.GPU_DURATION) - } - } - - @Synchronized - fun reset() { - listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach { - it.clear() - } - phaseMarks.clear() - since = System.currentTimeMillis() - } - - @Synchronized - fun lines(refreshHz: Float): List { - if (total.isEmpty()) return listOf(" no frames recorded -- scroll first, then press this") - val seconds = (System.currentTimeMillis() - since) / 1000.0 - val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7 - val late = total.count { it / 1_000_000.0 > budget } - return listOf( - " ${total.size} frames over ${"%.1f".format(seconds)}s" + - " at ${"%.0f".format(refreshHz)}Hz (${"%.1f".format(budget)}ms budget)", - " late: $late (${percent(late, total.size)})" + - if (total.size >= CAP) " [capped]" else "", - phase("total ", total), - phase("waited", waited), - phase("input ", input), - phase("anim ", animation), - phase("layout", layout), - phase("draw ", draw), - phase("sync ", sync), - phase("issue ", issue), - phase("swap ", swap), - ) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu)) - } - - /** - * One block per [markPhase] call: how many frames landed between that mark and the next (or the - * end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst - * single frame, and how long the phase actually ran. Marks with no frames between them (a phase - * that finished before a frame was drawn) still get a line rather than being silently dropped - * -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number. - */ - @Synchronized - fun phaseLines(refreshHz: Float): List { - if (phaseMarks.isEmpty()) return emptyList() - val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7 - val lines = ArrayList() - phaseMarks.forEachIndexed { i, mark -> - val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size - val endMs = - if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs - else System.currentTimeMillis() - val samples = total.subList(mark.startIndex, endIndex) - val seconds = (endMs - mark.startMs) / 1000.0 - lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s" - if (samples.isEmpty()) { - lines += " no frames recorded in this phase" - } else { - val late = samples.count { it / 1_000_000.0 > budget } - lines += " late: $late (${percent(late, samples.size)})" - lines += " " + phase("total ", samples) - lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}" - } - } - return lines - } - - /** How long the frames recorded here spent in their draw phase, and how many there were. */ - @Synchronized fun drawPhase(): Pair = draw.sum() to draw.size - - private fun phase(name: String, samples: List): String { - val sorted = samples.sorted() - return " $name p50 ${at(sorted, 50)} p90 ${at(sorted, 90)} p99 ${at(sorted, 99)}" - } - - private fun at(sorted: List, percentile: Int): String { - if (sorted.isEmpty()) return "-" - val index = (sorted.size - 1) * percentile / 100 - return "%.1fms".format(sorted[index] / 1_000_000.0) - } - - private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole) -} - -/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */ -private const val CAP = 20_000 - -/** - * Records into [FrameStats] for as long as this screen is on it. - * - * The listener is what comes and goes; what it writes into does not, so a report covers the same - * stretch of time as the counters beside it. - * - * The listener is handed its own thread because the platform calls it for every frame and the - * documentation is explicit that doing that on the main thread taxes the very thing being measured. - */ -@Composable -fun RecordFrames() { - val window = LocalContext.current.activity()?.window - DisposableEffect(window) { - if (window == null) return@DisposableEffect onDispose {} - val thread = HandlerThread("frame-stats").apply { start() } - val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ -> - FrameStats.add(metrics) - } - window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) - onDispose { - window.removeOnFrameMetricsAvailableListener(listener) - thread.quitSafely() - } - } -} - -/** The activity behind a composable's context, which is what owns the window. */ -fun Context.activity(): Activity? { - var context: Context? = this - while (context is ContextWrapper) { - if (context is Activity) return context - context = context.baseContext - } - return null -} - -/** What the display is actually refreshing at, so "late" is measured against the real budget. */ -fun Context.refreshHz(): Float = - @Suppress("DEPRECATION") (activity()?.windowManager?.defaultDisplay?.refreshRate ?: 60f) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt deleted file mode 100644 index b3fcefd..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Highlighter.kt +++ /dev/null @@ -1,316 +0,0 @@ -package com.example.aiapp - -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString - -/** What a span of code is, in the terms the palette has a colour for. */ -enum class Kind { - KEYWORD, - STRING, - LITERAL, - COMMENT, - METADATA, - PUNCTUATION, - MARK, -} - -/** A run of [Kind] in the code, as a half-open range. */ -data class Span(val start: Int, val end: Int, val kind: Kind) - -/** - * The colours the highlighter draws with, ours rather than a library's; [catppuccinSyntax] is the - * one instance and lives with the rest of the palette. - */ -data class SyntaxPalette( - val keyword: Color, - val string: Color, - val literal: Color, - val comment: Color, - val metadata: Color, - val punctuation: Color, - val mark: Color, -) { - fun of(kind: Kind): Color = - when (kind) { - Kind.KEYWORD -> keyword - Kind.STRING -> string - Kind.LITERAL -> literal - Kind.COMMENT -> comment - Kind.METADATA -> metadata - Kind.PUNCTUATION -> punctuation - Kind.MARK -> mark - } -} - -/** - * [code] with its keywords, strings and comments coloured, or plain if there is no language for it. - * - * Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever - * it appears. - * - * Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it - * off the drawing thread. - * - * The timing is the number the highlighter is judged by: the library this replaced took **174ms** - * on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted] - * caches the answer rather than a `remember` inside the fence recomputing it on every scroll back. - */ -fun highlight(code: String, language: Language?): AnnotatedString { - if (language == null) return AnnotatedString(code) - val spans = DebugStats.timed("code highlighted") { spansOf(code, language) } - val palette = catppuccinSyntax() - return buildAnnotatedString { - append(code) - spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) } - } -} - -/** - * [code] read once, left to right, into the spans that carry a colour. - * - * One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a - * locator per token kind over the whole text, which is what the library did and is why it found - * comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a - * block-comment opener inside a shell glob each commented out the rest of a line that was nothing - * of the sort. - * - * Every span is produced by advancing an index forward, so the result is ordered, non-overlapping - * and inside the code by construction. Nothing here throws: an unterminated string or comment runs - * to the end of the code, which is also what it looks like while a fence is still being written. - * - * In ordinary code the order of recognition is comment, string, attribute, number, word, and - * finally a single punctuation or mark character, which are coloured only in ordinary code. - */ -fun scan(code: String, rules: Rules): List = Scanner(code, rules).run() - -/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */ -private const val PUNCTUATION = ",.:;" -private const val MARKS = "()={}<>-+[]|&" - -private class Scanner(private val code: String, private val rules: Rules) { - private val spans = ArrayList() - private var at = 0 - - fun run(): List { - while (at < code.length) { - // Every branch that answers true has advanced `at`, so this terminates. - val consumed = - blockComment() || - lineComment() || - rawString() || - characterOrLifetime() || - string() || - attribute() || - number() || - word() || - singleCharacter() - if (!consumed) at++ - } - return spans - } - - private fun emit(start: Int, kind: Kind) { - if (at > start) spans.add(Span(start, at, kind)) - } - - private fun starts(token: String) = code.startsWith(token, at) - - /** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */ - private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&(" - - /** Whether only whitespace stands between the start of this line and here. */ - private fun atLineStart(): Boolean { - var back = at - 1 - while (back >= 0 && code[back] != '\n') { - if (!code[back].isWhitespace()) return false - back-- - } - return true - } - - private fun toEndOfLine() { - while (at < code.length && code[at] != '\n') at++ - } - - /** From an open bracket through the one that matches it, or to the end if none does. */ - private fun toMatchingBracket() { - var depth = 0 - while (at < code.length) { - when (code[at]) { - '[' -> depth++ - ']' -> depth-- - } - at++ - if (depth == 0) return - } - } - - private fun blockComment(): Boolean { - val comment = rules.blockComment ?: return false - if (!starts(comment.open)) return false - val start = at - at += comment.open.length - var depth = 1 - while (at < code.length && depth > 0) { - // The closer is tried first so that a language whose two delimiters are the same string - // -- CoffeeScript's `###` -- closes rather than nesting forever. - if (starts(comment.close)) { - depth-- - at += comment.close.length - } else if (comment.nests && starts(comment.open)) { - depth++ - at += comment.open.length - } else { - at++ - } - } - emit(start, Kind.COMMENT) - return true - } - - private fun lineComment(): Boolean { - if (rules.lineComments.none { starts(it) }) return false - if (rules.lineCommentsAtWordStart && !atWordStart()) return false - val start = at - toEndOfLine() - emit(start, Kind.COMMENT) - return true - } - - /** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */ - private fun rawString(): Boolean { - if (!rules.rawStrings) return false - var ahead = at - if (code.getOrNull(ahead) == 'b') ahead++ - if (code.getOrNull(ahead) != 'r') return false - ahead++ - var hashes = 0 - while (code.getOrNull(ahead) == '#') { - ahead++ - hashes++ - } - if (code.getOrNull(ahead) != '"') return false - val start = at - val closer = "\"" + "#".repeat(hashes) - val closed = code.indexOf(closer, ahead + 1) - at = if (closed < 0) code.length else closed + closer.length - emit(start, Kind.STRING) - return true - } - - /** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */ - private fun characterOrLifetime(): Boolean { - if (!rules.lifetimes || code[at] != '\'') return false - val next = code.getOrNull(at + 1) ?: return false - if (next == '\\' || code.getOrNull(at + 2) == '\'') { - quoted(Quote("'", "'", escapes = true)) - } else { - at++ - } - return true - } - - private fun string(): Boolean { - // Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string - // followed by a quote. A loop rather than filter/maxBy: this runs at every character of - // ordinary code, and the pair of lists that would allocate is the whole cost of the scan. - var quote: Quote? = null - for (candidate in rules.quotes) { - if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) { - quote = candidate - } - } - quoted(quote ?: return false) - return true - } - - private fun quoted(quote: Quote) { - val start = at - at += quote.open.length - while (at < code.length) { - if (quote.escapes && code[at] == '\\' && at + 1 < code.length) { - at += 2 - continue - } - if (starts(quote.close)) { - at += quote.close.length - break - } - at++ - } - at = at.coerceAtMost(code.length) - emit(start, Kind.STRING) - } - - private fun attribute(): Boolean { - val start = at - when (rules.attributes) { - Attributes.NONE -> return false - Attributes.AT_WORD -> { - if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false - at++ - while (at < code.length && isWordPart(code[at])) at++ - } - Attributes.HASH_BRACKET -> { - if (code[at] != '#') return false - var ahead = at + 1 - if (code.getOrNull(ahead) == '!') ahead++ - if (code.getOrNull(ahead) != '[') return false - at = ahead - toMatchingBracket() - } - Attributes.HASH_LINE -> { - if (code[at] != '#' || !atLineStart()) return false - toEndOfLine() - } - Attributes.LINE_BRACKET -> { - if (code[at] != '[' || !atLineStart()) return false - toMatchingBracket() - } - } - emit(start, Kind.METADATA) - return true - } - - /** - * A number is a run starting with a digit and carrying on through letters, digits, `_` and `.` - * -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them. - */ - private fun number(): Boolean { - if (!code[at].isDigit()) return false - val start = at - while ( - at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.') - ) { - at++ - } - emit(start, Kind.LITERAL) - return true - } - - private fun word(): Boolean { - if (!isWordStart(code[at])) return false - val start = at - while (at < code.length && isWordPart(code[at])) at++ - if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD) - return true - } - - private fun singleCharacter(): Boolean { - val kind = - when (code[at]) { - in PUNCTUATION -> Kind.PUNCTUATION - in MARKS -> Kind.MARK - else -> return false - } - at++ - emit(at - 1, kind) - return true - } -} - -private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_') - -private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_' diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt deleted file mode 100644 index c8845c7..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt +++ /dev/null @@ -1,667 +0,0 @@ -package com.example.aiapp - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** What a row says about itself while an operation is running on it. See [BusyItem]. */ -private const val IMPORTING = "importing" -private const val DELETING = "deleting" - -/** - * What the rows further down a batch say while they wait their turn. - * - * Its own word rather than the operation's, because nothing has been done to this session yet, so a - * batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over - * all the same -- a queued row that still looked ordinary was still tappable. - */ -private const val WAITING = "waiting" - -/** - * How long a row that has just moved ignores being touched. - * - * A batch takes rows out of the list as each one lands, so everything below the one that went - * slides up -- and a tap already on its way then arrives at whichever row moved into that place. On - * this screen that means importing a session nobody chose. - * - * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be - * a flicker running down the list. - */ -private const val SETTLE_MS = 500L - -/** - * Continuing a Claude Code session the machine already has. - * - * The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so - * an enrolled phone cannot turn this screen into a file reader. - * - * Holding a row selects it and puts the screen in selection mode, where the options that act on a - * selection appear along the bottom. That exists because these arrive in bulk -- a machine - * accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason - * clearing them out was not worth doing. - */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) { - val scope = rememberCoroutineScope() - var setups by remember { mutableStateOf>>(LoadState.Loading) } - var chosen by remember { mutableStateOf(null) } - var sessions by remember { mutableStateOf>>(LoadState.Loading) } - - // What is happening to each row right now, as the word the row shows. A map keyed by id rather - // than a flag per row, because the rows are rebuilt from whatever the server last said and this - // belongs to the request rather than to the session. - var running by remember { mutableStateOf>(emptyMap()) } - // Which rows the reader has picked out. Empty means selection mode is off: a selection mode - // with nothing selected is a state with no controls in it and no way to leave except Back. - var selected by remember { mutableStateOf>(emptySet()) } - // Failures that belong to one row rather than to the screen, shown on that row. A batch is - // exactly where a single banner fails: nine deletes succeeded and one did not, and the banner - // cannot say which. - var rowErrors by remember { mutableStateOf>(emptyMap()) } - // Deleting a transcript cannot be undone, so it is asked rather than done. Held as the rows - // themselves, not a flag, so the dialog can say what it is about. - var confirming by remember { mutableStateOf?>(null) } - // Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty - // times. - var permissionMode by remember { mutableStateOf("auto") } - // When each row last slid upwards, as a plain map rather than state: nothing is drawn from it, - // so a tap reading it needs no recomposition. - val movedAt = remember { mutableMapOf() } - fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS - - /** - * Fetches the list and takes the row states from it. - * - * Taken from the answer rather than kept across the load: the server is what knows what is - * running, and this screen may be opening on work another phone started. - */ - suspend fun fetchInto(setup: Setup): LoadState> = - try { - val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) } - running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap() - rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap() - LoadState.Loaded(rows) - } catch (err: Exception) { - LoadState.Error(err.message ?: "Couldn't list sessions") - } - - fun loadSessions(setup: Setup) { - sessions = LoadState.Loading - selected = emptySet() - scope.launch { sessions = fetchInto(setup) } - } - - /** Takes a row out of the list, once the machine no longer has it to offer. */ - fun forget(id: String) { - val loaded = sessions - if (loaded is LoadState.Loaded) { - // Only this row, and only what changed -- refetching instead put every other row back - // through a loading spinner to report a change that was never in doubt. - sessions = LoadState.Loaded(loaded.value.filterNot { it.id == id }) - } - } - - LaunchedEffect(reloadToken) { - setups = - try { - val found = withContext(Dispatchers.IO) { fetchSetups(settings) } - found.firstOrNull()?.let { - chosen = it - loadSessions(it) - } - LoadState.Loaded(found) - } catch (err: Exception) { - LoadState.Error(err.message ?: "Couldn't list machines") - } - } - - /** - * Hands [targets] to the server in one request, marking every row it covers. - * - * The request only *starts* the work -- the server runs it and says how each row went on the - * change stream, which is what lets this screen be left while a batch is still going. - * - * Marked [WAITING] rather than with the operation's own word until the server confirms. Between - * the request leaving and the `started` event coming back, "we have asked" is the truth and "it - * is importing" is a guess. - * - * The selection is dropped as the work is handed over, not when it finishes: the screen goes - * back to how it started, and what says the work is happening is the rows it is happening to. - */ - fun handOver(targets: List, send: suspend (List) -> Unit) { - selected = emptySet() - running = running + targets.associate { it.id to WAITING } - rowErrors = rowErrors - targets.map { it.id }.toSet() - val setup = chosen - val ids = targets.map { it.id } - scope.launch { - // One request for the whole batch, not one per row. Sent row by row, a handover was - // only as atomic as the network, and what came back was some rows running and some - // untouched -- indistinguishable, on the list, from rows nobody had picked. - try { - withContext(Dispatchers.IO) { send(ids) } - } catch (err: Exception) { - // The server never took it, so nothing is running and no event will arrive to say - // so. This is the one failure the screen must report itself -- and it is the whole - // batch's failure, which is the point: no row was singled out. - running = running - ids.toSet() - rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" } - return@launch - } - - // Then ask what actually happened, if anything still looks outstanding. - // - // The change stream is a broadcast with no memory, so an operation that started and - // finished while it was still connecting is one nothing will ever be said about -- and - // the row sits marked for ever. That is not hypothetical: with responses held back far - // enough, one row of a pair of deletes cleared and the other stayed on "waiting". - // - // The listing is the repair, because it carries the same state the events do. Only when - // something still looks outstanding, so the ordinary case does not pay for a second - // listing, which is the most expensive call this screen makes. - if (setup != null && targets.any { running.containsKey(it.id) }) { - // Quietly: no Loading, because blanking the list to report on rows that are already - // saying what is happening to them is the flicker this screen avoids everywhere - // else. - sessions = fetchInto(setup) - } - } - } - - val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } - - /** Continues [targets] in the background, leaving the screen where it is. */ - fun importAll(targets: List) { - val setup = chosen ?: return - val useProvider = provider ?: return - handOver(targets) { ids -> - startImport( - settings, - setup = setup.id, - sessionIds = ids, - provider = useProvider.name, - permissionMode = permissionMode, - ) - } - } - - /** - * Continues one session and goes to it. - * - * The tap keeps waiting, because "take me there" needs the session it made and the server's - * accepted-and-running answer does not carry one. It is one session and somebody is watching - * it, which is the case where waiting is the right thing anyway. - */ - fun importAndOpen(target: Importable) { - val setup = chosen ?: return - val useProvider = provider ?: return - running = running + (target.id to IMPORTING) - rowErrors = rowErrors - target.id - scope.launch { - try { - val spawned = - withContext(Dispatchers.IO) { - spawnSession( - settings, - setup = setup.id, - provider = useProvider.name, - // Nothing to say: the server titles it from the session it continues. - title = "", - permissionMode = permissionMode, - import = target.id, - ) - } - forget(target.id) - onImported(spawned) - } catch (err: Exception) { - rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't import that one")) - } finally { - running = running - target.id - } - } - } - - // Live changes to what the server is doing to these sessions, for as long as this screen is up. - // The listing already carried the same state when the screen opened -- this is what keeps it - // current afterwards, including for work another phone started. - // - // Failures here are deliberately quiet. There is nothing for a reader to do about a dropped - // event stream, and every state it would have carried is in the next listing. - val liveChanges = remember { - java.util.concurrent.atomic.AtomicReference(null) - } - LaunchedEffect(chosen?.id) { - val setup = chosen?.id ?: return@LaunchedEffect - try { - while (true) { - val stream = ImportableStream(settings, setup) - liveChanges.set(stream) - try { - withContext(Dispatchers.IO) { - stream.run(onOpen = {}) { change -> - when (change.state) { - "started" -> - running = - running + (change.session to (change.operation ?: WAITING)) - // Gone from the machine either way: a delete removed the - // transcript, an import made it a session. - "finished" -> { - running = running - change.session - forget(change.session) - } - "failed" -> { - running = running - change.session - rowErrors = - rowErrors + - (change.session to (change.message ?: "Didn't work")) - } - } - } - } - } catch (e: kotlinx.coroutines.CancellationException) { - // The screen leaving, not a failure -- and swallowing it would leave this loop - // reconnecting to a stream nobody is watching. - throw e - } catch (_: Exception) { - // Retried below; the listing is the truth in the meantime. Any failure, not - // only an [ApiException]: a stream is an optimisation over the listing here, - // and catching only the expected failure means an unexpected one closes the app - // from a screen that is merely loading a list. - } finally { - stream.close() - } - delay(RECONNECT_DELAY_MS) - } - } finally { - // Cancellation cannot interrupt a blocking socket read; closing is what unblocks it. - liveChanges.getAndSet(null)?.close() - } - } - // The screen leaving the composition entirely, which the effect above does not cover. - DisposableEffect(chosen?.id) { onDispose { liveChanges.get()?.close() } } - - // Back leaves selection mode rather than the tab, which is the level it is one step above. - // Nested inside MainScreen's own handler, so it wins while there is a selection. - BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() } - - // Measured rather than assumed: the list reserves exactly what the bar covers, so the last row - // can still be scrolled to while it is up. - var barHeight by remember { mutableStateOf(0.dp) } - val density = LocalDensity.current - - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize().padding(16.dp)) { - // No heading: the tab that selected this one already says "Import". The sentence below - // stays, because it says what importing *does*, which the tab label cannot. - Text( - "Sessions Claude Code already has on the machine. Importing continues one where " + - "it left off; the transcript here shows its recent history. Hold one to " + - "select it, and several at a time.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) - - when (val loaded = setups) { - is LoadState.Loading -> CircularProgressIndicator() - is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> { - // Only worth choosing when there is a choice. - if (loaded.value.size > 1) { - Row(Modifier.fillMaxWidth()) { - loaded.value.forEach { setup -> - TextButton( - onClick = { - chosen = setup - loadSessions(setup) - } - ) { - Text( - setup.name, - color = - if (setup.id == chosen?.id) - MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - if (chosen != null && provider == null) { - Text( - "${chosen?.name} has no Claude CLI, so there is nothing here to " + - "continue.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - ChipGroup( - label = "Permissions", - options = PERMISSION_MODES, - selected = permissionMode, - onSelect = { permissionMode = it }, - ) - Spacer(Modifier.height(8.dp)) - ImportableList( - state = sessions, - running = running, - settling = ::settling, - selected = selected, - errors = rowErrors, - bottomInset = barHeight, - onToggle = { session -> - selected = - if (session.id in selected) selected - session.id - else selected + session.id - }, - onOpen = { session -> importAndOpen(session) }, - ) - } - } - } - } - - // Beside nothing in particular, because a selection is not one row: the options that act on - // it belong to the screen, and the bottom is where a thumb already is. - if (selected.isNotEmpty()) { - val picked = - (sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty() - SelectionBar( - count = picked.size, - modifier = - Modifier.align(Alignment.BottomCenter).onSizeChanged { - barHeight = with(density) { it.height.toDp() } - }, - onDelete = { confirming = picked }, - onImport = { importAll(picked) }, - ) - } - } - - confirming?.let { targets -> - AlertDialog( - onDismissRequest = { confirming = null }, - title = { - Text( - if (targets.size == 1) "Delete this session?" - else "Delete ${targets.size} sessions?" - ) - }, - text = { - Text( - // One name is worth showing and twelve are not, so the count stands in for - // them. The sentence after it is the same either way, because what deleting - // costs does not change with how many. - (if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") + - "Claude Code keeps no copy: its transcript is the session, so this ends " + - "any chance of resuming that conversation. Sessions already imported " + - "here keep the history they replayed, but cannot be continued." - ) - }, - confirmButton = { - TextButton( - onClick = { - val setup = chosen ?: return@TextButton - confirming = null - handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) } - } - ) { - // Coloured by consequence: this takes something away, wherever it appears. - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } }, - ) - } -} - -/** - * What can be done to the rows that are selected. - * - * Delete and Import only, for now: they are the two things this screen has ever done to a session, - * and an option that appears here has to work on every row in a selection. - */ -@Composable -private fun SelectionBar( - count: Int, - modifier: Modifier = Modifier, - onDelete: () -> Unit, - onImport: () -> Unit, -) { - Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 3.dp, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Text( - "$count selected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onDelete) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - Spacer(Modifier.width(4.dp)) - TextButton(onClick = onImport) { Text("Import") } - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun ImportableList( - state: LoadState>, - /** Rows an operation is running on, as the word each one shows. */ - running: Map, - /** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */ - settling: (String) -> Boolean, - selected: Set, - errors: Map, - /** What the selection bar covers, so the last row can still be reached under it. */ - bottomInset: Dp, - onToggle: (Importable) -> Unit, - onOpen: (Importable) -> Unit, -) { - when (state) { - is LoadState.Loading -> CircularProgressIndicator() - is LoadState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> - if (state.value.isEmpty()) { - Text( - "No Claude Code sessions on that machine.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - val selecting = selected.isNotEmpty() - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = bottomInset), - ) { - uniqueItems(state.value, key = { it.id }) { session -> - val picked = session.id in selected - BusyItem(label = running[session.id]) { - Card( - colors = - if (picked) - CardDefaults.cardColors( - containerColor = - MaterialTheme.colorScheme.secondaryContainer, - contentColor = - MaterialTheme.colorScheme.onSecondaryContainer, - ) - else CardDefaults.cardColors(), - modifier = - Modifier.fillMaxWidth() - .padding(vertical = 4.dp) - .combinedClickable( - // Off while something is happening to this row -- see - // [BusyItem], which draws that but leaves the gestures - // alone so the list still scrolls. - enabled = running[session.id] == null, - onClick = { - if (settling(session.id)) return@combinedClickable - // In selection mode a tap is a selection, so the - // reader is never one mis-tap away from starting a - // CLI they were only picking rows for. - // - // Outside it, a tap continues the session -- except - // on a row that cannot be continued, where it - // selects instead. That row's only remaining action - // is Delete, and a tap that did nothing at all - // would be a worse answer. Two `--resume` processes - // on one transcript each replay the other's writes, - // which is why this must not simply try. - if (selecting || session.inUse == "yes") - onToggle(session) - else onOpen(session) - }, - onLongClick = { - if (!settling(session.id)) onToggle(session) - }, - ), - ) { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.Top) { - Text( - session.title, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - // Beside the title, because "which one was I just in" is - // the question this list answers and the order already - // reflects it. - Text( - relativeTime(session.modified), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.height(4.dp)) - // The path first, and the only thing here that is cut: it is - // one long value with no natural break. Cut at the head, - // because a path is identified by its tail and these all share - // a long prefix. By the row's real width rather than a - // character count, which was one guess for every font size and - // screen. - session.cwd - .takeIf { it.isNotEmpty() } - ?.let { cwd -> - Text( - cwd, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.StartEllipsis, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text( - statsOf(session), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // Its own line and its own colour, because it differs in kind - // from the stats above rather than in degree: those describe - // the session, this says whether taking it is safe at all. - warningOf(session)?.let { warning -> - Text( - warning, - style = MaterialTheme.typography.bodySmall, - color = warningColor, - ) - } - // Reported where it happened, in the server's own words. - errors[session.id]?.let { message -> - Spacer(Modifier.height(4.dp)) - Text( - message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - } - } - } - } - } -} - -/** What this session is: the measurements, in the order they are worth knowing. */ -private fun statsOf(session: Importable): String = - listOfNotNull( - // Said, because a name and a last message are different claims: one describes the - // session, the other is only what happened last in it. - if (session.named) "named" else null, - // What continuing it costs, which is the question this list is really asked. Absent - // rather than zero when nothing has been measured -- a session with no turns yet has no - // figure, not a figure of none. - session.contextTokens?.let { "${it / 1000}k context" }, - "${session.lines} lines", - // Kept beside the context figure because the two disagree usefully: most of a large - // transcript is history from before a compaction, so a big file can be cheap to - // continue. - humanSize(session.bytes), - ) - .joinToString(" · ") - -/** - * Why this session might not be safe to take, if it isn't. - * - * Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind, - * and no shade distinguishes them. - */ -private fun warningOf(session: Importable): String? = - when (session.inUse) { - // What was measured is that a live process on that machine holds this session open. Which - // process is not measured, so it isn't claimed: "a terminal -- close it there first" sent - // people looking for a window that need not exist. Naming a place the reader then can't - // find turns a correct refusal into a wrong instruction. - "yes" -> "something on that machine is running it" - "unknown" -> "can't tell if it's open" - else -> null - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt deleted file mode 100644 index 4009a54..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ImportableStream.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.example.aiapp - -/** - * What a machine's Claude Code sessions are having done to them, live. - * - * The import screen starts and then leaves work behind: the server runs it, so the phone that asked - * is free to go elsewhere and the answer arrives here rather than as a reply. What the screen shows - * on arrival comes from the listing, which carries the same state for whoever was not connected - * when it changed; this is only what keeps a screen somebody is watching current. - * - * The connection and its framing belong to [Sse]. Closing is the caller's cancellation path, and - * the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in - * the next listing. - */ -class ImportableStream(settings: ServerSettings, private val setup: String) { - private val stream = Sse(settings) - - fun close() = stream.close() - - fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) { - stream.run("/setups/$setup/importable/events", onOpen) { _, data -> - if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange) - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt deleted file mode 100644 index a1d54b1..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Languages.kt +++ /dev/null @@ -1,453 +0,0 @@ -package com.example.aiapp - -/** - * A language the highlighter can colour. - * - * The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds - * that table. A word with no entry there is null, and null is drawn plain, because a fence coloured - * by another language's rules looks highlighted and is wrong in a way the reader cannot see. - * - * Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that - * is not; see [spansOf]. - */ -enum class Language { - C, - COFFEESCRIPT, - CPP, - CSHARP, - DART, - FISH, - GO, - JAVA, - JAVASCRIPT, - JSON, - KOTLIN, - MARKDOWN, - PERL, - PHP, - PYTHON, - RON, - RUBY, - RUST, - SHELL, - SWIFT, - TOML, - TYPESCRIPT, -} - -/** - * What [scan] needs to know about one language -- data, not code, so that adding a language is a - * row in [RULES] rather than a branch anywhere. - * - * The two forms that could not be expressed as data are flags here and a few lines in the scanner: - * [rawStrings], because the closing delimiter depends on how many hashes the opener had, and - * [lifetimes], because whether `'` opens anything at all depends on what follows it. - */ -data class Rules( - /** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */ - val keywords: Set, - /** Tokens that open a comment running to the end of the line. */ - val lineComments: List = emptyList(), - /** - * Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}` - * and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this - * scanner exists to stop. - */ - val lineCommentsAtWordStart: Boolean = false, - val blockComment: BlockComment? = null, - /** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */ - val quotes: List = emptyList(), - val attributes: Attributes = Attributes.NONE, - /** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */ - val rawStrings: Boolean = false, - /** - * Rust: `'` opens a character literal only when a backslash or one character and a `'` follow. - * Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the - * next apostrophe in the block. - */ - val lifetimes: Boolean = false, -) - -data class BlockComment(val open: String, val close: String, val nests: Boolean) - -/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */ -data class Quote(val open: String, val close: String, val escapes: Boolean) - -/** What opens a metadata span, of the shapes that exist across these languages. */ -enum class Attributes { - NONE, - /** `@` and a word: Kotlin and Java annotations, Python decorators. */ - AT_WORD, - /** `#[` or `#![` through the matching `]`: Rust and RON attributes. */ - HASH_BRACKET, - /** `#` at the start of a line, to the end of it: the C preprocessor. */ - HASH_LINE, - /** `[` at the start of a line through the matching `]`: a TOML table header. */ - LINE_BRACKET, -} - -/** - * The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to - * be made of. - * - * Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner. - * Markdown has none of those, and what a character means there depends on where on the line it - * sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a - * row of rules or an entry in [SCANNERS], and no caller learns which one it got. - */ -fun spansOf(code: String, language: Language): List = SCANNERS.getValue(language)(code) - -// Lazy for the same reason [RULES] is, since it reads it. -private val SCANNERS: Map List> by lazy { - RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } + - mapOf(Language.MARKDOWN to ::scanMarkdown) -} - -private val C_STYLE = BlockComment("/*", "*/", nests = false) -private val NESTING = BlockComment("/*", "*/", nests = true) - -private val DOUBLE = Quote("\"", "\"", escapes = true) -private val SINGLE = Quote("'", "'", escapes = true) -private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true) -private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true) - -// Lazy because the keyword sets below are top-level properties too, and a file's properties -// initialize in the order they are written: read eagerly here, every set would be null. -private val RULES: Map by lazy { - mapOf( - Language.C to - Rules( - keywords = KEYWORDS_C, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE), - attributes = Attributes.HASH_LINE, - ), - Language.CPP to - Rules( - keywords = KEYWORDS_CPP, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE), - attributes = Attributes.HASH_LINE, - ), - Language.CSHARP to - Rules( - keywords = KEYWORDS_CSHARP, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE), - ), - // `###` opens and closes a block comment and `#` opens a line one, which is why the scanner - // tries the block opener first. - Language.COFFEESCRIPT to - Rules( - keywords = KEYWORDS_COFFEESCRIPT, - lineComments = listOf("#"), - blockComment = BlockComment("###", "###", nests = false), - quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE), - ), - Language.DART to - Rules( - keywords = KEYWORDS_DART, - lineComments = listOf("//"), - blockComment = NESTING, - quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE), - attributes = Attributes.AT_WORD, - ), - Language.FISH to - Rules( - keywords = KEYWORDS_FISH, - lineComments = listOf("#"), - lineCommentsAtWordStart = true, - // fish's single quotes escape only `\'` and `\\`, which is what "skip the character - // after a backslash" already does. - quotes = listOf(DOUBLE, SINGLE), - ), - Language.GO to - Rules( - keywords = KEYWORDS_GO, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)), - ), - Language.JAVA to - Rules( - keywords = KEYWORDS_JAVA, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE), - attributes = Attributes.AT_WORD, - ), - Language.JAVASCRIPT to - Rules( - keywords = KEYWORDS_JAVASCRIPT, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)), - ), - Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)), - Language.KOTLIN to - Rules( - keywords = KEYWORDS_KOTLIN, - lineComments = listOf("//"), - blockComment = NESTING, - quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE), - attributes = Attributes.AT_WORD, - ), - Language.PERL to - Rules( - keywords = KEYWORDS_PERL, - lineComments = listOf("#"), - quotes = listOf(DOUBLE, SINGLE), - ), - Language.PHP to - Rules( - keywords = KEYWORDS_PHP, - lineComments = listOf("//", "#"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE), - attributes = Attributes.AT_WORD, - ), - Language.PYTHON to - Rules( - keywords = KEYWORDS_PYTHON, - lineComments = listOf("#"), - quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE), - attributes = Attributes.AT_WORD, - ), - Language.RON to - Rules( - keywords = KEYWORDS_RON, - lineComments = listOf("//"), - blockComment = NESTING, - quotes = listOf(DOUBLE, SINGLE), - attributes = Attributes.HASH_BRACKET, - rawStrings = true, - ), - Language.RUBY to - Rules( - keywords = KEYWORDS_RUBY, - lineComments = listOf("#"), - quotes = listOf(DOUBLE, SINGLE), - ), - Language.RUST to - Rules( - keywords = KEYWORDS_RUST, - lineComments = listOf("//"), - blockComment = NESTING, - // No `'` here: [Rules.lifetimes] decides when one opens a character literal. - quotes = listOf(DOUBLE), - attributes = Attributes.HASH_BRACKET, - rawStrings = true, - lifetimes = true, - ), - Language.SHELL to - Rules( - keywords = KEYWORDS_SHELL, - lineComments = listOf("#"), - lineCommentsAtWordStart = true, - // A shell's single quotes are literal: `'a\'` is not one string. - quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)), - ), - Language.SWIFT to - Rules( - keywords = KEYWORDS_SWIFT, - lineComments = listOf("//"), - blockComment = NESTING, - quotes = listOf(TRIPLE_DOUBLE, DOUBLE), - attributes = Attributes.AT_WORD, - ), - Language.TOML to - Rules( - keywords = KEYWORDS_TOML, - lineComments = listOf("#"), - quotes = - listOf( - TRIPLE_DOUBLE, - Quote("'''", "'''", escapes = false), - DOUBLE, - Quote("'", "'", escapes = false), - ), - attributes = Attributes.LINE_BRACKET, - ), - Language.TYPESCRIPT to - Rules( - keywords = KEYWORDS_TYPESCRIPT, - lineComments = listOf("//"), - blockComment = C_STYLE, - quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)), - attributes = Attributes.AT_WORD, - ), - ) -} - -/** - * The keyword sets. - * - * Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0 - * (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns - * plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family, - * Ruby's `defined?` -- because the word scanner cannot reach them. - */ -private fun words(list: String): Set = - list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet() - -private val KEYWORDS_C = - words( - """auto break case char const continue default do double else enum extern float for goto if - int long register return short signed sizeof static struct switch typedef union unsigned - void volatile while""" - ) - -private val KEYWORDS_CPP = - words( - """asm auto bool break case catch char class const const_cast continue default delete do - double dynamic_cast else enum explicit export extern false float for friend goto if inline - int long mutable namespace new operator private protected public register reinterpret_cast - return short signed sizeof static static_cast struct switch template this throw true try - typedef typeid typename union unsigned using virtual void volatile wchar_t while""" - ) - -private val KEYWORDS_CSHARP = - words( - """abstract as base bool break byte case catch char checked class const continue decimal - default delegate do double else enum event explicit extern false finally fixed float for - foreach goto if implicit in int interface internal is lock long namespace new null object - operator out override params private protected public readonly ref return sbyte sealed short - sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked - unsafe ushort using virtual void volatile while""" - ) - -private val KEYWORDS_COFFEESCRIPT = - words( - """Infinity NaN and arguments await break by case catch class continue debugger delete defer - default do else export extends false finally for function if import in instanceof is isnt - let loop new no not null of on or package return super switch this throw true try typeof - unless undefined var wait when with yield""" - ) - -private val KEYWORDS_DART = - words( - """abstract as assert async await base break case catch class const continue covariant - default deferred do dynamic else enum export extends external factory false final finally - for get if implements import in interface is late library mixin new null on operator part - required rethrow return sealed set show static super switch this throw true try var void - when with while yield""" - ) - -/** - * fish is not in the library at all, so its fences are drawn plain today. The list is the shell's - * own words, which is what a fish fence is mostly made of. - */ -private val KEYWORDS_FISH = - words( - """and begin break builtin case command continue else end exec for function if in not or - return switch while set echo test string math read source""" - ) - -private val KEYWORDS_GO = - words( - """break case chan const continue default defer else fallthrough false for func go goto if - import interface map package range return select struct switch true type var""" - ) - -private val KEYWORDS_JAVA = - words( - """abstract assert boolean break byte case catch char class const continue default do double - else enum extends final finally float for goto if implements import instanceof int interface - long native new null package private protected public return short static strictfp super - switch synchronized this throw throws transient try void volatile while""" - ) - -private val KEYWORDS_JAVASCRIPT = - words( - """async await boolean break case catch class const continue debugger default delete do else - enum export extends false finally for function if implements import in instanceof interface - let new null package private protected public return super switch this throw true try typeof - var void while with yield""" - ) - -private val KEYWORDS_JSON = words("true false null") - -private val KEYWORDS_KOTLIN = - words( - """actual abstract annotation as break by catch class companion const constructor continue - coroutine crossinline data delegate dynamic do else enum expect external false final finally - for fun get if import in infix inline interface internal is lazy lateinit native null object - open operator out override package private protected public reified return sealed set super - suspend tailrec this throw true try typealias typeof val var vararg when while yield""" - ) - -private val KEYWORDS_PERL = - words( - """__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for - foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until - use while xor""" - ) - -private val KEYWORDS_PHP = - words( - """__halt_compiler abstract and array as break callable case catch class clone const continue - declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch - endwhile eval exit extends final finally fn for foreach function global goto if implements - include include_once instanceof insteadof interface isset list match new or print private - protected public require require_once return static switch throw trait try unset use var - while xor yield""" - ) - -private val KEYWORDS_PYTHON = - words( - """False True and as assert async await break class continue def del elif else except finally - for from global if import in is lambda nonlocal not or pass raise return try while with - yield""" - ) - -/** RON is not in the library either; these are the words a RON file can hold. */ -private val KEYWORDS_RON = words("true false Some None inf NaN") - -private val KEYWORDS_RUBY = - words( - """__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do - else elsif end ensure false for if in module next nil not or redo rescue retry return self - super then true undef unless until when while yield""" - ) - -private val KEYWORDS_RUST = - words( - """as async await break const continue crate dyn else enum extern false fn for if impl in - let loop match mod move mut pub ref return Self self static struct super trait true type - union unsafe use where while abstract become box do final macro override priv try typeof - unsized virtual yield""" - ) - -private val KEYWORDS_SHELL = - words( - """alias bg bind break builtin caller cd command compgen complete compopt continue declare - dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let - local logout popd printf pushd pwd read readonly return set shift shopt source suspend - test""" - ) - -private val KEYWORDS_SWIFT = - words( - """_ associatedtype class deinit enum extension fileprivate func import init inout internal - let open operator private precedencegroup protocol public rethrows static struct subscript - typealias var break case catch continue default defer do else fallthrough for guard if in - repeat return throw switch where while Any as await false is nil self Self super throws true - try associativity convenience didSet dynamic final get indirect infix lazy left mutating none - nonmutating optional override postfix precedence prefix Protocol required right set some Type - unowned weak willSet""" - ) - -/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */ -private val KEYWORDS_TOML = words("true false inf nan") - -private val KEYWORDS_TYPESCRIPT = - words( - """abstract as asserts await break case catch class const constructor continue debugger - default delete do else enum export extends false finally for from function get if implements - import in infer instanceof interface is keyof let module namespace new null number object - package private protected public readonly require global return set static string super - switch this throw true try type typeof undefined unique unknown var void while with yield""" - ) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt deleted file mode 100644 index e42aaaa..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.example.aiapp - -/** - * What a screen knows about something it had to fetch: still finding out, got it, or couldn't. - * - * Three states rather than a value alongside a nullable error, because "we couldn't find out" must - * not share a representation with "there is nothing" -- a failed fetch would otherwise render as an - * empty list, which is the one wrong answer that looks like a right one. - * - * [Loading] and [Error] carry no payload, so they are `LoadState` and this is covariant in - * [T]: one `LoadState.Loading` serves every screen. - */ -sealed class LoadState { - data object Loading : LoadState() - - data class Loaded(val value: T) : LoadState() - - data class Error(val message: String) : LoadState() - - companion object { - /** - * The failure a fetch produces. Api.kt writes its messages to be read on this screen, so - * this passes one through rather than replacing it; the fallback covers only a throwable - * with no message at all, which [ApiException] never is. - */ - fun failed(e: ApiException): Error = Error(e.message ?: "Unknown error") - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt deleted file mode 100644 index 78fdc4d..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt +++ /dev/null @@ -1,246 +0,0 @@ -package com.example.aiapp - -import android.Manifest -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.widget.Toast -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.layout.layout -import androidx.core.view.WindowCompat - -class MainActivity : ComponentActivity() { - // Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the - // stored settings. - private var settingsVersion by mutableIntStateOf(0) - - // The session a notification tap asked for, or null if nothing has. The serial is what makes a - // second tap on the same session's notification a second request: without it the two compare - // equal and the composition below has nothing to react to. - private var openRequest by mutableStateOf(null) - private var opens = 0 - - // What another app shared into this one, for the same reason and with the same serial. - private var shareRequest by mutableStateOf(null) - private var shares = 0 - - // Registered up front since permission launchers must be registered before the activity reaches - // STARTED. - private val requestLocalNetworkPermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) {} - - /** - * The service starts either way, and posts nothing if this is refused. - * - * Deliberately not gated on the answer: the permission can be granted later from Android's own - * settings, and a service that only ever started at the moment it was granted would stay down - * until the app was launched again. - */ - private val requestNotificationPermission = - registerForActivityResult(ActivityResultContracts.RequestPermission()) {} - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - // Before anything else that could throw, so the first crash of a launch is caught too. - installCrashLog(this) - - // Transparent status bar on every version; the Surface below paints through underneath it - // and content insets itself. Same reasoning as dev-updater's MainActivity. - enableEdgeToEdge() - - // The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session - // screen against BenchFixture's in-process fake backend, with no enrollment, no network - // permission, and no notification prompt -- none of them mean anything with no server and - // no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built - // to talk to a real backend is made to talk to this instead. Still needs the same - // status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the - // platform's own default from Android 15 on this app's targetSdk, with or without the call - // above, so skipping the padding here put the header's own buttons under the status bar -- - // there to look at, but not there for `ui-trace`'s tap-by-label to land on. - if (BuildConfig.FIXTURE_MODE) { - installFixtureNetworkOnce() - BenchFixture.ensureLoaded(this) - setContent { - MaterialTheme(colorScheme = AiAppColors) { - Surface(modifier = Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { - SessionScreen( - settings = BenchFixture.settings, - summary = benchSessionSummary(), - onBack = { finish() }, - onFiles = {}, - ) - } - } - } - } - return - } - // Dark status-bar icons only over a light background, decided from the scheme rather than - // fixed. It was hardcoded to `true`, which was right against the default light surface and - // became unreadable the moment the app wore Catppuccin Mocha. - WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = - AiAppColors.background.luminance() > 0.5f - - // Android 17+ silently drops local-network traffic without this; requested up front because - // a denial is invisible at the socket layer (it just times out). - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) { - requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) - } - - handleIntent(intent) - // After enrollment, so a first launch that arrives with a token starts the service with - // something to connect to rather than stopping it and waiting for the next launch. - NotificationService.sync(this) - - setContent { - // Selection colours with the theme rather than at each place text is drawn: the - // transcript is one selection container, and a selection that ran from a reply into the - // code block under it would otherwise change colour halfway. - MaterialTheme(colorScheme = AiAppColors) { - CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) { - Surface(modifier = Modifier.fillMaxSize()) { - Box( - modifier = - // Timed like the transcript times itself, and for the same reason: - // the frame's draw phase is where Compose's measurement lands, and - // a report saying "draw is high" cannot otherwise say whether the - // cost is the transcript or the chrome around it. The keyboard is - // the case that made it matter. - Modifier.layout { measurable, constraints -> - val started = System.nanoTime() - val placeable = measurable.measure(constraints) - DebugStats.record( - "measure: the app root", - System.nanoTime() - started, - ) - layout(placeable.width, placeable.height) { - val placing = System.nanoTime() - placeable.place(0, 0) - DebugStats.record( - "place: the app root", - System.nanoTime() - placing, - ) - } - } - .drawWithContent { - val started = System.nanoTime() - drawContent() - DebugStats.record( - "record: the app root", - System.nanoTime() - started, - ) - } - .fillMaxSize() - .statusBarsPadding() - // The gesture strip at the bottom of most phones. Without it - // the send row sits under the swipe area, where a tap is as - // likely to navigate away as to press a button. - // - // No imePadding here, deliberately: applied at the root it - // resizes this whole box on every frame of the keyboard - // animation, which re-measures, re-places and re-records every - // screen's entire tree per frame. Each screen takes the - // keyboard itself, so the per-frame cost is scoped to what - // actually moves. - .navigationBarsPadding() - ) { - AppRoot(settingsVersion, openRequest, shareRequest) - } - } - } - } - } - } - - /** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */ - private fun benchSessionSummary() = - SessionSummary( - id = BenchFixture.SESSION_ID, - setup = "bench", - setupName = "bench", - provider = "bench", - title = "P0 benchmark", - model = null, - keepsOwnTranscript = false, - permissionMode = null, - effort = null, - takesEffort = false, - imported = false, - notify = false, - autoResume = false, - autoResumeMessage = "", - resumeAt = null, - cwd = null, - contextTokens = null, - maxImageEdge = null, - usageProvider = null, - status = "idle", - lastActivity = 0.0, - subagents = 0, - ) - - // launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open, - // lands here rather than in a second activity instance. - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - handleIntent(intent) - } - - /** - * The one place an incoming intent is sorted into what it means. - * - * Three things arrive this way -- a share from another app, and an `aiapp://` URI that is - * either an enrollment code or a notification naming a session. The URIs are told apart by host - * rather than by two entry points, so a further kind is a branch here. - */ - private fun handleIntent(intent: Intent?) { - intent ?: return - sharedContent(intent, shares + 1)?.let { shared -> - shares = shared.serial - shareRequest = shared - return - } - val uri = intent.data ?: return - val sessionId = notifiedSessionId(uri) - if (sessionId != null) { - opens++ - openRequest = SessionOpenRequest(sessionId, opens) - return - } - val settings = parseEnrollmentUri(uri) - if (settings == null) { - Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show() - return - } - saveServerSettings(this, settings) - settingsVersion++ - // Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the - // moment the old one stops being it. - NotificationService.sync(this) - Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show() - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt deleted file mode 100644 index 4252ac4..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ /dev/null @@ -1,153 +0,0 @@ -package com.example.aiapp - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle - -/** - * The app's root: one title, and four views of the backend behind it. - * - * These were four screens reached by four words in a row under the title, and the row was already - * full. Tabs say the same thing in less space and say one more thing besides: that these are places - * to be rather than errands to run. Sessions, the machine's importable history, the models on it - * and the machines themselves are all *the same backend*, looked at four ways, and none is a step - * down from another. Settings still is, which is why it stays a pushed screen with its own Back. - */ -private enum class MainTab(val label: String) { - Sessions("Sessions"), - Import("Import"), - Models("Models"), - Setups("Setups"), -} - -@Composable -fun MainScreen( - settings: ServerSettings, - reloadToken: Int, - /** What another app shared in and no session has taken yet; see [ShareRequest]. */ - share: ShareRequest? = null, - onOpen: (SessionSummary) -> Unit, - /** Opens one session's subagent, from the expander under its card. */ - onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit, - onSpawn: () -> Unit, - onImported: (SessionSummary) -> Unit, - onSettings: () -> Unit, -) { - var tab by remember { mutableStateOf(MainTab.Sessions) } - var refreshToken by remember { mutableIntStateOf(0) } - - // Coming back to the app asks again, on whichever tab is showing. - // - // What these four draw is a snapshot of a backend they are not connected to, so it is only as - // fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A - // phone that was away while the tunnel was down came back to "Couldn't reach the server" - // sitting at the top of a list the server would now answer for perfectly well. A stale failure - // is worse than a stale list: it is a claim about right now. - // - // Through the same token the Refresh button uses, so this is one instruction the tabs already - // understand. Not on the first entry: the tab composing already asks. - val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(lifecycleOwner) { - var opening = true - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - if (!opening) refreshToken++ - opening = false - } - } - - // A tab the app put over the list has to step back to it rather than fall through to the system - // default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled. - BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions } - - Column(Modifier.fillMaxSize()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp), - ) { - Text( - "AI Sessions", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - // Glyphs rather than the words they replaced: neither ever changes, both are read - // faster than they are spelled, and together they take the width that let the title - // keep its own line. They sit on the title's row because they act on the whole screen. - // - // Flush against each other: a glyph button carries its own padding, so two side by side - // already have two rings between their marks. - Row { - GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) - GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) - } - } - // What is waiting to be attached, and what to do about it. Said here because the list below - // is where the choice is made, and a share that arrived with nothing on screen saying so - // would read as a tap that did nothing. - share?.let { - Text( - it.summary() + " -- open the session it belongs in.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .background( - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.shapes.small, - ) - .padding(12.dp), - ) - } - // Primary rather than the plain TabRow, which is deprecated in favour of the two that say - // where they sit: these are the app's top-level destinations. - PrimaryTabRow(selectedTabIndex = tab.ordinal) { - MainTab.entries.forEach { entry -> - Tab( - selected = tab == entry, - onClick = { tab = entry }, - text = { Text(entry.label) }, - ) - } - } - - // Refreshing means "ask again about what I am looking at", so the button feeds the tab that - // is showing. The token from above means something else already changed what these show; - // the two are the same instruction, so they are summed rather than tracked apart. - val token = reloadToken + refreshToken - when (tab) { - MainTab.Sessions -> - SessionListScreen( - settings = settings, - reloadToken = token, - onOpen = onOpen, - onOpenSubagent = onOpenSubagent, - onSpawn = onSpawn, - ) - MainTab.Import -> - ImportScreen(settings = settings, reloadToken = token, onImported = onImported) - MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token) - MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token) - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt deleted file mode 100644 index 07512dd..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ /dev/null @@ -1,667 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.layout -import androidx.compose.ui.semantics.CollectionInfo -import androidx.compose.ui.semantics.CollectionItemInfo -import androidx.compose.ui.semantics.collectionInfo -import androidx.compose.ui.semantics.collectionItemInfo -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import com.mikepenz.markdown.compose.LocalImageTransformer -import com.mikepenz.markdown.compose.LocalMarkdownAnimations -import com.mikepenz.markdown.compose.LocalMarkdownColors -import com.mikepenz.markdown.compose.LocalMarkdownComponents -import com.mikepenz.markdown.compose.LocalMarkdownDimens -import com.mikepenz.markdown.compose.LocalMarkdownPadding -import com.mikepenz.markdown.compose.LocalMarkdownTypography -import com.mikepenz.markdown.compose.LocalReferenceLinkHandler -import com.mikepenz.markdown.compose.components.markdownComponents -import com.mikepenz.markdown.compose.elements.MarkdownDivider -import com.mikepenz.markdown.compose.elements.listDepth -import com.mikepenz.markdown.m3.elements.MarkdownCheckBox -import com.mikepenz.markdown.m3.markdownColor -import com.mikepenz.markdown.m3.markdownTypography -import com.mikepenz.markdown.model.NoOpImageTransformerImpl -import com.mikepenz.markdown.model.State -import com.mikepenz.markdown.model.markdownAnimations -import com.mikepenz.markdown.model.markdownDimens -import com.mikepenz.markdown.model.markdownPadding -import com.mikepenz.markdown.model.parseMarkdown -import java.util.concurrent.ConcurrentHashMap -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.intellij.markdown.MarkdownTokenTypes -import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.ast.findChildOfType -import org.intellij.markdown.flavours.gfm.GFMElementTypes -import org.intellij.markdown.flavours.gfm.GFMTokenTypes - -/** - * [text] drawn as its pieces, one under the other; see [Piece]. - * - * [live] is the reply still arriving, and two things are different for it. Its parse is incremental - * -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole - * message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is - * worth a great deal while every delta invalidates the message and worth nothing once it stops - * changing -- and it is not free: each layer is a layout node and a display list held for the life - * of the row, and live node count is what the transcript's per-frame cost scales with. - */ -@Composable -fun MarkdownText( - text: String, - replies: ParsedReplies, - modifier: Modifier = Modifier, - live: Boolean = false, -) { - val segments = - if (live) liveSegments(text) - else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) } - Column(modifier.fillMaxWidth()) { - var previous: Piece? = null - var previousSegment: Segment? = null - segments.forEachIndexed { at, segment -> - val nextContinues = segments.getOrNull(at + 1)?.continues == true - // Only the tail is still being written; a frozen segment is finished text that happens - // to sit in a live reply, and it takes its colours now. - MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) { - segment.pieces.forEachIndexed { index, piece -> - val gap = - when { - previousSegment == null -> 0.dp - previousSegment !== segment -> - if (segment.continues) 0.dp else BLOCK_SPACING - else -> gapBefore(previous, piece) - } - // Keyed by where the piece starts in the message rather than by its position in - // this column, so a delta landing in the last block leaves every other piece's - // composition alone -- and a block keeps its key when it freezes. - key(segment.start, piece) { - MarkdownPiece( - segment.parse, - segment.text, - piece, - Modifier.padding(top = gap) - .then(if (live) Modifier.graphicsLayer() else Modifier) - .drawWithContent { - val started = System.nanoTime() - drawContent() - DebugStats.record( - "record: one block", - System.nanoTime() - started, - ) - }, - continuesList = segment.continues && index == 0, - listContinues = nextContinues && index == segment.pieces.lastIndex, - ) - } - previous = piece - previousSegment = segment - } - } - } - } -} - -/** - * A stretch of a message with a parse of its own: the whole of a settled message, or one block, the - * finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins - * in the message. [continues] says the first piece is an item of the list the segment before it - * ended with, so the two draw as one list. - */ -private class Segment( - val text: String, - val start: Int, - val parse: State, - val pieces: List, - val continues: Boolean = false, -) - -/** - * The live reply's segments: parsed on the composing thread the first time the row is drawn, and - * incrementally off it for every delta afterwards. - * - * The first parse has to be inline. The renderer's own asynchronous path draws an empty loading - * slot until its result arrives, so a row is measured at nothing before it is measured at its real - * height, and the transcript above it collapses and springs back -- seen with five replies on - * screen at once, the whole conversation shrunk to fit a single screen. - * - * Every parse after the first is off the composing thread, and the row keeps drawing the parse it - * already has until the new one lands, so there is never a frame without a height. - */ -@Composable -private fun liveSegments(text: String): List { - val parsed = remember { - mutableStateOf( - DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) } - ) - } - LaunchedEffect(text) { - if (parsed.value.text == text) return@LaunchedEffect - val previous = parsed.value - parsed.value = - withContext(Dispatchers.Default) { - DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) } - } - } - return parsed.value.segments -} - -/** - * A reply still arriving, parsed a block at a time. - * - * Reparsing the whole message per delta was fine for a short reply and not for a long one: a - * twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran - * off the composing thread it was every core busy while the frame's own thread waited for one. - * - * Markdown's blocks make the cut safe: a top-level block that another block has started *after* is - * finished -- nothing appended later can reach back into it. So every block but the last is - * [frozen] with the parse that finished it, and only the tail is parsed again. - * - * A list is cut once more, at its last item, by the same reasoning one level down. Without this a - * reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut - * lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of - * a paragraph line once `-x` arrives. - * - * What the cut gives up is one thing: a reference definition arriving later than a link that uses - * it. The link draws as its brackets until the reply settles and is parsed whole by [warm]. - */ -private class LiveParse( - val text: String, - private val frozen: List, - /** How much of [text] the frozen segments cover; the tail starts here. */ - private val consumed: Int, - private val tail: Segment, -) { - val segments: List - get() = frozen + tail - - fun advanceTo(next: String): LiveParse { - // Anything but an append to what was frozen -- a message replaced, a stream reset -- starts - // over. - if (!next.regionMatches(0, text, 0, consumed)) return whole(next) - val tailText = next.substring(consumed) - val parse = parseMarkdown(tailText) - val all = pieces(parse) - val open = (parse as? State.Success)?.let { openPiece(it, all) } - if (open == null) { - return LiveParse( - next, - frozen, - consumed, - Segment(tailText, consumed, parse, all, tail.continues), - ) - } - val done = - all.subList(0, all.indexOf(open)) - .groupBy { it.block } - .values - .mapIndexed { at, pieces -> - Segment( - tailText, - consumed, - parse, - pieces, - continues = at == 0 && tail.continues, - ) - } - // Cut at the start of the open piece's line rather than at the piece, so an indented item - // or block keeps the indentation the parse of the rest reads its nesting from. - val node = - parse.node.children[open.block].let { - if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item] - } - val cut = tailText.lastIndexOf('\n', node.startOffset) + 1 - val rest = tailText.substring(cut) - val restParse = parseMarkdown(rest) - return LiveParse( - next, - frozen + done, - consumed + cut, - Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0), - ) - } - - /** - * The piece of the tail still being written: the last item of a list of several, or the first - * piece of the last block when there is more than one. Null when nothing before it is finished. - */ - private fun openPiece(parse: State.Success, all: List): Piece? { - val last = all.lastOrNull() ?: return null - val lastBlockStart = all.indexOfFirst { it.block == last.block } - return when { - last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last - lastBlockStart > 0 -> all[lastBlockStart] - else -> null - } - } - - /** Whether a list item holds anything beyond its marker yet. */ - private val ASTNode.hasBegun: Boolean - get() = children.any { it.type !in MARKER_TOKENS } - - companion object { - fun whole(text: String): LiveParse { - val parse = parseMarkdown(text) - return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse))) - } - - private val MARKER_TOKENS = - setOf( - MarkdownTokenTypes.LIST_BULLET, - MarkdownTokenTypes.LIST_NUMBER, - MarkdownTokenTypes.WHITE_SPACE, - MarkdownTokenTypes.EOL, - ) - } -} - -/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */ -@Composable -fun MarkdownPiece( - text: String, - piece: Piece, - replies: ParsedReplies, - modifier: Modifier = Modifier, -) { - // Remembered so a message the flatten drew before [warm] reached it is parsed once here, not - // once per composition. - val parse = remember(text) { replies.of(text) } - MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) } -} - -/** - * The renderer's own environment -- its colours, type scale, dimensions, component table and - * reference links -- around whatever draws pieces of [parse]. - * - * The parsing is the library's: markdown is somebody else's specification, and a hand-written - * parser would get the edge cases wrong one case at a time. So is the environment: the element - * composables its dispatch reaches read these locals, and providing them once here is what lets a - * piece be drawn anywhere -- in a message's column, or as one item of the transcript list. - * - * The locals are provided directly rather than through the renderer's `Markdown()` composable, - * which was the last of its composables on the hot path and was here only to provide them. So - * nothing between a piece and the screen is the library's but the leaf composables named in the - * component table. - * - * Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its - * own. - * - * [streaming] says this parse is the part of a reply still being written, which only the fences - * care about: lexing is proportional to how much code there is. Measured streaming a two-hundred- - * line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms -- - * for colours on text being replaced as fast as they were computed. So a fence still being written - * is drawn plain and takes its colours when the block freezes. - */ -@Composable -private fun MarkdownRoot( - parse: State, - replies: ParsedReplies, - streaming: Boolean = false, - content: @Composable () -> Unit, -) { - if (parse !is State.Success) { - // Nothing below needs the environment; [MarkdownPiece] draws the words plainly. - content() - return - } - val body = MaterialTheme.typography.bodyLarge - CompositionLocalProvider( - LocalReferenceLinkHandler provides parse.referenceLinkHandler, - LocalMarkdownPadding provides markdownPadding(), - // Read by the renderer's own text composable, which no paragraph reaches any more, and by - // its checkbox. Provided so a path that does reach them draws no image rather than failing - // to compose. - LocalImageTransformer provides remember { NoOpImageTransformerImpl() }, - LocalMarkdownAnimations provides markdownAnimations(), - LocalMarkdownColors provides - markdownColor( - text = MaterialTheme.colorScheme.onSurface, - dividerColor = MaterialTheme.colorScheme.outlineVariant, - // The dark surface every verbatim thing in this app sits on -- and the tool call - // above this reply, which now matches. `surfaceVariant` was exactly a card's own - // fill, so a fenced block inside a tool call had no background at all. - codeBackground = rawSurface, - // The same colour. Not drawn by the renderer as a span background but by - // [LinkedText] behind the text, so a selection lands on top of it -- see - // `appendCodeChip`. - inlineCodeBackground = rawSurface, - // The same tint a code block gets, rather than the renderer's 2%-alpha default: two - // adjacent tints that differ by a fiftieth read as one flat block on a phone. - tableBackground = MaterialTheme.colorScheme.surfaceVariant, - ), - LocalMarkdownTypography provides - markdownTypography( - // A ladder that starts near the body text and descends, because these are headings - // inside a chat message rather than the top of a document. The renderer's defaults - // are the Material *display* styles -- `#` came out at 57sp, bigger than this app's - // own screen titles. Every step is a different size, so two levels of nesting never - // draw the same. - h1 = MaterialTheme.typography.headlineSmall, - h2 = MaterialTheme.typography.titleLarge, - h3 = MaterialTheme.typography.titleMedium, - h4 = MaterialTheme.typography.titleSmall, - h5 = MaterialTheme.typography.labelMedium, - h6 = MaterialTheme.typography.labelSmall, - text = body, - paragraph = body, - ordered = body, - bullet = body, - list = body, - table = body, - // Code in a monospace face, in the ordinary text colour. The face and the tinted - // background are what say "this is code"; colour is not, and it used to be green -- - // the palette's colour for a *literal*. A block of code is not a literal, and - // painting all of it green said the whole block was one. Where a literal really - // does appear inside code, what should colour it is a syntax highlighter. - code = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurface, - ), - inlineCode = - body.copy( - fontFamily = FontFamily.Monospace, - // Unspecified so an inline span keeps the size of the line it sits in. - fontSize = TextUnit.Unspecified, - color = MaterialTheme.colorScheme.onSurface, - ), - textLink = - TextLinkStyles( - style = - body - .copy( - color = linkColor, - textDecoration = TextDecoration.Underline, - ) - .toSpanStyle() - ), - ), - LocalMarkdownDimens provides - markdownDimens( - // Half the renderer's 16dp. Padding is charged on both sides of every cell, so at - // the default a fifth of the narrowest column went on space rather than on words. - tableCellPadding = 8.dp, - // What a column narrows to before the table starts scrolling sideways instead. It - // is the floor, not the width: a table with room to spare spreads across it. - // - // Down from the renderer's 160dp, and the number is a measurement rather than a - // taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp - // makes even a three-column table scroll, while 136dp fits three across the phone - // this app is read on. Four and up still scroll, which is the right answer for - // genuinely too many columns. This is the widest minimum that keeps three on - // screen. - tableCellWidth = 136.dp, - ), - LocalMarkdownComponents provides - markdownComponents( - // The m3 renderer's own default, restored: supplying `components` at all replaces - // the whole set, and this is the only member the Material layer overrides. - checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) }, - // Everything that draws a run of text, so a link is a span rather than a node -- - // see [LinkedText]. Setext headings take the same styles as `#` and `##`. - text = { LinkedText(it, it.typography.text) }, - paragraph = { LinkedText(it, it.typography.paragraph) }, - heading1 = { LinkedHeading(it, it.typography.h1) }, - heading2 = { LinkedHeading(it, it.typography.h2) }, - heading3 = { LinkedHeading(it, it.typography.h3) }, - heading4 = { LinkedHeading(it, it.typography.h4) }, - heading5 = { LinkedHeading(it, it.typography.h5) }, - heading6 = { LinkedHeading(it, it.typography.h6) }, - setextHeading1 = { LinkedHeading(it, it.typography.h1) }, - setextHeading2 = { LinkedHeading(it, it.typography.h2) }, - // Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so - // they draw like the top-level ones the transcript cuts into items. - orderedList = { MarkdownList(it.content, it.node, it.listDepth) }, - unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, - table = { LinkedTable(it.content, it.node, it.typography.table) }, - // Code is highlighted the way a tool call's input is; see [CodeFence]. - codeFence = { - CodeFence(it.content, it.node, it.typography.code, replies, streaming) - }, - codeBlock = { - CodeBlock(it.content, it.node, it.typography.code, replies, streaming) - }, - ), - content = content, - ) -} - -/** - * A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need. - * - * Each column has a floor, so the table is at least columns-times-floor wide; narrower than the - * room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The - * renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one - * layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal - * scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after - * it reads the minimum back and sizes the rows to the larger of that and the floor. - */ -@Composable -private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) { - val dimens = LocalMarkdownDimens.current - val colors = LocalMarkdownColors.current - val columns = - remember(node) { - node.findChildOfType(GFMElementTypes.HEADER)?.children?.count { - it.type == GFMTokenTypes.CELL - } ?: 0 - } - val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 } - val floor = dimens.tableCellWidth * columns - Column( - Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize)) - .semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) } - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .layout { measurable, constraints -> - val width = maxOf(constraints.minWidth, floor.roundToPx()) - val placeable = - measurable.measure(constraints.copy(minWidth = width, maxWidth = width)) - layout(width, placeable.height) { placeable.place(0, 0) } - } - ) { - var rowIndex = 1 - node.children.forEach { child -> - when (child.type) { - GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0) - GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++) - GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider() - } - } - } -} - -/** - * One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText]. - * - * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means - * most of a table is simply not readable: an elided cell looks like a short one, so a table of - * measurements reads as a table of plausible shorter measurements. And they draw a link in a cell - * as its own layout node, the cost [LinkedText] exists to avoid. - * - * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell - * beside a one-line one centred the short one against the middle of the tall one. What the wrapping - * does *not* do is make a wide table fit; [LinkedTable] scrolls it instead. - * - * The semantics are the renderer's: each cell is an item of the table's collection. - */ -@Composable -private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) { - val padding = LocalMarkdownDimens.current.tableCellPadding - val header = rowIndex == 0 - val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style - Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) { - row.children - .filter { it.type == GFMTokenTypes.CELL } - .forEachIndexed { column, cell -> - LinkedText( - content, - cell, - cellStyle, - Modifier.padding(padding).weight(1f).semantics { - if (header) heading() - collectionItemInfo = - CollectionItemInfo( - rowIndex = rowIndex, - rowSpan = 1, - columnIndex = column, - columnSpan = 1, - ) - }, - ) - } - } -} - -/** - * Replies parsed before the row that draws them is composed. - * - * Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much - * was written. Measured against a real Claude Code transcript on the emulator, one message took - * **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first - * tuned on -- so a page of history landing composed several rows that each stalled the frame. - * - * Nothing here changes what a row does when it has no answer waiting: it parses inline, because a - * row measured at nothing before its real height collapses the transcript above it. The point is - * only that by the time the reader scrolls to a row, the answer is usually already made. - * - * A miss is not stored, and that is what bounds this: the map holds one entry per message a page - * warmed, so a reply still streaming cannot fill it with hundreds of copies of itself. - */ -@Stable -class ParsedReplies { - private val parsed = ConcurrentHashMap() - - /** - * How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per - * fold, and walking the tree again each time is proportional to the message. - */ - private val pieces = ConcurrentHashMap>() - - /** - * How each message divides into prose and memory notes, cached for the same reason: the regex - * scan behind [messageParts] is proportional to the message. - */ - private val parts = ConcurrentHashMap>() - - private val chunks = ConcurrentHashMap>() - - /** - * Each fence's coloured text, keyed by its language and code. - * - * Beside the parses for the same reason and at the same cost: lexing is proportional to how - * much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator -- - * and a lazy list drops the composition of a block that scrolls away, so a `remember` inside - * the fence paid that again every time the reader came back to it. Six times in one scroll, - * measured. - */ - private val highlights = ConcurrentHashMap() - - private val ready = ConcurrentHashMap.newKeySet() - - /** The pieces of [text], from its parse -- made now if [warm] has not made it. */ - fun piecesOf(text: String): List = - pieces.computeIfAbsent(text) { - DebugStats.timed("markdown cut into pieces") { pieces(of(it)) } - } - - /** How a long user message divides into slices; cached for the same reason as [piecesOf]. */ - fun chunksOf(text: String): List = - chunks.computeIfAbsent(text) { - DebugStats.timed("user message cut into slices") { userChunks(it) } - } - - /** - * Whether [warm] has made everything drawing [text] as pieces will look up. - * - * What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole - * message and the flatten runs on the composing thread, so a reply not marked yet stays whole - * until the screen has warmed it. An explicit mark rather than a peek into the parse cache, - * because a message with memory notes is warmed as its *parts*: nothing ever parses its full - * text, and inferring readiness from the cache left exactly that message unsplittable forever. - */ - fun splitReady(text: String): Boolean = text in ready - - /** The other half of [splitReady]; [warm] calls it once a message's parses exist. */ - fun markSplitReady(text: String) { - ready.add(text) - } - - fun partsOf(text: String): List = - parts.computeIfAbsent(text) { - DebugStats.timed("message cut into parts") { messageParts(it) } - } - - /** - * [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the - * language, because the same code lexes differently under two of them. - */ - fun highlighted(code: String, language: Language?): AnnotatedString = - if (language == null) AnnotatedString(code) - else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) } - - /** The parse of [text] -- the one made ahead, or one made now. */ - fun of(text: String): State = - parsed[text]?.also { DebugStats.count("markdown ready") } - ?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) } - - /** - * Parses whatever is not held yet. Call off the composing thread; that is the whole point. - * - * Suspending, and yielding between messages, because "off the composing thread" is not the same - * as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in - * a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every - * core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th - * percentile. - */ - suspend fun warm(texts: List) { - texts.forEach { text -> - val parse = - parsed.computeIfAbsent(text) { - DebugStats.timed("markdown warmed") { parseMarkdown(it) } - } - // The fences too, and here rather than in a pass of its own: they are found in the - // parse this just made, and lexing one is the same kind of cost as parsing the message - // it is in. - fences(parse).forEach { (code, language) -> highlighted(code, language) } - } - } - - /** Everything these described is gone; see [ParsedReplies]. */ - fun clear() { - parsed.clear() - pieces.clear() - parts.clear() - chunks.clear() - highlights.clear() - ready.clear() - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt deleted file mode 100644 index 417fb86..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ /dev/null @@ -1,320 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.waitForUpOrCancellation -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.isSpecified -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.node.Ref -import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.text.TextStyle -import com.mikepenz.markdown.annotator.AnnotatorSettings -import com.mikepenz.markdown.annotator.annotatorSettings -import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString -import com.mikepenz.markdown.compose.LocalMarkdownColors -import com.mikepenz.markdown.compose.components.MarkdownComponentModel -import com.mikepenz.markdown.model.markdownAnnotator -import com.mikepenz.markdown.utils.getUnescapedTextInNode -import com.mikepenz.markdown.utils.resolveImageAlt -import com.mikepenz.markdown.utils.resolveImageLink -import org.intellij.markdown.MarkdownElementTypes -import org.intellij.markdown.MarkdownTokenTypes -import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.ast.findChildOfType -import org.intellij.markdown.flavours.gfm.GFMTokenTypes - -/** - * A paragraph, heading or bare text whose links are spans of the text rather than nodes of their - * own. - * - * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, - * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text - * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation - * per link. Measured on the emulator against the same paragraphs with each link replaced by its - * label and address as plain words -- *more* text, the same gestures -- the linked version cost - * five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. - * - * Here a link is the link colour and underline, a string annotation carrying its address, and one - * tap detector for the whole text that asks the layout which character was under the finger. What - * that gives up is a link being its own accessibility node with a pressed state; the app's link - * style never defined a pressed style, so nothing visible changes. - * - * Every block the renderer dispatches through its component table comes here, and so does every - * table cell. Reference-style links are the one kind still drawn the renderer's way. - * - * An image is a link too, carrying its alt text. The app has no image loader and the renderer's - * transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the - * model put something. The link says what was there and where, and opens it. - */ -@Composable -fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { - LinkedText(model.content, model.node, style) -} - -/** - * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or - * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it - * does not know, so handed the heading node itself it draws an empty line. - */ -@Composable -fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) { - val words = - model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT) - ?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT) - ?: model.node - LinkedText(model.content, words, style, Modifier.semantics { heading() }) -} - -/** The inline content of [node] within [content], drawn as [LinkedText] describes. */ -@Composable -fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) { - val settings = plainLinkSettings() - val text = - remember(content, node, style) { - content.buildMarkdownAnnotatedString(node, style, settings) - } - val uriHandler = LocalUriHandler.current - val onPlainTap = LocalMarkdownTap.current - val layout = remember { Ref() } - // The renderer's own rule for a style that names no colour: the theme's text colour. - val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text - val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) } - val chipColor = LocalMarkdownColors.current.inlineCodeBackground - // Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame - // finds it set -- no state needed, and a relayout redraws the node anyway. - val chipFills = remember { Ref>() } - val chipFill = - if (chips.isEmpty()) Modifier - else - Modifier.drawBehind { - chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) } - } - BasicText( - text = text, - modifier = - // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second - // one has to be answered from inside the text rather than left to the card. - modifier.then(chipFill).pointerInput(text, onPlainTap) { - awaitEachGesture { - // Unconsumed is not required: something outside may already be tracking this - // press, and it is still the press that may land on a link. - awaitFirstDown(requireUnconsumed = false) - // A tap and nothing else. Null when the gesture became somebody else's -- a - // scroll, or a press held past the long-press timeout, which is how a selection - // starts. The timeout is the load-bearing half: without it a press held for a - // second and released was still an up with nothing consumed, so holding a peer - // message to select from it shut the card instead. - val up = - withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { - waitForUpOrCancellation() - } ?: return@awaitEachGesture - val url = text.linkAt(layout.value, up.position) - when { - url != null -> { - up.consume() - uriHandler.openUri(url) - } - onPlainTap != null -> { - up.consume() - onPlainTap() - } - } - } - }, - style = style, - color = { color }, - onTextLayout = { - layout.value = it - chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) } - }, - ) -} - -/** - * What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in, - * usually -- or null where a plain tap means nothing. - * - * A composition local because there is nowhere else to put it. The paragraphs of a message are - * composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours - * to pass a parameter through. - * - * It exists because a pointer-input node over the glyphs takes the tap and the card's own click - * handler never sees it. Measured against an opened peer message: with a handler on the text -- - * consuming or not -- a tap on its words did nothing at all, and with the handler removed the same - * tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless - * the words do the shutting. - * - * Provided as a value that outlives a recomposition, since a fresh lambda per composition would - * invalidate every paragraph reading it. - */ -val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } - -/** - * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the - * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text - * under it on every recomposition of the card. - */ -@Composable -fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit { - val latest = rememberUpdatedState(onTap) - return remember { { latest.value() } } -} - -/** - * The address under [position], if a link's glyph is there rather than merely nearest to it. - * - * The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a - * glyph names the character after it; the glyph under the finger is the one on either side of that - * boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the - * end of a line ending in a link opens nothing. - */ -private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? { - layout ?: return null - val caret = layout.getOffsetForPosition(position) - val glyph = - (caret - 1..caret).firstOrNull { - it in 0 until length && layout.getBoundingBox(it).contains(position) - } ?: return null - return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item -} - -private const val LINK_URL = "url" - -/** - * Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does, - * but with no background of its own -- if it is a code span; false leaves anything else to the - * renderer. - * - * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's - * background is part of the text's own drawing, and the text node draws the selection first and the - * glyphs over it, so a chip painted as a span background covered the selection: selecting a - * sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the - * text is under both, which is where a fenced block's box already is. - */ -private fun appendCodeChip( - builder: AnnotatedString.Builder, - content: String, - node: ASTNode, - settings: AnnotatorSettings, -): Boolean { - if (node.type != MarkdownElementTypes.CODE_SPAN) return false - builder.pushStringAnnotation(CODE_CHIP, "") - builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified)) - builder.append(' ') - // The backticks are the first and last children. - builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings) - builder.append(' ') - builder.pop() - builder.pop() - return true -} - -private const val CODE_CHIP = "code" - -/** - * One box per line of the text [start] until [end] covers, in the layout's own coordinates. - * - * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every - * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line - * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space - * stops being drawn -- the same rule the selection rectangle obeys, so the two agree. - * - * A run's extent is taken from the boxes of its first and last characters, which is exact while a - * line reads in one direction; mixed directions inside a code span would draw one box across the - * whole run, and code spans are code. - */ -private fun TextLayoutResult.chipRects(start: Int, end: Int): List { - val rects = mutableListOf() - for (line in getLineForOffset(start)..getLineForOffset(end - 1)) { - val from = maxOf(start, getLineStart(line)) - val to = minOf(end, getLineEnd(line, visibleEnd = true)) - if (from >= to) continue - val head = getBoundingBox(from) - val tail = getBoundingBox(to - 1) - rects += - Rect( - left = minOf(head.left, tail.left), - top = minOf(head.top, tail.top), - right = maxOf(head.right, tail.right), - bottom = maxOf(head.bottom, tail.bottom), - ) - } - return rects -} - -/** - * The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip] - * for inline code. The annotator needs the settings to draw a link's label, and the settings hold - * the annotator, so the reference goes through a cell filled in once both exist. - */ -@Composable -private fun plainLinkSettings(): AnnotatorSettings { - val cell = remember { Ref() } - val annotator = remember { - markdownAnnotator { content, node -> - appendPlainLink(this, content, node, cell.value!!) || - appendCodeChip(this, content, node, cell.value!!) - } - } - return annotatorSettings(annotator = annotator).also { cell.value = it } -} - -/** - * Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a - * `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer. - */ -private fun appendPlainLink( - builder: AnnotatedString.Builder, - content: String, - node: ASTNode, - settings: AnnotatorSettings, -): Boolean { - val destination: String - /** The label's own inline nodes, when it has markup of its own to draw. */ - var label: List? = null - /** Plain words for the label; the address itself when there are none. */ - var words: String? = null - when (node.type) { - MarkdownElementTypes.INLINE_LINK -> { - val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false - destination = - node - .findChildOfType(MarkdownElementTypes.LINK_DESTINATION) - ?.getUnescapedTextInNode(content) - ?.removeSurrounding("<", ">") ?: return false - // The brackets are the first and last children of the label. - label = text.children.drop(1).dropLast(1) - } - MarkdownElementTypes.AUTOLINK -> - destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">") - GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content) - MarkdownElementTypes.IMAGE -> { - destination = - node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false - words = node.resolveImageAlt(content) - } - else -> return false - } - builder.pushStringAnnotation(LINK_URL, destination) - builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle()) - if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings) - else builder.append(words ?: destination) - builder.pop() - builder.pop() - return true -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt deleted file mode 100644 index fd62492..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.isTraversalGroup -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.mikepenz.markdown.compose.LocalMarkdownComponents -import com.mikepenz.markdown.compose.LocalMarkdownPadding -import com.mikepenz.markdown.compose.LocalMarkdownTypography -import com.mikepenz.markdown.compose.MarkdownElement -import com.mikepenz.markdown.compose.components.MarkdownComponentModel -import com.mikepenz.markdown.model.State -import org.intellij.markdown.MarkdownElementTypes -import org.intellij.markdown.MarkdownTokenTypes -import org.intellij.markdown.ast.ASTNode -import org.intellij.markdown.ast.findChildOfType -import org.intellij.markdown.ast.getTextInNode -import org.intellij.markdown.flavours.gfm.GFMTokenTypes - -/** - * One drawable piece of a parsed message: a top-level block, or one item of a top-level list. - * - * The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and - * is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a - * hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on - * a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one - * message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are. - * - * Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a - * nested list are each one node whatever is inside them. A list is the one block that is not - * bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its - * items. - * - * A piece is an *address* into the message's one parse rather than a substring of it. Every piece - * is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and - * a reference definition at its foot still resolves the links above it. - */ -@Immutable -data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) { - companion object { - const val WHOLE_BLOCK = -1 - } -} - -/** - * The pieces of [parse], in reading order. Blank nodes between blocks are not pieces. - * - * A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a - * message that drew as nothing would be a hole in the transcript with no sign of what fell out. - */ -fun pieces(parse: State): List { - val success = parse as? State.Success ?: return listOf(Piece(0)) - val out = ArrayList() - success.node.children.forEachIndexed { at, node -> - when { - node.getTextInNode(success.content).isBlank() -> {} - node.isList -> repeat(node.listItems().size) { out += Piece(at, it) } - else -> out += Piece(at) - } - } - return out -} - -/** - * The room above [piece] when it follows [previous] in the same message: none between two items of - * one list, whose own padding already separates them, and a block's gap otherwise. The first piece - * of a message takes the message's gap, which is the caller's to know. - */ -fun gapBefore(previous: Piece?, piece: Piece): Dp = - if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING - -/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */ -val BLOCK_SPACING: Dp = 6.dp - -/** - * [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme, - * the components and the reference links to the renderer's element composables. - * - * A whole block goes to the renderer's own dispatch with this app's component table. Only the list - * item is drawn directly, because a list item is the one piece the renderer has no element for. - * - * [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an - * item that is the first or last of its own parse but not of the list the reader sees keeps an - * inner item's padding, so nothing moves when the seam between segments does. - */ -@Composable -fun MarkdownPiece( - parse: State, - text: String, - piece: Piece, - modifier: Modifier = Modifier, - continuesList: Boolean = false, - listContinues: Boolean = false, -) { - if (parse !is State.Success) { - // The parser threw. Nothing else in the app has seen this happen; if it does, the words are - // still worth more than a blank. - Text(text, modifier, style = MaterialTheme.typography.bodyLarge) - return - } - val node = parse.node.children[piece.block] - if (piece.item == Piece.WHOLE_BLOCK) { - Box(modifier) { - MarkdownElement( - node, - LocalMarkdownComponents.current, - parse.content, - includeSpacer = false, - ) - } - } else { - val items = node.listItems() - MarkdownListItem( - content = parse.content, - list = node, - item = items[piece.item], - index = piece.item, - first = piece.item == 0 && !continuesList, - last = piece.item == items.lastIndex && !listContinues, - depth = 0, - modifier = modifier, - ) - } -} - -/** - * A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a - * list inside a quote, and the nested lists an item holds. Top-level lists never come here. - */ -@Composable -fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) { - val items = list.listItems() - Column(modifier) { - items.forEachIndexed { index, item -> - MarkdownListItem( - content, - list, - item, - index, - first = index == 0, - last = index == items.lastIndex, - depth = depth, - ) - } - } -} - -/** - * One item: its marker beside its content, laid out the way the renderer's own list does so that a - * list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first - * and last items, since there is no list column to carry it. - * - * The marker is drawn here rather than by a handler because it is the thing a reader might one day - * want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn. - */ -@Composable -private fun MarkdownListItem( - content: String, - list: ASTNode, - item: ASTNode, - index: Int, - first: Boolean, - last: Boolean, - depth: Int, - modifier: Modifier = Modifier, -) { - val padding = LocalMarkdownPadding.current - val typography = LocalMarkdownTypography.current - val components = LocalMarkdownComponents.current - // A task item's box sits right after the bullet: `- [ ] text`. - val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX } - Row( - modifier - .semantics { isTraversalGroup = true } - .fillMaxWidth() - .padding( - start = padding.listIndent * depth, - top = padding.listItemTop + if (first) padding.list else 0.dp, - bottom = padding.listItemBottom + if (last) padding.list else 0.dp, - ) - ) { - if (checkbox != null) { - components.checkbox(MarkdownComponentModel(content, checkbox, typography)) - } else if (list.type == MarkdownElementTypes.ORDERED_LIST) { - Marker("${list.startNumber(content) + index}. ", typography.ordered) - } else { - Marker(BULLETS[depth % BULLETS.size], typography.bullet) - } - Column { - item.children.forEach { child -> - when (child.type) { - MarkdownTokenTypes.LIST_BULLET, - MarkdownTokenTypes.LIST_NUMBER, - GFMTokenTypes.CHECK_BOX -> {} - MarkdownElementTypes.ORDERED_LIST, - MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1) - else -> MarkdownElement(child, components, content, includeSpacer = false) - } - } - } - } -} - -/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */ -@Composable -private fun Marker(text: String, style: TextStyle) { - BasicText(text, style = style.copy(color = listMarkerColor)) -} - -/** - * The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a - * browser draws, so a nested list is told from its parent by the glyph as well as by the indent. - * Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check - * is the price of adding one here. - */ -private val BULLETS = listOf("• ", "◦ ", "▪ ") - -internal val ASTNode.isList: Boolean - get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST - -internal fun ASTNode.listItems(): List = children.filter { - it.type == MarkdownElementTypes.LIST_ITEM -} - -/** Where an ordered list counts from: the number its first item was written with. */ -private fun ASTNode.startNumber(content: String): Int = - findChildOfType(MarkdownElementTypes.LIST_ITEM) - ?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER) - ?.getTextInNode(content) - ?.takeWhile(Char::isDigit) - ?.toString() - ?.toIntOrNull() ?: 1 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt deleted file mode 100644 index d82f06b..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt +++ /dev/null @@ -1,450 +0,0 @@ -package com.example.aiapp - -/** - * Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md` - * file in the viewer. - * - * Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings: - * what a character means depends on where it sits. A `#` opens a heading at the start of a line and - * is an ordinary character three words in; a `*` opens emphasis only if something closes it on the - * same line. The token scanner cannot ask either question. - * - * Structure is read a line at a time and each line's prose left to right, so every decision is made - * inside one line -- except the two that are not. A fenced block is state carried forward, so an - * unclosed fence colours the rest of the text, which is what it looks like while somebody is - * writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot - * be anything else, and its header is the line before that -- the one place here that looks ahead. - * - * What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is - * one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by - * what came before. Colouring the wrong one as code is a mistake the reader cannot see. - * - * Like [scan], the spans come out ordered, non-overlapping and inside the text by construction. - */ -fun scanMarkdown(code: String): List = MarkdownScanner(code).run() - -/** The characters an unordered list may be bulleted with. */ -private const val BULLETS = "-*+" - -/** The characters a thematic break, or a setext heading's underline, can be drawn with. */ -private const val RULE_MARKERS = "-*_=" - -/** The characters that can open emphasis, strong emphasis or a strikethrough. */ -private const val EMPHASIS = "*_~" - -/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */ -private const val URL_STOPS = "<>\"'`|" -private const val URL_TRAILING = ".,:;!?" - -private class MarkdownScanner(private val code: String) { - private val spans = ArrayList() - - fun run(): List { - var at = 0 - // The delimiter run that opened the fenced block we are inside, or null between them. - var fence: String? = null - // Whether the row above was part of a table, which is what makes this one a body row. - var table = false - while (at <= code.length) { - val end = lineEnd(at) - val open = fence - if (open != null) { - // The content and the closing line alike: a fence is one block of code, and its own - // delimiters belong to it the way a string's quotes belong to the string. - emit(at, end, Kind.STRING) - if (closesFence(at, end, open)) fence = null - } else { - val opened = opensFence(at, end) - fence = opened - if (opened != null) table = false else table = row(at, end, table) - } - if (end == code.length) break - at = end + 1 - } - return spans - } - - /** The end of the line beginning at [at]: the newline, or the end of the text. */ - private fun lineEnd(at: Int): Int { - val newline = code.indexOf('\n', at) - return if (newline < 0) code.length else newline - } - - /** - * One line that is not inside a fence, and whether the table it may be part of is still open. - * - * A table is recognised by its delimiter row, the only line of one that cannot be anything - * else. That row comes *after* the header it belongs to, so the header is found by looking one - * line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the - * document, which would mark the pipes in a shell command written in a paragraph. - */ - private fun row(start: Int, end: Int, table: Boolean): Boolean { - if (tableDelimiter(start, end)) { - emit(indented(start, end), end, Kind.MARK) - return true - } - val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1)) - if ((table || header) && hasPipe(start, end)) { - tableRow(start, end) - return true - } - structure(start, end) - return false - } - - /** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */ - private fun tableDelimiter(start: Int, end: Int): Boolean { - var dashes = false - var pipes = false - for (at in indented(start, end) until end) { - when (code[at]) { - '-' -> dashes = true - '|' -> pipes = true - ':', - ' ', - '\t' -> {} - else -> return false - } - } - return dashes && pipes - } - - private fun hasPipe(start: Int, end: Int): Boolean { - var at = start - while (at < end) { - if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++ - } - return false - } - - /** A table row: the pipes are the structure, and what is between them is prose. */ - private fun tableRow(start: Int, end: Int) { - var at = indented(start, end) - var cell = at - while (at < end) { - when (code[at]) { - '\\' -> at += 2 - '|' -> { - inline(cell, at) - emit(at, at + 1, Kind.MARK) - at++ - cell = at - } - else -> at++ - } - } - inline(cell, end) - } - - /** - * Spans, coalesced with the one before when they touch and agree. Worth doing here rather than - * leaving it to the caller: the line scanner emits per marker and per word, so a heading would - * otherwise arrive as a dozen abutting spans of one colour. - */ - private fun emit(start: Int, end: Int, kind: Kind) { - if (end <= start) return - val last = spans.lastOrNull() - if (last != null && last.kind == kind && last.end == start) { - spans[spans.size - 1] = Span(last.start, end, kind) - } else { - spans.add(Span(start, end, kind)) - } - } - - /** The first character of the line at or after [start] that is not indentation. */ - private fun indented(start: Int, end: Int): Int { - var at = start - while (at < end && (code[at] == ' ' || code[at] == '\t')) at++ - return at - } - - /** The run of backticks or tildes that could open or close a fence on this line, or null. */ - private fun fenceRun(start: Int, end: Int): IntRange? { - val at = indented(start, end) - if (at == end) return null - val marker = code[at] - if (marker != '`' && marker != '~') return null - var run = at - while (run < end && code[run] == marker) run++ - return if (run - at >= 3) at until run else null - } - - /** Draws an opening fence line and answers its delimiter, or null if this is not one. */ - private fun opensFence(start: Int, end: Int): String? { - val run = fenceRun(start, end) ?: return null - emit(run.first, run.last + 1, Kind.STRING) - // The info word is what the fence is a fence *of*, which is metadata about the block rather - // than part of it. - emit(indented(run.last + 1, end), end, Kind.METADATA) - return code.substring(run.first, run.last + 1) - } - - /** - * Whether this line closes a fence opened by [open]: the same character, at least as many of - * them, and nothing else on the line -- so a longer run closes a shorter one and a line of - * backticks with a word after it does not close anything. - */ - private fun closesFence(start: Int, end: Int, open: String): Boolean { - val run = fenceRun(start, end) ?: return false - if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false - return indented(run.last + 1, end) == end - } - - /** One ordinary line: what its opening characters make it, and then its prose. */ - private fun structure(start: Int, end: Int) { - var at = indented(start, end) - // Quote markers come before everything else and can be several deep, and what follows one - // is an ordinary line again -- a heading inside a quote is still a heading. - while (at < end && code[at] == '>') { - at++ - emit(at - 1, at, Kind.MARK) - at = indented(at, end) - } - if (at == end) return - if (heading(at, end) || thematicBreak(at, end)) return - inline(bullet(at, end), end) - } - - /** `#` to `######` and a space. Without the space it is a word beginning with a hash. */ - private fun heading(start: Int, end: Int): Boolean { - var at = start - while (at < end && code[at] == '#') at++ - val depth = at - start - if (depth !in 1..6) return false - if (at < end && code[at] != ' ' && code[at] != '\t') return false - emit(start, end, Kind.KEYWORD) - return true - } - - /** - * A line made of one repeated rule character and nothing else. - * - * `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a - * setext heading. The two are the same line to look at and mean the same thing to a reader, so - * they get one appearance rather than a lookback. One `=` is enough because a setext underline - * may be a single character; a break needs three, which keeps a `- ` bullet out of here. - */ - private fun thematicBreak(start: Int, end: Int): Boolean { - val marker = code[start] - if (marker !in RULE_MARKERS) return false - var seen = 0 - for (at in start until end) { - val character = code[at] - if (character == marker) seen++ else if (!character.isWhitespace()) return false - } - if (seen < if (marker == '=') 1 else 3) return false - emit(start, end, Kind.MARK) - return true - } - - /** Draws a list marker if the line opens with one, and answers where the item's text starts. */ - private fun bullet(start: Int, end: Int): Int { - val marker = code[start] - if (marker in BULLETS && spaceOrEnd(start + 1, end)) { - emit(start, start + 1, Kind.MARK) - return indented(start + 1, end) - } - var digits = start - while (digits < end && code[digits].isDigit()) digits++ - val delimiter = code.getOrNull(digits) - if ( - digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end) - ) { - emit(start, digits + 1, Kind.MARK) - return indented(digits + 1, end) - } - return start - } - - private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t' - - /** - * The inline forms, left to right. - * - * Every branch answers a position strictly after [start] of its call, so this terminates - * whether or not the form it was looking at turned out to be one. - */ - private fun inline(start: Int, end: Int) { - var at = start - while (at < end) { - val character = code[at] - at = - when { - // A backslash takes the character after it out of the running entirely, which - // is how `\*` stays an asterisk rather than opening emphasis. - character == '\\' -> at + 2 - character == '`' -> codeSpan(at, end) - character == '[' -> link(at, at, end) - character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end) - character == '<' -> autolink(at, end) - character in EMPHASIS -> emphasis(at, end) - else -> url(at, end) ?: (at + 1) - } - } - } - - /** - * `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what - * lets a span hold a backtick of its own, and why the search skips over a shorter or longer run - * rather than stopping at the first backtick. - */ - private fun codeSpan(start: Int, end: Int): Int { - var open = start - while (open < end && code[open] == '`') open++ - val ticks = open - start - var at = open - while (at < end) { - if (code[at] != '`') { - at++ - continue - } - var close = at - while (close < end && code[close] == '`') close++ - if (close - at == ticks) { - emit(start, close, Kind.STRING) - return close - } - at = close - } - // Nothing closes it on this line, so those were ordinary backticks. - return open - } - - /** - * `[text](destination)`, and the same with a leading `!` for an image. - * - * The text is drawn as prose -- it is what the reader reads -- so only the brackets around it - * are marked, and the destination is metadata. A `[text]` with no destination after it is left - * plain, because that is what a reference link and a bracketed aside look like. - */ - private fun link(start: Int, bracket: Int, end: Int): Int { - var depth = 0 - var close = bracket - while (close < end) { - when (code[close]) { - '\\' -> close++ - '[' -> depth++ - ']' -> { - depth-- - if (depth == 0) break - } - } - close++ - } - if (close >= end) return start + 1 - val destination = close + 1 - if (code.getOrNull(destination) != '(') return start + 1 - val paren = code.indexOf(')', destination) - if (paren < 0 || paren >= end) return start + 1 - emit(start, bracket + 1, Kind.MARK) - inline(bracket + 1, close) - emit(close, destination, Kind.MARK) - emit(destination, paren + 1, Kind.METADATA) - return paren + 1 - } - - /** - * `` and ``, drawn as the destination they are. - * - * The angle brackets have to hold no whitespace and something that makes an address of it -- a - * scheme's colon or an at sign -- which is what keeps an HTML tag out. - */ - private fun autolink(start: Int, end: Int): Int { - var at = start + 1 - var addressed = false - while (at < end) { - val character = code[at] - if (character.isWhitespace() || character == '<') return start + 1 - if (character == '>') { - if (!addressed) return start + 1 - emit(start, at + 1, Kind.METADATA) - return at + 1 - } - if (character == ':' || character == '@') addressed = true - at++ - } - return start + 1 - } - - /** - * A bare `scheme://…` written in prose, or null if one does not start here. - * - * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry. - * - * Where it ends is the part worth stating: the sentence's punctuation is not the address, so a - * trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the - * URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a - * table cell must not swallow the cell's edge. - */ - private fun url(start: Int, end: Int): Int? { - if (start > 0 && isWord(code[start - 1])) return null - var scheme = start - while (scheme < end && code[scheme].isLetter()) scheme++ - if (scheme == start || !code.startsWith("://", scheme)) return null - val body = scheme + 3 - var at = body - var openers = 0 - var closers = 0 - while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) { - if (code[at] == '(') openers++ else if (code[at] == ')') closers++ - at++ - } - while (at > body) { - val last = code[at - 1] - if (last in URL_TRAILING) at-- - else if (last == ')' && closers > openers) { - closers-- - at-- - } else break - } - if (at == body) return null - emit(start, at, Kind.METADATA) - return at - } - - /** - * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the - * token scanner draws a string: the quotes are part of the thing. - * - * The two guards keep this off code that happens to be in a paragraph: the opener must be - * followed by something to emphasise and the closer preceded by something emphasised, so `a * b - * * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start - * or end inside a word, or every `snake_case_name` would be half emphasised. - */ - private fun emphasis(start: Int, end: Int): Int { - val marker = code[start] - var open = start - while (open < end && code[open] == marker) open++ - val length = open - start - if (marker == '~' && length != 2) return open - if (length > 3) return open - if (open == end || code[open].isWhitespace()) return open - if (marker == '_' && start > 0 && isWord(code[start - 1])) return open - var at = open - while (at < end) { - if (code[at] == '\\') { - at += 2 - continue - } - if (code[at] != marker) { - at++ - continue - } - var close = at - while (close < end && code[close] == marker) close++ - val finish = at + length - if ( - close - at >= length && - !code[at - 1].isWhitespace() && - !(marker == '_' && finish < end && isWord(code[finish])) - ) { - emit(start, finish, Kind.LITERAL) - return finish - } - at = close - } - return open - } -} - -private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_' diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt deleted file mode 100644 index 117fccc..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -/** - * An assistant's reply, with anything it says it remembered drawn as a note rather than as markup. - * - * Claude Code marks a sentence that came from its stored memory by wrapping it in ``. Markdown has nothing to say about that, so it arrived on screen as literal - * angle brackets in the middle of a sentence -- which reads as the model having emitted broken - * HTML. It is really the opposite: a claim about where something came from, and "I was told this - * before" and "I worked this out just now" are different things the reader cannot otherwise tell - * apart. - * - * A tag that has not finished arriving is left alone: a half-written marker is not a marker yet. - */ -@Composable -fun AssistantMessage( - text: String, - replies: ParsedReplies, - /** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */ - openNotes: Set, - onToggleNote: (String) -> Unit, - modifier: Modifier = Modifier, - live: Boolean = false, -) { - DebugStats.count("message composed") - val parts = remember(text) { messageParts(text) } - val only = parts.singleOrNull() - if (only is MessagePart.Prose) { - MarkdownText(only.text, replies, modifier, live) - return - } - Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { - parts.forEach { part -> - when (part) { - is MessagePart.Prose -> MarkdownText(part.text, replies, live = live) - is MessagePart.Remembered -> - MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) } - } - } - } -} - -/** - * The pieces [AssistantMessage] draws, which is [splitMemoryNotes] with one correction. - * - * A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed - * prose part made while looking for them -- inspecting a message must not change it. That belongs - * here rather than at the places that need the answer, because [warm] has to name the same strings - * the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports. - * - * Public because [transcriptUnits] flattens settled replies into the same parts; go through - * [ParsedReplies.partsOf] on any path that runs per fold or per page. - */ -fun messageParts(text: String): List { - val parts = splitMemoryNotes(text) - return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts -} - -/** - * One sentence the model attributed to a memory file, closed until somebody asks. - * - * Closed by default, like a tool call and a peer message and for the same reason: it is not part of - * what was said to the reader, it is a note about where a claim came from. Left open it breaks the - * reply in half around a card, and these arrive several to a message. - * - * What stays visible is which file it came from, because that is the whole of what the note claims - * and the part a reader scanning for "why does it think that" is looking for. - * - * Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to - * still be open on the way back, and a card that remembered for itself would forget the moment the - * list stopped composing it. - */ -@Composable -fun MemoryNote( - note: MessagePart.Remembered, - replies: ParsedReplies, - expanded: Boolean, - onToggle: () -> Unit, -) { - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { - Column(Modifier.padding(12.dp)) { - // Named, not just tinted: a colour can say "this one is different", but it cannot say - // what kind of different, and "recalled from a file" is a difference in kind. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - if (note.files.size == 1) "remembered from ${note.files[0]}" - else "remembered from ${note.files.joinToString(", ")}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - if (!expanded) { - Spacer(Modifier.width(8.dp)) - Text( - note.text, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - // The head, not the tail: a sentence is identified by how it opens. - overflow = TextOverflow.Ellipsis, - ) - } - } - // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. - if (expanded) { - CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { - MarkdownText(note.text, replies, Modifier.padding(top = 4.dp)) - } - } - } - } -} - -/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */ -sealed class MessagePart { - /** The markdown this piece is drawn from. */ - abstract val text: String - - data class Prose(override val text: String) : MessagePart() - - data class Remembered(override val text: String, val files: List) : MessagePart() -} - -private val MEMORY_NOTE = - Regex("""(.*?)""", RegexOption.DOT_MATCHES_ALL) - -/** - * Splits [text] into prose and memory notes, in order. Always returns at least one part, so a - * message with no notes is one piece of prose and costs nothing extra to draw. - */ -fun splitMemoryNotes(text: String): List { - val parts = mutableListOf() - var at = 0 - for (match in MEMORY_NOTE.findAll(text)) { - val before = text.substring(at, match.range.first) - if (before.isNotBlank()) parts += MessagePart.Prose(before.trim()) - val files = match.groupValues[1].split(",").map { it.trim() }.filter { it.isNotEmpty() } - parts += MessagePart.Remembered(match.groupValues[2].trim(), files) - at = match.range.last + 1 - } - val rest = text.substring(at) - if (rest.isNotBlank() || parts.isEmpty()) parts += MessagePart.Prose(rest.trim()) - return parts -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt deleted file mode 100644 index bb887f1..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.aiapp - -/** - * What a session with no model of its own is called, in the button and in the list it opens. - * - * One constant rather than a literal in each place, because the two have to agree: a picker whose - * options cannot say every state its button can display is one you can leave and not get back to. - * It is also the Claude CLI's own word for "whatever is configured". - */ -const val DEFAULT_MODEL = "default" - -/** - * A model's name as a person reads it. - * - * Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude- - * haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using" - * and far too long for a button in a row that also holds Stop and Send. - * - * So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which - * is the same on every model this app can show, and the release date, which distinguishes builds of - * one model rather than one model from another. Anything that does not look like that is returned - * untouched. - * - * A display decision, not a correction: the full name is what the session reports. - */ -fun modelLabel(model: String?): String { - val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL - return name.removePrefix("claude-").replace(DATED_SUFFIX, "") -} - -/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */ -private val DATED_SUFFIX = Regex("""-\d{8}$""") diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt deleted file mode 100644 index 0521f9f..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt +++ /dev/null @@ -1,374 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Models on the backend, and HuggingFace to get more from. - * - * Everything here is the server's state rather than this screen's: what is downloaded, and what is - * downloading, are the same answers on every enrolled device, and a download started here keeps - * going when this screen closes. - */ -@Composable -fun ModelsScreen(settings: ServerSettings, reloadToken: Int) { - val scope = rememberCoroutineScope() - var state by remember { mutableStateOf>(LoadState.Loading) } - var query by remember { mutableStateOf("") } - var results by remember { mutableStateOf>?>(null) } - var openRepo by remember { mutableStateOf(null) } - var repoFiles by remember { mutableStateOf>?>(null) } - var actionError by remember { mutableStateOf(null) } - - suspend fun reload() { - state = - try { - withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) } - } catch (e: ApiException) { - LoadState.failed(e) - } - } - - // Polled rather than pushed: a download belongs to the machine, not to any session, so it has - // no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the - // loop with a read now rather than leaving the reader watching for a second and a half. - LaunchedEffect(reloadToken) { - while (true) { - reload() - delay(1500) - } - } - - Column(Modifier.fillMaxSize().padding(16.dp)) { - actionError?.let { - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - OutlinedTextField( - value = query, - onValueChange = { query = it }, - label = { Text("Search HuggingFace") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - TextButton( - enabled = query.isNotBlank(), - onClick = { - openRepo = null - results = LoadState.Loading - scope.launch { - results = - try { - withContext(Dispatchers.IO) { - LoadState.Loaded(searchModels(settings, query)) - } - } catch (e: ApiException) { - LoadState.failed(e) - } - } - }, - ) { - Text("Search") - } - - Spacer(Modifier.height(8.dp)) - LazyColumn(Modifier.fillMaxSize()) { - when (val current = state) { - is LoadState.Loading -> item { CircularProgressIndicator() } - is LoadState.Error -> - item { Text(current.message, color = MaterialTheme.colorScheme.error) } - is LoadState.Loaded -> { - if (current.value.downloads.isNotEmpty()) { - item { SectionLabel("Downloading") } - uniqueItems(current.value.downloads, key = { it.key + it.run }) { download - -> - DownloadCard(download) { - scope.launch { - actionError = - runCatching { - withContext(Dispatchers.IO) { - cancelDownload(settings, download.key) - } - } - .exceptionOrNull() - ?.message - } - } - } - } - item { SectionLabel("On the backend") } - if (current.value.local.isEmpty()) { - item { - Text( - "None yet. Search above to find one.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - uniqueItems(current.value.local, key = { it.key }) { model -> - LocalModelCard(model) { - scope.launch { - actionError = - runCatching { - withContext(Dispatchers.IO) { - deleteModel(settings, model.key) - } - } - .exceptionOrNull() - ?.message - reload() - } - } - } - } - } - - results?.let { found -> - item { SectionLabel("HuggingFace") } - when (found) { - is LoadState.Loading -> item { CircularProgressIndicator() } - is LoadState.Error -> - item { Text(found.message, color = MaterialTheme.colorScheme.error) } - is LoadState.Loaded -> - uniqueItems(found.value, key = { it.id }) { repo -> - val open = openRepo == repo.id - RepoRow(repo, expanded = open) { - if (open) { - openRepo = null - } else { - openRepo = repo.id - repoFiles = LoadState.Loading - scope.launch { - repoFiles = - try { - withContext(Dispatchers.IO) { - LoadState.Loaded( - fetchRepoFiles(settings, repo.id) - ) - } - } catch (e: ApiException) { - LoadState.failed(e) - } - } - } - } - // Inside the expanded repository's own item rather than as a section - // after the list: drawn after every card, a repository's files read as - // belonging to whichever card happened to be last. - if (open) { - when (val files = repoFiles) { - null -> {} - is LoadState.Loading -> CircularProgressIndicator() - is LoadState.Error -> - Text(files.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> - Column { - val busy = - (state as? LoadState.Loaded) - ?.value - ?.downloads - .orEmpty() - .filter { it.state == "running" } - .map { it.key } - .toSet() - files.value.forEach { file -> - RepoFileRow( - file, - downloading = "${repo.id}/${file.path}" in busy, - ) { - scope.launch { - actionError = - runCatching { - withContext(Dispatchers.IO) { - startDownload( - settings, - repo.id, - file.path, - ) - } - } - .exceptionOrNull() - ?.message - reload() - } - } - } - } - } - } - } - } - } - } - } -} - -@Composable -private fun SectionLabel(text: String) { - Spacer(Modifier.height(12.dp)) - Text(text, style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(4.dp)) -} - -@Composable -private fun DownloadCard(download: Download, onCancel: () -> Unit) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Column(Modifier.padding(12.dp)) { - Text(download.file, style = MaterialTheme.typography.titleSmall) - Text( - download.repo, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - // A determinate bar only when the size is known. The server sends no total when it was - // never told one, and a bar drawn from a guess is worse than one that admits it is - // counting. - if (download.total != null && download.total > 0) { - LinearProgressIndicator( - progress = { download.done.toFloat() / download.total.toFloat() }, - // Blue at every value, unlike a quota bar: a download nearing its end is - // nearing success, and colouring it like a limit being approached would say the - // opposite. - color = progressColor, - modifier = Modifier.fillMaxWidth(), - ) - Text( - "${gigabytes(download.done)} of ${gigabytes(download.total)}", - style = MaterialTheme.typography.bodySmall, - ) - } else { - LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth()) - Text( - "${gigabytes(download.done)} so far, total size unknown", - style = MaterialTheme.typography.bodySmall, - ) - } - download.error?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - Row { - Text( - download.state, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - ) - if (download.state == "running") { - TextButton(onClick = onCancel) { Text("Cancel") } - } - } - } - } -} - -@Composable -private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(model.file, style = MaterialTheme.typography.titleSmall) - Text( - "${model.repo} · ${gigabytes(model.bytes)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - TextButton(onClick = onDelete) { Text("Delete") } - } - } -} - -@Composable -private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text( - repo.id, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - // The owner is the part that repeats; the model name at the end is what tells - // two entries apart. - overflow = TextOverflow.StartEllipsis, - ) - Text( - "${repo.downloads} downloads · ${repo.likes} likes", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") } - } - } -} - -@Composable -private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) { - Row( - Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(file.path, style = MaterialTheme.typography.bodyMedium) - Text( - gigabytes(file.bytes), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // Disabled rather than absent, so the row reads the same whether this one is absent, - // already here, or on its way. Offering "Download" for a file that is downloading would be - // a button that does nothing anyone can see. - TextButton(enabled = !file.have && !downloading, onClick = onDownload) { - Text( - when { - file.have -> "Downloaded" - downloading -> "Downloading" - else -> "Download" - } - ) - } - } -} - -private fun gigabytes(bytes: Long): String = - if (bytes >= 1_000_000_000) { - "%.2f GB".format(bytes / 1_000_000_000.0) - } else { - "%.0f MB".format(bytes / 1_000_000.0) - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt deleted file mode 100644 index b7985bc..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ /dev/null @@ -1,270 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -/** - * The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets. - * - * Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's - * size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one - * set and kept in step by hand. - * - * This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the - * grounds that a system font may not have the glyph. That objection is about *relying* on a system - * font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here - * is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols - * font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that - * the script did not subset is a glyph that silently isn't there. - * - * The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall. - * That is what makes two icons the same size without either being given a size: the proportional - * face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came - * out visibly different widths. [GLYPH_SIZE] carries the cost. - * - * The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two - * Material Design codepoints. Those two must not drift. The script is copied rather than shared - * because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the - * point of subsetting is to ship only the codepoints one app draws. - */ -val NerdIcons = FontFamily(Font(R.font.nerd_icons)) - -/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */ -private fun glyph(codePoint: Int) = String(Character.toChars(codePoint)) - -/** `md-cog` -- settings for the thing it sits beside. */ -val SETTINGS_GLYPH = glyph(0xF0493) - -/** `md-refresh` -- ask the server again for whatever is on screen. */ -val REFRESH_GLYPH = glyph(0xF0450) - -/** `md-send` -- the filled paper plane: submit what is in the composer. */ -val SEND_GLYPH = glyph(0xF048A) - -/** - * `md-stop` -- a filled square: end the process behind this session. - * - * The square is what stop has meant since tape decks, and it is spent here on the thing that - * actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session. - */ -val STOP_GLYPH = glyph(0xF04DB) - -/** - * `md-pause` -- two bars: take the running turn away and leave the session there. - * - * The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what - * pressing it now would do to the process, and the three marks are the three answers. An interrupt - * ends a turn and nothing else, which is a pause, not a stop. - */ -val PAUSE_GLYPH = glyph(0xF03E4) - -/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */ -val PLAY_GLYPH = glyph(0xF040A) - -/** - * `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn. - * - * The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than - * starting one, and one glyph doing both jobs would promise something immediate and do something - * that waits. - */ -val QUEUE_GLYPH = glyph(0xF1163) - -/** `md-close` -- take this off again: an attachment picked and not wanted. */ -val CLOSE_GLYPH = glyph(0xF0156) - -/** `md-arrow_left` -- back one level, to whatever this was opened from. */ -val BACK_GLYPH = glyph(0xF004D) - -/** `md-bell` -- the notifications this session is allowed to raise. */ -val BELL_GLYPH = glyph(0xF009A) - -/** - * `fa-line_chart` -- how much of the account's rate limits is gone. - * - * Font Awesome's rather than Material's, which is the one break in the family above: it was asked - * for by name, and Material's chart glyphs are a bare line where this one has its axes. - */ -val USAGE_GLYPH = glyph(0xF201) - -/** - * `md-speedometer` -- what this session is costing to draw. - * - * A speedometer rather than a bug, because what it copies is a measurement rather than a fault - * report: it is as useful on a screen that feels fine, where the answer is that nothing is slow. - */ -val SPEED_GLYPH = glyph(0xF04C5) - -/** - * `md-folder` -- the files on the machine this session runs on. - * - * The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and - * the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is - * what makes the button say where it leads. - */ -val FOLDER_GLYPH = glyph(0xF024B) - -/** `md-file_outline` -- one file, in a listing beside the directories. */ -val FILE_GLYPH = glyph(0xF0224) - -/** `md-plus` -- make something here. dev-updater's codepoint as well. */ -val PLUS_GLYPH = glyph(0xF0415) - -/** `md-pencil` -- change what this file says, rather than only reading it. */ -val EDIT_GLYPH = glyph(0xF03EB) - -/** - * `md-content_save` -- write the edits back to the machine. - * - * The floppy disk, which is what save has meant for longer than most of the people reading it have - * been alive and is still the only mark anybody recognises for it. - */ -val SAVE_GLYPH = glyph(0xF0193) - -/** - * The size an icon draws at beside a line of text. - * - * 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at - * most 0.83 em of its point size, so the number was standing in for the headroom above the tallest - * one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every - * icon in the app up by a fifth. - */ -private val GLYPH_SIZE = 17.sp - -/** - * The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp. - */ -private val GLYPH_EXTENT = GLYPH_SIZE.value.dp - -/** - * The square a glyph button occupies: the mark, plus the same ring of padding on all four sides. - * - * The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to - * the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of - * its own. That is what it was: the box was the size of the mark (28dp) and the separation was - * bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the - * edge. - * - * 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has - * to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's - * own corners and beside a title it arrived at the first letter. And it is taller than any header's - * text, which is what lets the button fill a header row rather than sit in the middle of one. - */ -private val GLYPH_BUTTON_SIZE = 48.dp - -/** - * The ring itself, for putting something that is *not* a glyph button next to one -- a title beside - * a back arrow. - * - * Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text - * brings none, so the second ring has to be asked for -- without it the pressed-state circle - * arrives at the first letter of the title. - */ -val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2 - -/** - * A glyph you can press: the icon equivalent of a `TextButton`. - * - * Its own composable so that every icon button in the app is one size and one colour without each - * caller saying so, and so the [label] none of them displays is still there for a screen reader -- - * which is also the answer to "what was that button for" six months from now. - * - * [enabled] is passed through rather than left to callers hiding the button: a control that comes - * and goes makes its own absence the signal, and absence cannot say whether there was nothing to do - * or nobody checked. - */ -@Composable -fun GlyphButton( - glyph: String, - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - colour: Color = MaterialTheme.colorScheme.primary, -) { - MarkButton(label, onClick, modifier, enabled) { - Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) - } -} - -/** - * The same square, around a mark that is not a glyph. - * - * A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the - * size, spacing and touch target every other icon button already is. The caller still owes it a - * [label]: nothing here draws a word. - */ -@Composable -fun MarkButton( - label: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - mark: @Composable () -> Unit, -) { - IconButton( - onClick = onClick, - enabled = enabled, - modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, - ) { - mark() - } -} - -/** - * The square a glyph button occupies, with a spinner in it instead of a mark. - * - * For a button whose work is under way. It takes the button's whole box rather than the mark's, so - * swapping one for the other leaves everything in the row exactly where it was. - */ -@Composable -fun GlyphSpinner(label: String, modifier: Modifier = Modifier) { - Box( - contentAlignment = Alignment.Center, - modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, - ) { - CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp) - } -} - -/** - * One icon, drawn as text. - * - * Callers that are already inside something pressable use this; [GlyphButton] is the one that adds - * the press. Either way the caller owes it a description, since neither draws a word. - */ -@Composable -fun Glyph( - glyph: String, - modifier: Modifier = Modifier, - colour: Color = MaterialTheme.colorScheme.primary, - size: TextUnit = GLYPH_SIZE, -) { - // Line height of the point size, which for this font is the square the glyph draws in: its - // ascent and descent add up to exactly one em. Left to the inherited body style the line box - // was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than - // horizontal. - Text( - glyph, - fontFamily = NerdIcons, - fontSize = size, - lineHeight = size, - color = colour, - modifier = modifier, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt deleted file mode 100644 index 050b6eb..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt +++ /dev/null @@ -1,352 +0,0 @@ -package com.example.aiapp - -import android.Manifest -import android.app.Notification -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.content.pm.ServiceInfo -import android.net.Uri -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationChannelCompat -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.app.ServiceCompat -import androidx.core.content.ContextCompat -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL -import kotlin.concurrent.thread -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import org.json.JSONObject - -/** - * Telling somebody a session wants them, when they are not looking at the app. - * - * This is a **foreground service**, which on Android is the only way to keep a connection open - * while the app is closed -- there has been no such thing as a long-lived background service since - * Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it - * gets a push from Google's servers, which would mean this backend talking to Google about - * somebody's coding sessions, and the whole point of the tunnel is that it does not. - * - * The cost Android charges is a notification of its own that cannot be dismissed. That is made as - * quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows - * no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it - * cannot be and because it should not be: it is the honest indicator that something is holding a - * connection open. - */ -class NotificationService : Service() { - @Volatile private var stream: HttpURLConnection? = null - @Volatile private var stopping = false - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val settings = loadServerSettings(this) - if (settings == null) { - // Nothing to connect to. Stopping rather than idling: a service holding no connection - // still costs the ongoing notification, which would be announcing work that is not - // happening. - stopSelf() - return START_NOT_STICKY - } - // Through ServiceCompat so the type is stated once and ignored on the versions that predate - // types, rather than branching here. - ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType()) - thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) } - // Restarted if Android kills it, which is the whole point: the window this covers is - // exactly the one where nobody is watching. - return START_STICKY - } - - override fun onDestroy() { - stopping = true - stream?.disconnect() - } - - /** - * Follows the backend's notification stream, reconnecting until stopped. - * - * A dropped connection is the ordinary case here rather than an error, so it retries quietly - * and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell - * you whether anything happened" is noise about a condition nobody can act on, and the session - * list already says what is waiting when they next look. - */ - private fun follow(settings: ServerSettings) { - while (!stopping) { - try { - readStream(settings) - } catch (_: IOException) { - // Deliberate: see above. - } - if (stopping) return - try { - Thread.sleep(RECONNECT_DELAY_MS) - } catch (_: InterruptedException) { - return - } - } - } - - private fun readStream(settings: ServerSettings) { - val connection = - URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection - stream = connection - try { - connection.applyPinnedTls() - connection.connectTimeout = CONNECT_TIMEOUT_MS - // No read timeout, for the reason EventStream gives: between notifications there is - // nothing to read, possibly for hours. - connection.readTimeout = 0 - connection.setRequestProperty("Authorization", "Bearer ${settings.token}") - connection.setRequestProperty("Accept", "text/event-stream") - if (connection.responseCode != 200) { - throw IOException("HTTP ${connection.responseCode} for the notification stream") - } - val reader = connection.inputStream.bufferedReader() - val data = StringBuilder() - while (!stopping) { - val line = reader.readLine() ?: break - when { - line.isEmpty() -> { - if (data.isNotEmpty()) show(parseNotification(data.toString())) - data.clear() - } - line.startsWith("data:") -> data.append(line.removePrefix("data:").trim()) - else -> {} // comments (keep-alives) and ids: nothing to do - } - } - } finally { - connection.disconnect() - stream = null - } - } - - /** - * One notification per session, replacing that session's previous one. - * - * Keyed by session id rather than accumulating: two sessions wanting attention are two things - * to know about, but one session that finished and then asked a question is one thing -- the - * question. A stack of stale rows is how a drawer becomes something to clear rather than read. - */ - private fun show(notification: SessionNotification) { - // Nothing to tell somebody about the session they are reading. The transcript in front of - // them is already saying it. - if (isOnScreen(notification.sessionId)) return - // The app is up: it says this itself, as a banner over whatever screen they are on. Never - // both -- one thing happened, and a drawer filling up behind an app that already showed you - // each one is a drawer nobody reads. - if (handOver(notification)) return - val manager = NotificationManagerCompat.from(this) - // Two different noes, and both are answers rather than faults: the runtime permission - // refused, and notifications switched off for the app in Android's own settings. - // - // The permission only exists from Android 13. Asking an older version about it gets - // "denied" for a name it does not know, which read as the person having said no -- so every - // notification on Android 12 and below was silently dropped. - val allowed = - Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - if (!allowed || !manager.areNotificationsEnabled()) { - return - } - val open = - PendingIntent.getActivity( - this, - 0, - sessionIntent(this, notification.sessionId), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val built = - NotificationCompat.Builder(this, ALERT_CHANNEL) - .setContentTitle(notification.title) - .setContentText(attentionLine(notification.kind)) - .setSmallIcon(android.R.drawable.stat_notify_chat) - .setContentIntent(open) - .setAutoCancel(true) - .setWhen((notification.at * 1000).toLong()) - .setShowWhen(true) - .build() - manager.notify(notification.sessionId, ALERT_ID, built) - } - - /** - * The type Android 14+ requires a foreground service to declare, and nothing before it. - * - * Named behind a version check rather than passed as a constant: the value is inlined at - * compile time and would be handed to platforms that have no concept of it, which is what - * lint's InlinedApi exists to catch. - */ - private fun foregroundType(): Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - } else { - 0 - } - - private fun ongoingNotification(): Notification = - NotificationCompat.Builder(this, ONGOING_CHANNEL) - .setContentTitle("Watching for sessions that need you") - .setSmallIcon(android.R.drawable.stat_notify_sync) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_MIN) - .build() - - companion object { - /** - * Starts the service if there is a server to connect to, and stops it otherwise. - * - * Called on every launch rather than once: a service Android killed does not restart itself - * if the process was replaced, and asking for one that is already running is free. - */ - fun sync(context: Context) { - val intent = Intent(context, NotificationService::class.java) - if (loadServerSettings(context) == null) { - context.stopService(intent) - return - } - createChannels(context) - ContextCompat.startForegroundService(context, intent) - } - - /** - * Two channels, because they are two different things to be told. - * - * The alerts are what somebody turned this on for, so they get the default importance. The - * ongoing one is the platform's tax for staying connected, so it takes the lowest - * importance that exists. Both are created before the service starts, since posting to a - * channel that does not exist is silently dropped. - */ - private fun createChannels(context: Context) { - val manager = NotificationManagerCompat.from(context) - manager.createNotificationChannel( - NotificationChannelCompat.Builder( - ALERT_CHANNEL, - NotificationManagerCompat.IMPORTANCE_DEFAULT, - ) - .setName("Sessions needing attention") - .build() - ) - manager.createNotificationChannel( - NotificationChannelCompat.Builder( - ONGOING_CHANNEL, - NotificationManagerCompat.IMPORTANCE_MIN, - ) - .setName("Staying connected") - .build() - ) - } - - /** - * The session somebody is looking at, or null when no screen is showing one. - * - * Process-wide state, which the rest of this app does without: Android constructs the - * service and the composition draws the screen, so the two have no common owner. Clearing - * names the session rather than setting null outright, because moving from one session to - * another composes the new screen before the old one's coroutine is cancelled -- an - * unconditional clear would throw away the new screen's claim. - */ - @Volatile private var onScreen: String? = null - - private fun isOnScreen(sessionId: String) = onScreen == sessionId - - /** - * The way a notification reaches the app instead of Android's drawer. - * - * Whether there is an app to reach is the subscriber count rather than a flag of its own: - * [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends - * nor blocks the thread reading the stream, and the buffer is there so a handful of - * sessions finishing together all land rather than the last one winning. - */ - private val toApp = MutableSharedFlow(extraBufferCapacity = 8) - - /** Everything meant for the screen rather than the drawer; see [toApp]. */ - val forTheScreen: SharedFlow = toApp.asSharedFlow() - - private fun handOver(notification: SessionNotification) = - toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification) - - /** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */ - fun showing(context: Context, sessionId: String) { - onScreen = sessionId - // Whatever was posted about it before is about to be read, so it has nothing left to - // say. - NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID) - } - - /** They have stopped, unless another screen has claimed it since. */ - fun stoppedShowing(sessionId: String) { - if (onScreen == sessionId) onScreen = null - } - - private const val ALERT_CHANNEL = "sessions" - private const val ONGOING_CHANNEL = "connection" - private const val ONGOING_ID = 1 - /** Shared by every alert; the session id is the tag that separates them. */ - private const val ALERT_ID = 2 - private const val RECONNECT_DELAY_MS = 5_000L - } -} - -/** - * The intent that opens one session, and the id it carries back out. - * - * The two halves are written together so neither can be changed without the other, and the scheme - * is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look - * at. - * - * The id rides in the intent's **data** rather than in an extra, which is not a style choice: - * PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring - * extras. Carried as an extra, every session's notification would update one shared PendingIntent - * and every tap would open whichever session was notified last. - */ -fun sessionIntent(context: Context, sessionId: String): Intent = - Intent(context, MainActivity::class.java) - .setAction(Intent.ACTION_VIEW) - .setData( - // Built rather than concatenated so an id needing escaping survives the round trip; - // lastPathSegment below decodes what appendPath encoded. - Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build() - ) - -/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */ -fun notifiedSessionId(uri: Uri): String? = - if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null - -/** One frame of `GET /notifications`. */ -data class SessionNotification( - val sessionId: String, - val title: String, - /** The wire's word: "awaitingInput" or "finished". */ - val kind: String, - val at: Double, -) - -/** - * What a notification asks of the reader, in the words they see. - * - * What they have to do, not what the session did: "awaitingInput" is the wire's word and says - * nothing to somebody reading a lock screen. One function because the same fact is shown in two - * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. - */ -fun attentionLine(kind: String): String = - when (kind) { - "awaitingInput" -> "Waiting for you" - else -> "Finished" - } - -fun parseNotification(json: String): SessionNotification { - val body = JSONObject(json) - return SessionNotification( - sessionId = body.getString("sessionId"), - title = body.getString("title"), - kind = body.getString("kind"), - at = body.optDouble("at", 0.0), - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt deleted file mode 100644 index f70e66b..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp - -/** - * A message another agent sent this session, closed until somebody asks. - * - * Closed by default, like a tool call and for the same reason: these are long, there can be several - * in a row, and what a reader scanning the transcript needs from one is that it happened and who - * sent it. The first line comes with the heading because a name alone does not say which message - * this was. - * - * Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a - * transcript that puts it in their voice is making a claim about who asked for the work that - * follows. - * - * Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block, - * each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought; - * what matters here is that the pieces have to add up to the card that was there before, so the - * fill, the corner radius and the padding all live in [peerSurface]. - */ -@Composable -fun PeerHeadRow( - item: TranscriptItem.PeerNote, - open: Boolean, - onToggle: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier.cardPiece( - top = true, - bottom = !open, - fill = CardDefaults.cardColors().containerColor, - onPress = onToggle, - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) - if (!open) { - Spacer(Modifier.width(8.dp)) - Text( - item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - // The head, not the tail: a message is identified by how it opens. - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -/** - * One block of an opened peer message, on the same card the heading started. - * - * Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control - * before it was several items, and which piece the finger lands on is not something the reader - * chose. - */ -@Composable -fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) { - Column( - Modifier.cardPiece( - top = false, - bottom = unit.last, - fill = CardDefaults.cardColors().containerColor, - onPress = onToggle, - ) - ) { - // The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap]. - // Without this the card closes everywhere except on the text, which is most of it. - CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) { - // The gap the card's own column used to provide between its heading and its prose, and - // between one block and the next -- inside the piece, so the card's fill runs through - // it. - MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing)) - } - } -} - -/** - * One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it. - * - * A filled Material card is elevation zero, so there is no shadow that a seam would show through -- - * which is the whole reason a card can be cut up at all. Each piece paints the caller's container - * colour and rounds only the corners at the ends of the message, so the pieces abut into one - * continuous card. Shared by the two rows cut this way -- an opened peer message and a long user - * message -- because two copies of the corner logic is how one of them grows a seam. - * - * The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top - * of it, the bottom piece the bottom, and the middle pieces neither. - */ -@Composable -fun Modifier.cardPiece( - top: Boolean, - bottom: Boolean, - fill: Color, - onPress: (() -> Unit)? = null, -): Modifier { - val square = CornerSize(0.dp) - val shape = - MaterialTheme.shapes.medium.copy( - topStart = if (top) MaterialTheme.shapes.medium.topStart else square, - topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square, - bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square, - bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square, - ) - return fillMaxWidth() - .clip(shape) - .background(fill) - .then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress)) - .padding( - start = CARD_PADDING, - end = CARD_PADDING, - top = if (top) CARD_PADDING else 0.dp, - bottom = if (bottom) CARD_PADDING else 0.dp, - ) -} - -/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */ -private val CARD_PADDING = 12.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt deleted file mode 100644 index 3a0c5e2..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt +++ /dev/null @@ -1,162 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -/** - * What is about to be sent, directly above the box it will be sent from. - * - * The count on the "+" button was the whole of what said an image was attached, so the only way to - * find out *which* image was to send it. A control belongs with the thing it acts on. - * - * Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is - * in it, so four attachments look like four of the same thing rather than four smaller ones. - */ -@Composable -fun PendingAttachments( - settings: ServerSettings, - sessionId: String, - refs: List, - onRemove: (String) -> Unit, - modifier: Modifier = Modifier, -) { - if (refs.isEmpty()) return - Row( - modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - refs.forEach { ref -> - if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) } - else PendingFile(ref) { onRemove(ref) } - } - } -} - -/** - * One attachment, square, tap to take it back off. - * - * Removal is here because there is nowhere else it could be: an image picked by mistake could - * otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a - * corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip. - */ -@Composable -private fun PendingThumbnail( - settings: ServerSettings, - sessionId: String, - ref: String, - onRemove: () -> Unit, -) { - val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) - val shape = RoundedCornerShape(8.dp) - Box( - Modifier.size(THUMBNAIL) - .clip(shape) - // An outline as well as a fill. Most of what gets attached here is a screenshot of a - // dark app, and cropped to a square its middle is often near-black -- against this - // background the tile then had no edge at all. - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) - // Behind the picture as well as under a missing one, so the tile is a tile before - // anything has arrived to fill it. - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = onRemove) - .semantics { contentDescription = "Attached image, tap to remove" }, - contentAlignment = Alignment.Center, - ) { - when (val image = bitmap) { - // The two are told apart for the same reason the transcript's images are: one of them - // is worth waiting for and the other never resolves. - null -> - if (failed) { - Text( - "!", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - // A spinner, as the transcript's images have: one appearance for "a picture is - // on its way", learned once. An ellipsis had to be read as a spinner not - // moving. - CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - } - else -> - Image( - bitmap = image, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(THUMBNAIL), - ) - } - // The whole square removes it, and this only says so. A cross small enough to sit in the - // corner of a 64dp thumbnail is smaller than a fingertip. - // - // The disc is sized here and the mark centred inside it, rather than the glyph being - // aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to - // the corner hung the visible mark over the edge. - Box( - Modifier.align(Alignment.TopEnd) - .padding(2.dp) - .size(20.dp) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape), - contentAlignment = Alignment.Center, - ) { - Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp) - } - } -} - -/** - * One attached file: its name, tap to take it back off. The same height and removal as a thumbnail, - * so a row of mixed attachments is one row; the cross sits after the name because a tile this wide - * has no corner the eye goes to. - */ -@Composable -private fun PendingFile(ref: String, onRemove: () -> Unit) { - val name = attachmentName(ref) - val shape = RoundedCornerShape(8.dp) - Row( - Modifier.height(THUMBNAIL) - .clip(shape) - .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .clickable(onClick = onRemove) - .semantics { contentDescription = "Attached file $name, tap to remove" } - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH)) - Spacer(Modifier.width(6.dp)) - Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp) - } -} - -private val THUMBNAIL = 64.dp - -/** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */ -private val FILE_TILE_WIDTH = 200.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt deleted file mode 100644 index be44e7e..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.example.aiapp - -import com.example.wgapplink.PinnedTls -import java.net.HttpURLConnection - -// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the -// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the -// private key that signs against it must never be anywhere this repo is, and an APK should pin -// whatever CA the backend it was built for actually serves. -// -// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What -// stays here is which certificate this app pins. -private val pinned = PinnedTls(PINNED_CA_PEM) - -/** Every request this app makes goes through this -- there is no unpinned path. */ -fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt deleted file mode 100644 index 2b3df2c..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp - -/** - * Verbatim text, on the surface that says so: a command about to be run, what a tool printed. - * - * A composable rather than a modifier repeated at each site, because the inset is part of it -- - * monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three - * copies of "clip, fill, pad" drift apart the first time one is adjusted. - * - * The colour is [rawSurface], which is also what a code block inside a reply is given. - */ -@Composable -fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { - Column( - modifier - .fillMaxWidth() - // Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded - // rectangle drawn at the same radius as the one behind it reads as a misprint. - .clip(MaterialTheme.shapes.extraSmall) - .background(rawSurface) - .padding(horizontal = 8.dp, vertical = 6.dp), - content = content, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt deleted file mode 100644 index 4316b71..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.example.aiapp - -import java.time.Duration -import java.time.OffsetDateTime - -// How long is left in a usage window. Shared by the session bar and the usage screen: the -// arithmetic is the same in both, so everything here returns the span or the state on its own and -// leaves the wording to the caller. - -/** - * "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words. - * - * Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s - * left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the - * figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot - * round a shared measurement two different ways. - */ -fun formatSpan(until: Duration): String { - val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1) - return when { - up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h" - up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m" - else -> "${up.toMinutes()}m" - } -} - -/** - * What is known about when a usage window ends. - * - * Three answers rather than a nullable duration, because two of them shared `null` and they are not - * the same thing at all. A window the server sent no reset time for is one that is **not running**: - * the five-hour window is anchored to the block it started in, so between sessions there is nothing - * counting down and the API says so by omitting the field. A timestamp that did arrive and could - * not be read is the genuinely unknown case. - * - * Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on - * the one row somebody reads before starting something big -- and the usage dialog, looking at the - * same field, quietly drew nothing. - */ -sealed class WindowEnd { - /** No reset time was sent, so nothing is running in this window. Not a failure to find out. */ - data object NotRunning : WindowEnd() - - /** A timestamp arrived and could not be read. The one case that is actually unknown. */ - data object Unreadable : WindowEnd() - - /** How long is left. Negative once the window is past, which each caller words for itself. */ - data class Ends(val until: Duration) : WindowEnd() -} - -/** - * [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now]. - * - * [now] is a parameter rather than read here so a caller can drive it from state and have the - * countdown recompute on its own schedule. - */ -fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd { - if (resetsAt == null) return WindowEnd.NotRunning - return try { - WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt))) - } catch (_: Exception) { - WindowEnd.Unreadable - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt deleted file mode 100644 index 140b009..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import androidx.core.content.edit - -private const val ANCHORS = "session-scroll" - -/** - * Where a session's transcript was left, so reopening it lands where reading stopped. - * - * Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by - * the row key the list draws with. An index means nothing across a reopen, since the transcript is - * fetched newest-first. The row key looks stable and is not: a tool row is named after its run, - * `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest - * page started with -- so an active session renames its tool runs every time it is reopened. A seq - * is the server's own numbering, assigned once and never moved. - * - * [unit] is which unit of the row the viewport started at and [offset] how far that unit was - * scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can - * be forty blocks long. - */ -data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0) - -/** - * On this device rather than on the backend, which is where this app otherwise keeps state so every - * device sees it. Scroll position is the same exception a draft is: it is where the phone in - * somebody's hand is pointed. - */ -fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? { - val stored = - context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null) - ?: return null - val fields = stored.split(':') - val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null - val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null - // Positions saved before the unit was recorded name the row's oldest unit, which is the closest - // older place -- the same choice [unitIndexFor] makes when a unit is gone. - return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0) -} - -/** - * Records where [sessionId] is being read, or forgets it when [anchor] is null. - * - * The path out is reading to the newest end, which is what the caller passes null for: a session - * left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its - * key behind, for the reason and at the cost `Drafts.kt` describes. - */ -fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) { - context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit { - if (anchor == null) remove(sessionId) - else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}") - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt deleted file mode 100644 index 9947569..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import android.net.Uri -import com.example.wgapplink.ServerStore - -/** - * Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by - * scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity) - * or by typing the fields into the settings screen. - */ -typealias ServerSettings = com.example.wgapplink.ServerSettings - -/** - * This app's enrollment, which is the whole of what is product-specific about it. - * - * Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev - * Updater, and the key alias names the Android Keystore key the token is already sealed under on - * every enrolled phone -- changing it would leave those phones reading as not enrolled. - */ -private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key") - -fun loadServerSettings(context: Context): ServerSettings? = store.load(context) - -fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings) - -fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt deleted file mode 100644 index 611bf79..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.example.aiapp - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue -import androidx.compose.material3.Text -import androidx.compose.material3.rememberSwipeToDismissBoxState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle - -/** - * A session wanting attention, said over the app rather than through Android's drawer. - * - * Two places can carry the same fact and only one is right at a time. A row in the shade is for - * somebody looking at something else: it makes a sound, it waits however long it has to, and acting - * on it means leaving whatever they were doing. Somebody with this app open needs none of that. So - * while these are on screen the stream is delivered here instead, which is arranged by the - * collection below and nothing else. - * - * A banner can go three ways, each somebody deciding something different: tapped, which opens the - * session; pushed off either side; or left alone, in which case it goes when the bar runs out. - */ -@Composable -fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) { - val queue = remember { mutableStateListOf() } - // What tells two notifications about one session apart, and what a replaced banner gets a new - // one of so its timer starts again rather than inheriting the remains of the last one's. - var arrivals by remember { mutableIntStateOf(0) } - val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(lifecycleOwner) { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - try { - NotificationService.forTheScreen.collect { notification -> - arrivals++ - val alert = SessionAlert(notification, arrivals) - // One banner per session, replacing that session's own -- the same rule the - // drawer follows: a session that finished and then asked a question is one - // thing to know about, the question. It keeps its place in the queue rather - // than moving to the end, because the reader may already be reaching for it. - val already = queue.indexOfFirst { - it.notification.sessionId == notification.sessionId - } - if (already >= 0) queue[already] = alert else queue.add(alert) - } - } finally { - // Leaving the app hands the job back to the drawer, so nothing arriving while it is - // away is lost. What would be lost is the truth of what is already up: these say a - // session wants somebody *now*, and one still sitting here on a return several - // minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock - // with the window. - queue.clear() - } - } - } - // Oldest at the top, so a new one appears below the ones already being read instead of shoving - // them down the screen mid-reach. - Column(modifier.fillMaxWidth().padding(8.dp)) { - queue.forEach { alert -> - key(alert.arrival) { - AlertBanner( - alert = alert, - onOpen = { - queue.remove(alert) - onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival)) - }, - onGone = { queue.remove(alert) }, - ) - } - } - } -} - -/** One notification queued for the screen, with the arrival that tells it from its predecessor. */ -private data class SessionAlert(val notification: SessionNotification, val arrival: Int) - -/** - * One banner: what wants attention, and how long this has left to say so. - * - * The bar and the going away are one value rather than a bar beside a timer, because two of them - * would be two accounts of the same countdown and only one can be the one that fires. - */ -@Composable -private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) { - val swipe = rememberSwipeToDismissBoxState() - val life = remember { Animatable(1f) } - LaunchedEffect(Unit) { - life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing)) - onGone() - } - // Settled is "still where it started"; anything else is a push that carried far enough for the - // gesture to commit, which the platform decides rather than this screen. - LaunchedEffect(swipe.currentValue) { - if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone() - } - SwipeToDismissBox( - state = swipe, - // Nothing behind it. Pushing one of these away means the same thing whichever way it went, - // so a coloured ground with an icon would be drawing a distinction that isn't there. - backgroundContent = {}, - modifier = Modifier.padding(bottom = 8.dp), - ) { - Card( - onClick = onOpen, - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh - ), - // Outlined, because the step it needs to make is not one this palette can make with a - // tint: the card under a banner on the session list is the same surface, so a banner - // relying on colour alone reads as one more row in the way. The border is the one cue. - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), - ) { - Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) { - Text( - alert.notification.title, - style = MaterialTheme.typography.titleSmall, - // One line, cut at the tail: a session is identified by the start of its name, - // and a banner that grew with the name would move the one below it. - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - attentionLine(alert.notification.kind), - style = MaterialTheme.typography.labelLarge, - // The list's own colour for a session waiting on a person, so the banner and - // the row behind it are saying one thing rather than two. - color = - if (alert.notification.kind == "awaitingInput") awaitingColor - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - LinearProgressIndicator( - progress = { life.value }, - // Blue because it is reporting how much of something is left rather than passing - // judgement on it. Stated beside the track, which is the card's own colour so that - // the spent part reads as empty rather than as a second bar. - color = progressColor, - trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, - drawStopIndicator = {}, - gapSize = 0.dp, - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -/** - * How long a banner stays if nobody touches it. - * - * Long enough to read a session name and a line, short enough that a stack of them clears itself - * while somebody is still on the screen that produced them. The bar makes the number visible. - */ -private const val ALERT_LIFE_MS = 6_000 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt deleted file mode 100644 index e440b1c..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt +++ /dev/null @@ -1,265 +0,0 @@ -package com.example.aiapp - -import android.graphics.BitmapFactory -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTransformGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.FilterQuality -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * One image from the session's files route: the bitmap once it arrives, and whether it never will. - * - * [failed] exists because the two empty states differ in kind -- still coming and never coming -- - * and a reader can act on the second; each caller supplies its own words for them. - */ -data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean) - -/** - * Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling - * does not refetch. Shared by the transcript's images and the composer's pending attachments, - * because the fetch, the decode and the two-state answer are one block of logic that had been - * written twice. - */ -@Composable -fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap { - var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) } - LaunchedEffect(ref) { - state = - try { - val bytes = - withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) } - val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() - SessionBitmap(decoded, failed = decoded == null) - } catch (_: ApiException) { - SessionBitmap(null, failed = true) - } - } - return state -} - -/** - * An image in the transcript: a fixed-height thumbnail that opens full screen. - * - * The height is decided before the bytes arrive and never changes. An image row that grew when it - * finished loading pushed everything below it, so a transcript being read scrolled itself -- and in - * a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes. - * - * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather - * than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the - * screen, which draws [SessionImageViewer] outside the list. - */ -@Composable -fun SessionImage( - settings: ServerSettings, - sessionId: String, - ref: String, - onOpen: (String) -> Unit, -) { - val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) - val height = thumbnailHeight() - val heightPx = with(LocalDensity.current) { height.roundToPx() } - Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { - when (val image = bitmap) { - // Two states, not one: an image still arriving and an image that will never arrive look - // nothing alike to a reader who can do something about the second. - null -> - if (failed) { - Text( - "[image $ref unavailable]", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - LoadingImage(height) - } - else -> - Image( - bitmap = image, - contentDescription = "Attached image, tap to view full screen", - contentScale = ContentScale.Fit, - filterQuality = enlargingFilter(image.height, heightPx), - modifier = Modifier.fillMaxSize().clickable { onOpen(ref) }, - alignment = Alignment.CenterStart, - ) - } - } -} - -/** - * The image somebody opened, drawn by the screen rather than by the row it was tapped in. - * - * The row is the wrong place to hold this, and it took a real fault to see why: an image from a - * `Read` on its own is a row of one call, and the moment the next call arrives the two become a - * group -- a different composable in a different part of the tree, so everything the old subtree - * remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the - * transcript because the session made another tool call. - * - * Held by the screen, none of that reaches it: what is open is a property of the screen. - * - * The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go - * through. Paid deliberately: it is one request for a picture somebody asked to see. - */ -@Composable -fun SessionImageViewer( - settings: ServerSettings, - sessionId: String, - ref: String, - onClose: () -> Unit, -) { - val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose), - contentAlignment = Alignment.Center, - ) { - when (val image = bitmap) { - // Two states, not one, exactly as the thumbnail has them. Stated in white because - // this box paints its own black behind them and a theme colour would be picked - // against a surface that is not there. - null -> - if (failed) { - Text( - "Image $ref is unavailable", - color = Color.White, - style = MaterialTheme.typography.bodyMedium, - ) - } else { - // The whole dialog is the area this picture is about to fill, so the - // spinner sits in the middle of it. White for the same reason the words - // beside it are. - CircularProgressIndicator(color = Color.White) - } - else -> ZoomableImage(image) - } - } - } -} - -/** - * The room a picture is about to take, with a spinner in the middle of it. - * - * A square of the row's own height rather than the full width of the transcript: the height is what - * [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width - * placeholder would promise a picture wider than most turn out to be. - * - * Tinted, so the reader can see that something is being kept for a picture -- which is also what - * distinguishes it from the failure beside it, words on the ordinary surface. - */ -@Composable -private fun LoadingImage(height: Dp) { - Box( - Modifier.size(height) - .clip(MaterialTheme.shapes.small) - .background(MaterialTheme.colorScheme.surfaceContainerHigh), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp) - } -} - -/** Small enough to sit inside the thumbnail's square without filling it. */ -private val LOADING_SPINNER = 24.dp - -/** - * Four lines of the body style the transcript is set in. - * - * Measured from the type rather than written as a dp, so it stays four lines when the text size - * changes -- including when the reader has scaled fonts up, which is when a hardcoded height is - * wrong. - */ -@Composable -private fun thumbnailHeight(): Dp { - val line = MaterialTheme.typography.bodyLarge.lineHeight - val density = LocalDensity.current - return remember(line, density) { - with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp } - } -} - -/** - * Nearest neighbour when the image is being enlarged, smooth when it is being shrunk. - * - * A small image blown up with interpolation turns into a blur that hides what it is -- the same - * image with hard pixel edges stays readable. Shrinking wants the opposite. - */ -private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality = - if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High - -/** - * The image on its own, as large as it fits, with pinch to zoom. - * - * Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back - * gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image - * visible. - */ -@Composable -private fun ZoomableImage(image: ImageBitmap) { - var scale by remember { mutableFloatStateOf(1f) } - var offsetX by remember { mutableFloatStateOf(0f) } - var offsetY by remember { mutableFloatStateOf(0f) } - Image( - bitmap = image, - contentDescription = "Attached image", - contentScale = ContentScale.Fit, - // Zoomed in, the reader is looking at pixels on purpose. - filterQuality = FilterQuality.None, - modifier = - Modifier.fillMaxSize() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - // Floor of 1 so the image cannot be pinched smaller than fitted, which is - // already the whole of it; a ceiling so it cannot be lost off-screen. - scale = (scale * zoom).coerceIn(1f, 8f) - if (scale > 1f) { - offsetX += pan.x - offsetY += pan.y - } else { - offsetX = 0f - offsetY = 0f - } - } - } - .graphicsLayer { - scaleX = scale - scaleY = scale - translationX = offsetX - translationY = offsetY - }, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt deleted file mode 100644 index 84192eb..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ /dev/null @@ -1,530 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox. - * - * No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one. - * What stays here is the button that adds a session, because that acts on this list and nothing - * else. - */ -@Composable -fun SessionListScreen( - settings: ServerSettings, - reloadToken: Int, - onOpen: (SessionSummary) -> Unit, - /** Opens one session's subagent, from the expander under its card. */ - onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit, - onSpawn: () -> Unit, -) { - val scope = rememberCoroutineScope() - var listState by remember { mutableStateOf>>(LoadState.Loading) } - var confirmingDelete by remember { mutableStateOf(null) } - - // Which session cards are expanded to show their subagents, and what each expansion fetched. - // Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt - // from - // whatever the server last said, and this belongs to the reader's own choice, which survives a - // refresh. - var expandedSessions by remember { mutableStateOf(setOf()) } - var subagentLoads by remember { - mutableStateOf(mapOf>>()) - } - - fun loadSubagents(sessionId: String) { - subagentLoads = subagentLoads + (sessionId to LoadState.Loading) - scope.launch { - subagentLoads = - subagentLoads + - (sessionId to - try { - LoadState.Loaded( - withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) } - ) - } catch (e: ApiException) { - LoadState.failed(e) - }) - } - } - - // Failures that belong to one session rather than to the list, keyed by its id and shown on its - // own card. The two scopes are decided by whether the server answered: it answered and refused, - // so this says nothing about the other rows. - // - // Cleared on the next successful load below -- an entry outlives its session otherwise. - var deleteErrors by remember { mutableStateOf>(emptyMap()) } - - // Which sessions have a delete in flight. A set of ids rather than a flag on the row, because - // the rows are rebuilt from whatever the server last said and this belongs to the request. - var deleting by remember { mutableStateOf>(emptySet()) } - - // This phone's copies of these sessions' transcripts, pruned from here because this is where a - // session stops existing. See TranscriptCache. - val context = LocalContext.current - val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } - - fun refresh() { - listState = LoadState.Loading - scope.launch { - listState = - try { - val loaded = - withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } - deleteErrors = emptyMap() - // The path out for a cached transcript whose session was deleted somewhere - // else. This list is the only place that ever learns the full set. On the - // answer rather than in `finally`: a list that failed to arrive says nothing - // about which sessions exist. - withContext(Dispatchers.IO) { - transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) - } - // A session gone from this answer cannot still be expanded, and an expanded one - // that is still here asks again -- its subagents may have changed since the - // last - // fetch. - val ids = loaded.value.map { it.id }.toSet() - expandedSessions = expandedSessions intersect ids - subagentLoads = subagentLoads.filterKeys { it in ids } - expandedSessions.forEach(::loadSubagents) - loaded - } catch (e: ApiException) { - LoadState.failed(e) - } - } - } - - LaunchedEffect(reloadToken) { refresh() } - - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize().padding(16.dp)) { - when (val state = listState) { - is LoadState.Loading -> CircularProgressIndicator() - // The message as Api.kt wrote it, with nothing added: it is already a whole - // sentence naming the address and what to check, so a prefix here read "Couldn't - // reach the server: Couldn't reach the server at ...". - is LoadState.Error -> - Text( - state.message, - color = MaterialTheme.colorScheme.error, - ) - is LoadState.Loaded -> { - if (state.value.isEmpty()) { - Text( - "No sessions. Tap + to spawn one.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - // Awaiting-answer first (the point of the screen), then most recently active. - val ordered = - state.value.sortedWith( - compareByDescending { it.status == "awaitingInput" } - .thenByDescending { it.lastActivity } - ) - LazyColumn { - uniqueItems(ordered, key = { it.id }) { session -> - SessionCard( - session = session, - error = deleteErrors[session.id], - deleting = session.id in deleting, - onOpen = { onOpen(session) }, - onLongPress = { confirmingDelete = session }, - expanded = session.id in expandedSessions, - subagents = subagentLoads[session.id], - onToggleSubagents = { - if (session.id in expandedSessions) { - expandedSessions = expandedSessions - session.id - } else { - expandedSessions = expandedSessions + session.id - loadSubagents(session.id) - } - }, - onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) }, - ) - Spacer(Modifier.height(12.dp)) - } - } - } - } - } - - FloatingActionButton( - onClick = onSpawn, - modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp), - ) { - Text("+", style = MaterialTheme.typography.headlineMedium) - } - } - - confirmingDelete?.let { session -> - // Reset per session, so a toggle turned on for one conversation is not still on for the - // next. Off to begin with: see [deleteSession]. - var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) } - AlertDialog( - onDismissRequest = { confirmingDelete = null }, - title = { Text("Delete \"${session.title}\"?") }, - text = { - // Two different acts behind one button, so it says which one this is. What - // separates them is whether the *driver* keeps its own record of the conversation - // -- the Claude Code CLI does, whether this app spawned the session or imported it; - // echo and llama.cpp do not. - // - // This used to branch on `imported`, above a comment asserting that "a session - // started here has no copy anywhere". That was false for every claude-cli session - // this app spawned, and getting it wrong in that direction is the expensive one: - // "this can't be undone", said of something that can, spends the credibility the - // sentence needs. - // - // Neither branch promises a restore. The recoverable one says what is known -- the - // driver keeps its own record -- rather than that the file is still there, and it - // names what goes either way, because this app's transcript holds images, peer - // messages and commands the CLI's own record never had. - Column { - Text( - when { - !session.keepsOwnTranscript -> - "Kills the process and deletes the conversation. Nothing else " + - "keeps a copy, so this can't be undone." - // The sentence below is the one the toggle makes false, which is why it - // is written twice rather than appended to: leaving "should still be - // there to import again" on screen beside a switch that removes it is - // the reassurance being read at the moment it stops being true. - alsoDeleteForeign -> - "Kills the process and deletes both copies of the conversation: " + - "this app's, and Claude Code's own transcript on the " + - "machine. Nothing keeps another, so this can't be undone." - else -> - "Stops the process and deletes this app's copy of the " + - "conversation, including any images, peer messages and " + - "commands recorded only here. Claude Code keeps its own " + - "transcript on the machine, so the conversation itself " + - "should still be there to import again." - } - ) - // Only where there is a second copy to decide about. Absent rather than - // disabled, because this is not a capability being withheld: for echo and - // llama.cpp there is no other transcript, and a switch offering to delete one - // would be asking about something that does not exist. - if (session.keepsOwnTranscript) { - Spacer(Modifier.height(16.dp)) - // Its own row rather than beside the paragraph: a switch is taller than a - // line of text and re-centres whatever shares a row with it. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Delete Claude Code's transcript too", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(12.dp)) - Switch( - checked = alsoDeleteForeign, - onCheckedChange = { alsoDeleteForeign = it }, - ) - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - confirmingDelete = null - // Marked here rather than after the request returns: the row has to say - // something is happening to it from the moment it is asked for. - deleting = deleting + session.id - deleteErrors = deleteErrors - session.id - scope.launch { - try { - withContext(Dispatchers.IO) { - deleteSession(settings, session.id, alsoDeleteForeign) - // After it succeeded, not before: a refused delete leaves the - // session exactly as it was, and its transcript with it. - transcriptCache.session(TranscriptAddress(session.id)).purge() - } - // Only this row, and only what changed. Refetching the list instead - // put every other session back through loading and handed the - // reader an empty screen, to report on something never in doubt. - val loaded = listState - if (loaded is LoadState.Loaded) { - listState = - LoadState.Loaded( - loaded.value.filterNot { it.id == session.id } - ) - } - } catch (e: ApiException) { - // Kept, because it is still there: the server refused, so the - // session it refused about is exactly as it was. - deleteErrors = - deleteErrors + (session.id to (e.message ?: "Delete failed")) - } finally { - deleting = deleting - session.id - } - } - } - ) { - // Coloured by consequence: this takes something away, and does so wherever it - // appears -- the same rule the import screen's Delete follows. - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") } - }, - ) - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SessionCard( - session: SessionSummary, - /** What went wrong acting on *this* session, if anything has. */ - error: String?, - /** - * Whether this session is being deleted right now. - * - * Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its - * way out without claiming it has gone: a row removed the moment Delete is pressed is a promise - * about a request that has not been answered yet. - */ - deleting: Boolean, - onOpen: () -> Unit, - onLongPress: () -> Unit, - /** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */ - expanded: Boolean, - /** What the expander's own fetch answered, or null before it has been asked. */ - subagents: LoadState>?, - onToggleSubagents: () -> Unit, - onOpenSubagent: (SubagentSummary) -> Unit, -) { - BusyItem(label = if (deleting) "deleting" else null) { - Card( - // Off while the delete is in flight: a card that still opens a session it is deleting - // is a race the reader can start by tapping. On the card rather than in [BusyItem], - // which leaves gestures alone so the list still scrolls. - Modifier.fillMaxWidth() - .combinedClickable( - enabled = !deleting, - onClick = onOpen, - onLongClick = onLongPress, - ) - ) { - Column(Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - session.title, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - StatusText(session.status) - } - Spacer(Modifier.height(4.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Text( - // Machine, then what runs on it, then what it is set to: the same order and - // separator as the session screen's header and the usage dialog, so one - // pair of facts is not written three ways. - listOfNotNull( - session.setupName, - session.provider, - session.model?.let { modelLabel(it) }, - ) - .joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Text( - relativeTime(session.lastActivity), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - error?.let { - Spacer(Modifier.height(8.dp)) - // The server's own words, unprefixed, the way every other failure is shown. - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - // Nothing at all for a card with no subagents: a disabled expander here would be - // noise on every ordinary session's card. Its own row at the bottom rather than - // beside the title or the machine line, so opening it never displaces text that was - // already on screen -- see UI_RULES on a control not displacing the text beside it. - if (session.subagents > 0) { - Spacer(Modifier.height(8.dp)) - // The platform's minimum touch height, not the chevron's own ten or so dp: - // at the chevron's height a tap meant for it landed on the first subcard - // beneath and opened a subagent instead. - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.fillMaxWidth() - .heightIn(min = 48.dp) - .clickable(enabled = !deleting, onClick = onToggleSubagents) - .semantics { - contentDescription = - if (expanded) "Collapse subagents" else "Expand subagents" - }, - ) { - Chevron(if (expanded) Pointing.Up else Pointing.Down) - } - if (expanded) { - Spacer(Modifier.height(4.dp)) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - when (subagents) { - null, - is LoadState.Loading -> - CircularProgressIndicator( - modifier = Modifier.width(20.dp).height(20.dp), - strokeWidth = 2.dp, - ) - is LoadState.Error -> - // Said here rather than left silent: a fetch that failed and an - // expander that simply found nothing must not look the same -- - // see UI_RULES on designing the unknown state first. - Text( - subagents.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - is LoadState.Loaded -> - subagents.value.forEach { subagent -> - SubagentCard( - subagent, - onClick = { onOpenSubagent(subagent) }, - ) - } - } - } - } - } - } - } - } -} - -/** - * One subagent, indented inside its session's card -- the way dev-updater draws a project's - * components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own - * filled one, so the nesting reads as one step rather than as another session. - */ -@Composable -private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) { - OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) { - Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { - Text(subagent.title, style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.height(2.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Text( - subagentStatusLabel(subagent.status), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Text( - relativeTime(subagent.lastActivity), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -/** - * The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function - * rather than a branch inside [StatusText], because a subagent's three states are not that - * composable's five: "exited" reads as "finished" here, since its process was always its parent's - * and never something of its own to have merely stopped. - */ -private fun subagentStatusLabel(status: String) = - when (status) { - "running" -> "running" - "exited" -> "finished" - else -> "unknown" - } - -@Composable -fun StatusText(status: String) { - val (label, color) = - when (status) { - "awaitingInput" -> "your turn" to awaitingColor - "running" -> "running" to runningColor - "compacting" -> "compacting" to commandColor - "exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant - // Said in words, because it differs in kind from the others rather than in degree: the - // session is not idle and has not exited, nobody has been able to find out which. A - // muted colour alone would read as one of the quiet states. - "unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant - else -> status to MaterialTheme.colorScheme.onSurfaceVariant - } - Row(verticalAlignment = Alignment.CenterVertically) { - if (sessionWorking(status)) { - // The same colour as the word beside it: the two are one signal, and a spinner in the - // theme's accent says the state is something other than what the label says. - CircularProgressIndicator( - modifier = Modifier.width(14.dp).height(14.dp), - strokeWidth = 2.dp, - color = color, - ) - Spacer(Modifier.width(6.dp)) - } - Text(label, style = MaterialTheme.typography.labelLarge, color = color) - } -} - -fun relativeTime(epochSeconds: Double): String { - val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong() - return when { - seconds < 60 -> "just now" - seconds < 3600 -> "${seconds / 60}m ago" - seconds < 86400 -> "${seconds / 3600}h ago" - else -> "${seconds / 86400}d ago" - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt deleted file mode 100644 index dbbe847..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ /dev/null @@ -1,2502 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import android.content.pm.ApplicationInfo -import android.net.Uri -import android.os.Build -import android.os.SystemClock -import android.util.Log -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.isImeVisible -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.selection.rememberSelectionState -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.lifecycle.repeatOnLifecycle -import java.util.concurrent.atomic.AtomicLong -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * How big the "still loading this conversation" spinner is: bigger than the ones inside a tool - * card, which report on one call among many, and smaller than a splash. - */ -private val LOADING_SPINNER = 48.dp - -/** - * How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history - * before the next page is fetched. - * - * Multiplied by the viewport to give a number of *pixels*, which is the distance the question is - * actually about. A row is anything from one line to a page, so a count of rows is that distance - * only by accident: eight rows was the number once, and on a tool-heavy transcript that is less - * than one screen, so the reader ran out of loaded transcript on every swipe. - * - * Six, because the two ways to be wrong are not the same size: firing early costs a page nobody - * reads, firing late is a spinner under somebody's finger for a whole round trip over the tunnel. - */ -private const val HISTORY_SCREENS = 6 - -/** - * How many *rows* a backwards page asks for. - * - * Rows, not events, because the two are nothing alike: a reply is stored a token at a time, and on - * one real transcript (2,426 events) the whole conversation was seven assistant messages, the - * median folding four hundred deltas into one row. A page counted in events was a fifth of a single - * row, so reaching a screenful took dozens of sequential round trips. - * - * A few screens' worth, so one page clears the cushion below. A page that still falls short is - * followed by another in the background, nobody waiting on either. - * - * The opening page stays counted in events and small: it is on the critical path of showing the - * screen, and it is the newest window, where coalescing is unsafe for the live cursor anyway. - */ -private const val HISTORY_PAGE = 40 - -/** - * The most events one request of a restore may ask for. - * - * A restore knows exactly how far back it has to reach, so it asks in one request rather than - * walking there a page at a time. This bounds it anyway, because "exactly how far" is however far - * the reader had scrolled -- and a single response of arbitrary size is the shape a phone on a slow - * tunnel handles worst. At roughly 800 bytes an event, this is about three megabytes. Going past it - * costs another request rather than anything being missed. - */ -private const val RESTORE_PAGE_MAX = 4000 - -/** - * Events added past the anchor on a restore, so the anchor's row is never the oldest loaded one. - * - * The oldest loaded row is a half-row -- [joinPages] welds its other half on when the page behind - * it arrives, and it grows -- so a restore stopping exactly at the anchor would put the reader a - * screen out once that growth landed. - */ -private const val RESTORE_PAGE_CUSHION = 400 - -/** - * Which row was asked to hold its top edge, and how tall it was when it last measured. - * - * Deliberately *not* snapshot state, which is the point of the class. Both fields are written from - * the layout phase; a snapshot write there that composition reads would schedule another - * recomposition, and the correction has to land inside the frame already being laid out. [key] is - * cleared by the resize it was set for, so it cannot be spent on an unrelated one. - */ -private class TopEdgeHold { - var key: Any? = null -} - -/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */ -private class LastHeight { - var value: Int? = null -} - -/** - * Which row the last touch landed in, and whether it landed in the row's top half -- the end that - * row should hold when it changes height; see [holdTopEdge]. - * - * One slot rather than a map, because only the touch about to toggle something matters: - * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each - * *visible* row, so it costs only rows on screen and runs on touch rather than per frame. - */ -private class LastTouch { - var key: Any? = null - var high = false -} - -/** - * Keeps this row's top edge where it is when the row changes height, if it was asked to. - * - * This runs in the *layout* phase, from the measurement that discovers the new height, and that is - * why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or - * more after the layout it is correcting, so the wrong position is drawn once first -- visible as a - * flick, and worse the faster the screen refreshes. - * - * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of - * exactly that much leaves the top edge where it was. - */ -@Composable -private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier { - val last = remember { LastHeight() } - return onSizeChanged { size -> - val previous = last.value - last.value = size.height - // A first measurement has no previous height to have moved from, and a row that came back - // after being scrolled away is a first measurement again. - if (previous == null || previous == size.height || held.key != key) return@onSizeChanged - held.key = null - hold(size.height - previous) - } -} - -// The transcript's data model -- TranscriptItem, foldEvent, joinPages, warm -- lives in -// TranscriptItems.kt: it is pure event folding with no screen in it. -// -// isImeVisible: see the comment beside `imeVisible` below. -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun SessionScreen( - settings: ServerSettings, - summary: SessionSummary, - onBack: () -> Unit, - /** Opens the file explorer on this session's machine, starting where this session works. */ - onFiles: (FilesTarget) -> Unit, - /** What another app shared in while this session is the one open; see [ShareRequest]. */ - share: ShareRequest? = null, - /** Said once [share] has been attached here, so it is not attached again. */ - onShareTaken: () -> Unit = {}, - /** - * Draws this screen read-only, on a subagent's own transcript instead of the session's. - * - * A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so - * every gate below keyed on this switches off the composer, the files button, the settings cog, - * the usage bar and notifications, while everything that draws a transcript (paging, cache, - * selection, images, the status row, stream reconnects) is reused unchanged, pointed at - * [address] instead of the session's own. - */ - subagent: SubagentSummary? = null, -) { - DebugStats.count("session screen recomposed") - val isSubagent = subagent != null - val address = TranscriptAddress(summary.id, subagent?.id) - val scope = rememberCoroutineScope() - val topEdgeHeld = remember { TopEdgeHold() } - var items by remember { mutableStateOf(listOf()) } - var status by remember { mutableStateOf(subagent?.status ?: summary.status) } - // Seeded from the row this screen was opened from, so a conversation already under way says how - // much it is holding before any turn happens here. Null is "nobody has measured it", which is a - // different answer from an empty context and is drawn differently. - // - // A subagent has no context measurement of its own, so it always starts unmeasured rather than - // borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one - // that was measured. - var contextTokens by - remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) } - // When the current compaction started. The moment comes off the `compacting` status event - // itself -- the server timestamps every transcript line -- rather than off this device noticing - // one, which is what makes it survive leaving the session and reopening it. - var compactingSince by remember { mutableStateOf(null) } - var compactingFor by remember { mutableStateOf(null) } - var streamError by remember { mutableStateOf(null) } - var actionError by remember { mutableStateOf(null) } - // Whether the composer's process button has a request out. What it does next is decided from - // the session's status, and that only changes once the server has answered and the stream has - // carried it back -- so two presses in that gap are two requests, both decided against the - // state before either of them. - var processInFlight by remember { mutableStateOf(false) } - val context = LocalContext.current - // Seeded from what was left in the box last time and written back on every keystroke, so - // leaving the screen does not throw away a half-typed message. See `Drafts.kt`. - // - // A subagent has no box to type into, so it never touches a draft at all -- not this session's, - // which is what reading one keyed only by `summary.id` would do here. - var input by - remember(summary.id) { - mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id))) - } - // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching - // makes the session re-read the whole conversation. - var pendingModel by remember { mutableStateOf(null) } - // What was last taken from the command suggestions, so the list closes behind it. - var picked by remember { mutableStateOf(null) } - // The transcript's selection, held here rather than inside [TranscriptList] because the rows - // have to ask whether anything is selected before they treat a tap as their own. - val selection = rememberSelectionState() - // Read here, at composition, rather than inside [expanding] at the moment of the click. The - // container clears the selection from the very press a card then reads as its own, a few - // milliseconds earlier and in the same event -- so a card asking the live state always hears - // "nothing is selected", and a tap meant to put a selection away also shut the tool call the - // words were in. This is whatever was true as of the last frame. - val selecting = selection.selectedTexts.isNotEmpty() - var expandedTools by remember { mutableStateOf(setOf()) } - // Which runs of adjacent tool calls are open. Keyed by the first call's id, so a group survives - // more calls arriving after it. - var expandedGroups by remember { mutableStateOf(setOf()) } - // Runs already drawn as a group, so the transition into one is noticed exactly once. - var everGrouped by remember { mutableStateOf(setOf()) } - // Which messages from other agents are open, by the seq that identifies their row. Closed by - // default, which is the rule for anything new in this transcript. - var expandedNotes by remember { mutableStateOf(setOf()) } - // Which memory notes are open, by the note's own text. Held here rather than in the card so a - // note opened and scrolled past is still open on the way back. - var openMemories by remember { mutableStateOf(setOf()) } - // Which capped blocks the reader has asked to see whole: a tool call's input or output - // ([Capped]), and long messages, by the row key that identifies them. Held here rather than in - // the card or the row for `openMemories`' reason -- something opened and scrolled past is still - // open on the way back, and a lazy list drops the composition of anything off screen. - var shownWholeCalls by remember { mutableStateOf(setOf()) } - var shownWholeRows by remember { mutableStateOf(setOf()) } - // The image being looked at full screen, by ref. Here rather than in the row that drew the - // thumbnail: a row regrouped underneath the reader takes its whole subtree with it. - var fullImage by remember { mutableStateOf(null) } - // Uploaded-but-not-yet-sent attachment ids; sent with the next message. - var pendingAttachments by remember { mutableStateOf(listOf()) } - // The name shown at the top. Held here rather than read from the row that opened this screen, - // because renaming is something this screen can do -- and a header still showing the old name - // reads as a rename that did not take. - var title by remember(summary.id) { mutableStateOf(summary.title) } - var model by remember { mutableStateOf(summary.model) } - var permissionMode by remember { mutableStateOf(summary.permissionMode ?: "auto") } - // The models this provider actually offers, asked of the server rather than listed here: a - // hardcoded list is a claim about a machine. - var offeredModels by remember { mutableStateOf>(emptyList()) } - val lifecycleOwner = LocalLifecycleOwner.current - // The resume cursor, written from the stream's IO thread. - val lastSeq = remember { AtomicLong(0) } - // Bumped to rebuild this screen from nothing -- what Reload in the settings dialog does. It - // keys everything describing one visit: the source, the opening effect, the stream, and the - // anchor. See TRANSCRIPT_CACHE.md's decision 8. - var epoch by remember(summary.id) { mutableIntStateOf(0) } - // This server's cached transcripts, and this session's half of them. The cache is per server - // because two servers can hold a session with the same id; the source is per visit because - // Reload throws away what it was reading from. - val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } - val source = - remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) } - // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed - // from a cached cursor until it has, and a probe that could not be made leaves this false for - // the stream loop to try again. - var probePassed by remember(address, epoch) { mutableStateOf(false) } - // Whether the opening effect is still settling that question. It draws the cached rows and - // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream - // below waits for this rather than for `ready`, or it asks the same question twice. - var probing by remember(address, epoch) { mutableStateOf(true) } - // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is - // what keeps opening a long session cheap. - var oldestSeq by remember { mutableLongStateOf(0L) } - // Where this transcript was last being read, from this device's own store, keyed by the address - // rather than the session id so a subagent's saved position cannot collide with its session's. - // Read once, because the answer stops being interesting the moment the list is on screen. - val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) } - // Whether the saved position is still being put back. Nothing is drawn while it is: opening at - // the newest end and then travelling to the anchor is exactly the journey a reader must never - // see. - var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) } - // Messages the server has taken and the session has not read yet, by the id that will resolve - // them. From the event stream rather than from what this screen sent, so they survive leaving - // the session -- and a message sent from another device is drawn waiting on this one too. - var queued by remember { mutableStateOf(listOf()) } - // Commands the session has been asked to run and cannot yet, by the id that will resolve them. - // From the server, so a rename sent from another device is drawn waiting here too. - var waitingCommands by remember { mutableStateOf(listOf>()) } - val running = sessionWorking(status) - var moreHistory by remember { mutableStateOf(true) } - var loadingHistory by remember { mutableStateOf(false) } - var ready by remember { mutableStateOf(false) } - // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. - val replies = remember(address) { ParsedReplies() } - // Keyed like everything else describing one transcript. `rememberLazyListState` saves through - // `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would - // fight over the first frame. - val listState = remember(address) { LazyListState() } - // The list's own fling path -- what a real flick decays through -- captured here so BenchRun's - // fling phase can drive `LazyListState.scroll` through exactly the `FlingBehavior` this - // screen's - // `TranscriptList` already uses by not overriding it (its `LazyColumn` takes no `flingBehavior` - // argument, so this is the same default it gets). - val flingBehavior = ScrollableDefaults.flingBehavior() - // Where BenchRun's type phase focuses before it types, and the view it toggles the keyboard on - // -- both bench-only, but cheap enough (a remembered object, a CompositionLocal read) to hold - // unconditionally rather than behind a second code path only the bench build compiles. - val composerFocus = remember { FocusRequester() } - val view = LocalView.current - // Whether the newest message is on screen right now. The list is reversed, so the newest end is - // the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll - // state rather than of item indices, because a zero-height first item makes an index ambiguous. - // This is what the jump-to-newest button watches, and the gate on recording. - val atNewest by remember { derivedStateOf { !listState.canScrollBackward } } - // Transcript events that arrived while somebody was reading further back, in the order they - // arrived, waiting for them to return to the newest end. See [record]. - var held by remember { mutableStateOf(listOf()) } - // What is actually drawn: the transcript with runs of adjacent tool calls folded into one row. - val rows = remember(items) { groupToolRuns(items) } - // Bumped when a cold reply's parses become ready, so the flatten runs again and can split it. - var warmedTick by remember { mutableIntStateOf(0) } - val units = - remember(rows, expandedNotes, warmedTick, shownWholeRows) { - transcriptUnits(rows, replies, expandedNotes, shownWholeRows) - } - // The reply that just finished streaming is the one row whose parses nobody has made: pages - // warm before their fold lands, but nothing warms live deltas. Off the composing thread, then - // the tick re-flattens, so settling never costs a whole-message parse in a frame. - LaunchedEffect(rows, shownWholeRows) { - val cold = unwarmedReplies(rows, replies, shownWholeRows) - if (cold.isNotEmpty()) { - warm(replies, cold) - warmedTick++ - } - } - // The same list, readable from effects launched before this composition: an effect's closure - // keeps the values of the composition that launched it. - val currentUnits by rememberUpdatedState(units) - val lastTouch = remember { LastTouch() } - - /** - * Drops everything loaded, so the screen can be rebuilt from a window that is not adjacent to - * it. - * - * One function rather than a clearing written at each of the three places that need it -- a - * stream reset, a cached transcript the server turns out not to have, and Reload -- because - * what has to go is a property of "these rows are no longer continuous with what comes next". - * The two easy ones to leave out are [queued] and [waitingCommands]: both are folded from - * events, so a `messageQueued` whose resolving `userMessage` fell in the gap draws a bubble - * waiting for a message the session read long ago. [contextTokens] needs no clearing, because - * `UsageDelta.context` is absolute. - * - * The resume cursor is deliberately *not* cleared: a reset continues from where it was. - */ - fun dropLoadedTranscript() { - items = listOf() - replies.clear() - held = listOf() - oldestSeq = 0L - moreHistory = true - queued = listOf() - waitingCommands = listOf() - } - - /** - * Everything the transcript list draws, from one event. - * - * Separate from [apply] because it is the half that is allowed to wait. The list anchors on the - * leading edge of its first visible item, which in this upside-down layout is that item's - * *bottom* -- so a row that grows pushes everything already on screen upwards, and the view - * travels toward the newest end without anybody scrolling. Measured against a reply streamed in - * four hundred pieces: scrolling back a screen and waiting six seconds ended at the very - * bottom. - * - * Insertions were never the problem -- the list is keyed. What cannot be allowed is a row that - * is already there changing height, and the one guarantee covering every way that happens is to - * change nothing at all while somebody is reading further back. - */ - fun record(entry: SeqEvent) { - // The oldest event this view holds, which is what paging backwards starts from. Maintained - // here rather than by each loader: the first page and a stream reset both begin an empty - // view, and one of them getting it wrong is a transcript that will not scroll up. - if (oldestSeq == 0L) { - oldestSeq = entry.seq - moreHistory = entry.seq > 1L - } - val event = entry.event - // Waiting, then read. Matched by id: the same message sent twice is two bubbles, and - // clearing by text would take away whichever matched first. - if (event is SessionEvent.MessageQueued) { - queued = queued + QueuedMessage(event.id, event.text, event.attachments) - } - if (event is SessionEvent.UserMessage) { - queued = queued.filterNot { it.id == event.id } - } - // Waiting, then taken back. From the server rather than from the tap, so every device drops - // the bubble and a reconnect does not put back one that was cancelled. - if (event is SessionEvent.MessageDropped) { - queued = queued.filterNot { it.id == event.id } - } - // Waiting, then gone: a command leaves this list when the session takes it, and the row it - // becomes is added by `foldEvent` in the same pass. - if (event is SessionEvent.CommandQueued) { - waitingCommands = waitingCommands + (event.id to event.text) - } - if (event is SessionEvent.CommandSent) { - waitingCommands = waitingCommands.filterNot { it.first == event.id } - } - items = foldEvent(items, entry) - } - - /** - * One event, at the moment it arrives. - * - * What it says about the *session* -- running or not, which model, how many tokens -- lands - * immediately, because none of that is drawn in the list and freezing it would trade a - * transcript that jumps for a status row that lies. What it adds to the transcript goes through - * [record], which waits for the reader to be at the newest end. - */ - fun apply(entry: SeqEvent) { - lastSeq.set(entry.seq) - // Before the rest, and for every event rather than only the usage ones: a compaction and a - // clear move this as much as a turn does. See `contextAfter`. - contextTokens = contextAfter(contextTokens, entry.event) - when (val event = entry.event) { - // Nothing further: what it carries was folded into the context above. - is SessionEvent.UsageDelta -> {} - else -> { - // What the session says it is set to now, which is the only thing that says it: - // picking from either menu asks, and the answer comes back here. - if (event is SessionEvent.Settings) { - event.model?.let { model = it } - event.permissionMode?.let { permissionMode = it } - } - if (event is SessionEvent.Status) { - // The event's own timestamp, so a compaction that began before this screen - // opened is timed from when it actually began -- and a compaction worth asking - // about is a long one. - compactingSince = - when { - event.state != "compacting" -> null - status == "compacting" -> compactingSince - else -> entry.ts - } - status = event.state - } - // In order, always: one late event recorded ahead of the backlog would fold a - // streamed delta into whatever row happened to be last by then. - if (atNewest && held.isEmpty()) record(entry) else held = held + entry - } - } - } - - /** - * A press on the transcript that would open or close something, and the one thing every such - * press has to check first. - * - * The transcript is one [SelectionContainer], so a reader who has selected some text puts that - * selection away by tapping -- and the tap that does it lands on whatever card the text is - * drawn in. Left alone, that card takes it as a press of its own: the reader clears a selection - * and the tool call under their finger collapses. So a press with a selection outstanding - * spends itself clearing it and does nothing else. - * - * Every open and close goes through here rather than each writing the check, since which card - * the finger lands on is not something the reader chose. - */ - fun expanding(toggle: () -> Unit) { - if (selecting) { - selection.clear() - return - } - toggle() - } - - /** - * Changes a row's height while the end the reader touched stays where it is. - * - * The transcript is laid out from the bottom, so every row's *bottom* edge is what the list - * holds still and all growth goes upward. That is what a tap in a row's lower half already - * gets. A tap in the upper half is the other case -- left alone it sends the heading under the - * reader's finger up off the screen -- and that one asks the row to hold its top edge instead. - * - * Which half decides it, rather than which control was pressed, so everything that opens - * behaves the same way whether or not it has a control at each end. - * - * The correction itself belongs to the measurement -- see [holdTopEdge]. - */ - fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding { - if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key - toggle() - } - - /** - * [toggleAnchored] for a control that sits *below* the row it grows -- a capped message's "Show - * all", which is its own list item under the message it reveals. - * - * Always the top edge, with no reading of which half was touched: the control is at the bottom - * of the row by construction, and the whole point of pressing it is that the text just above it - * continues. Held from the bottom instead, the revealed lines would push everything the reader - * had been reading up off the screen and leave them at the end of the message. - */ - fun expandAnchored(key: Any, reveal: () -> Unit) = expanding { - topEdgeHeld.key = key - reveal() - } - - /** - * Whether the row holding transcript position [seq] is loaded, with older history behind it. - * - * "Behind it" is the part easy to leave out. The oldest loaded row is a half-row -- [joinPages] - * welds the other half on when the page before it arrives, and it grows -- so putting the - * reader inside one leaves them where they were only until the next page lands, which was a - * screen and a half out. Any row that is not the oldest is final. - * - * The last row starting at or before [seq], rather than one starting exactly there: the events - * behind a row can be regrouped between the save and the reopen, and the reader's place is - * inside whichever row now holds that seq. - * - * Computed from `items` rather than `rows` for the reason [loadOlderPage] gives. - */ - fun anchorRow(seq: Long): Long? { - val ordered = groupToolRuns(items) - val at = ordered.indexOfLast { it.startSeq <= seq } - // Zero is the oldest loaded row, which is the half-row above; not found is -1. - return if (at > 0) ordered[at].startSeq else null - } - - /** - * One page of older events onto the front of what is loaded; false when there was none. - * - * Shared by the two things that page backwards -- somebody scrolling to the far end, and - * putting the list back where it was left -- because they want the same page for the same - * reason. - * - * Reads `items` rather than `rows`: this runs in a coroutine, and `rows` is the composition's - * value, which does not change under a running one. - */ - suspend fun loadOlderPage(limit: Int = HISTORY_PAGE, coalesce: Boolean = true): Boolean { - // Nothing is loaded, so there is no "before" to ask about -- and asking anyway is not a - // harmless no-op: `before = 0` fetches the events before the first one, which is none, and - // an empty page is how this function is told it has reached the start of the conversation. - // It would latch `moreHistory` false and the session could never be paged back at all. - // - // The window it fires in is the first layout: `moreHistory` starts true, which puts the - // history spinner in the list and makes `visibleItemsInfo` non-empty before a single event - // has arrived. On a loopback server the opening page beat it; at `--delay 150`, which is - // what a phone over the tunnel costs, it won the race. - // - // Guarded here rather than at the two callers because it is a fact about the question. - if (oldestSeq == 0L) return false - // The fetch *and* the fold, both off the thread that draws. Only the fetch used to be, and - // the fold is the expensive half: `foldEvent` returns a new list per event, so a page is - // that many copies of a growing list -- around three hundred thousand element copies, run - // on the main thread in the middle of the scroll that asked for it. - // - // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one - // errand. Neither half touches anything the composition owns. - val page = - withContext(Dispatchers.IO) { - val older = source.page(before = oldestSeq, limit = limit, coalesce = coalesce) - if (older.isEmpty()) return@withContext null - // Folded oldest-first into a list of their own, then put in front: `foldEvent` - // merges streaming text into the item before it, so replaying an older page through - // the live list would glue it onto the newest message rather than its own. - var earlier = listOf() - older.forEach { entry -> - if (entry.event !is SessionEvent.UsageDelta) { - earlier = foldEvent(earlier, entry) - } - } - older.first().seq to earlier - } - if (page == null) { - moreHistory = false - return false - } - val (oldest, earlier) = page - oldestSeq = oldest - moreHistory = oldestSeq > 1L - // Joined here rather than above, because it is the one step that reads what is already - // loaded: `items` must be read where it is written. - val joined = joinPages(earlier, items) - // After the join rather than on the page alone: a boundary that fell through a reply leaves - // `joinPages` holding a message made of both halves, and that text has existed for no time - // at all. Warming the page by itself warmed the two halves and missed the one thing drawn. - warm(replies, joined) - items = joined - return true - } - - // A call opened on its own stays open when a second call in the same run turns it into a group. - // Until this, watching a Bash call and having the session make another one shut the one being - // read and folded it behind "Called 2 tools". - // - // Considered once per run, at the moment it first becomes a group, and never again: after that - // the group's own toggle owns it. - LaunchedEffect(rows) { - val fresh = rows.filterIsInstance().filter { it.id !in everGrouped } - if (fresh.isEmpty()) return@LaunchedEffect - expandedGroups = - expandedGroups + - fresh.filter { group -> group.calls.any { it.id in expandedTools } }.map { it.id } - everGrouped = everGrouped + fresh.map { it.id } - } - - // A compaction reports nothing about its own progress -- measured against the CLI, which says - // it has started and then nothing at all until it is done. So what this counts is the one thing - // anybody here can measure: how long it has been going. A bar filling up would be this screen - // inventing the part the CLI does not send. - LaunchedEffect(compactingSince) { - val since = compactingSince - if (since == null) { - compactingFor = null - return@LaunchedEffect - } - while (true) { - // Against this device's wall clock, because `since` is the server's. Floored at zero so - // a phone running a little behind the backend counts up from nothing rather than - // reporting a compaction that has not started yet. - compactingFor = (System.currentTimeMillis() / 1000.0 - since).toLong().coerceAtLeast(0) - delay(1000) - } - } - - // The stream lifecycle: connect, follow, and on any drop reconnect from the cursor. - // - // The newest window first, before the stream opens, so the stream starts from where that window - // ended and carries live events only. The window comes from this phone's own copy when there is - // one, and then costs a single request to check that the server's transcript is still the one - // it came from. See TRANSCRIPT_CACHE.md. - LaunchedEffect(address, epoch) { - /** - * One opening window onto the screen, whichever side it came from. - * - * Warmed before the fold lands rather than after: flattening the rows into units splits - * every settled reply, and the flatten runs in the composition that first sees the rows. - */ - suspend fun open(page: List) { - withContext(Dispatchers.IO) { - var scratch = listOf() - page.forEach { entry -> - if (entry.event !is SessionEvent.UsageDelta) { - scratch = foldEvent(scratch, entry) - } - } - warm(replies, scratch) - } - page.forEach { apply(it) } - } - - try { - // This phone's own copy first, drawn before anything is asked of the server. What makes - // it safe to draw before it is checked is that a failed check replaces these rows, with - // the same appearance as a reset. - val cached = withContext(Dispatchers.IO) { source.cachedOpening() } - if (cached != null) { - open(cached) - // A replay is as old as the last visit; the row this screen was opened from was - // fetched moments ago. So the transcript comes from the cache and everything that - // is not the transcript comes from the summary -- otherwise a session that finished - // an hour ago opens saying "working" until the stream connects. A subagent's status - // comes from its own summary, never the parent session's: they are two different - // things running or not, and the parent's model and permission mode do not apply to - // it at all. - status = subagent?.status ?: summary.status - if (!isSubagent) { - model = summary.model - permissionMode = summary.permissionMode ?: "auto" - } - if (status != "compacting") compactingSince = null - // Nothing to put back, so these rows are the screen and the probe can return under - // them. A restore still has history to fetch and is gated below. - if (savedAnchor == null) ready = true - } - // The one thing a cached cursor has to be shown before the stream resumes from it. - val usable = cached != null && withContext(Dispatchers.IO) { source.probe() } - if (usable) probePassed = true - if (!usable) { - // Either there was nothing cached, or what was cached is not what the server has -- - // the file was replaced or truncated under it. Same clearing as a reset, then an - // ordinary cold open. - if (cached != null) { - dropLoadedTranscript() - lastSeq.set(0) - } - open(withContext(Dispatchers.IO) { source.fetchOpening() }) - // Refilled from the server, so the tail is the server's by construction. - probePassed = true - } - } catch (e: ApiException) { - // Not fatal: the stream below still replays from zero, which is slow but complete. - // - // It is also where a probe that could not be *made* lands -- a phone with no route to - // the server. Whatever was cached stays on screen and [probePassed] stays false, so the - // stream loop asks again before it resumes from that cursor. - streamError = e.message - } finally { - // However that went, the stream is free to take it from here. - probing = false - } - - try { - // Then back where reading stopped. An anchor deeper than the newest page is exactly the - // one worth restoring -- somebody who read to the bottom has no anchor at all. - savedAnchor?.let { anchor -> - // Pages until the anchor's row is loaded and has something older behind it. The - // oldest loaded row is a half-row that grows when the page behind it arrives, so - // anchoring into one puts the reader where they were only until that lands. - // - // This terminates because `oldestSeq` walks strictly backwards and the anchor is a - // seq. Keying on the row's *name* instead could not promise that -- a tool run is - // renamed whenever the newest page starts somewhere new, so an anchor on one was - // never found and this paged to the first event of the conversation every time. - while (moreHistory && anchorRow(anchor.seq) == null) { - // The whole span in one request rather than a page at a time. `read_window` - // counts *lines* and a transcript numbers them one per seq, so the distance - // back to the anchor is the number of events to ask for -- and were seqs ever - // sparse, that difference overshoots into older history rather than stopping - // short. - // - // Capped, and the loop is what makes the cap safe: a span past it comes back in - // several requests instead of one, which is what this did for every restore - // until now -- thirteen sequential round trips to reopen a session somebody had - // read a little way back into. Raw, not coalesced: this counts events back to a - // known seq, and a page measured in rows cannot be counted to one. - val behind = oldestSeq - anchor.seq - val loaded = - if (behind < 0) { - // The anchor's row is loaded but is the oldest half-row, which - // [anchorRow] refuses; what completes it is the row before it, and only - // a page counted in rows can promise to reach that. Counted in events - // the span is negative and was coerced to one: a request per delta, six - // hundred round trips for an anchor inside a 1,400-delta reply. - loadOlderPage() - } else { - loadOlderPage( - (behind + RESTORE_PAGE_CUSHION) - .coerceIn(1L, RESTORE_PAGE_MAX.toLong()) - .toInt(), - coalesce = false, - ) - } - if (!loaded) break - } - // Resolved to the row that *holds* the saved position rather than passed straight - // through, because the two are not always the same seq: the events behind a row - // regroup between the save and the reopen. Null is a row no longer in the - // transcript at all, and means there is nothing to put back. - anchorRow(anchor.seq)?.let { rowSeq -> - // The units are built by composition, and this coroutine has been loading rows - // the composition may not have seen -- so wait for the build that holds the - // anchor's row before turning it into an index. Guaranteed to arrive, because - // the units are a pure function of `items`. Nothing is drawn during the wait. - // One past the index, because item zero is the "below" slot. - val index = - snapshotFlow { unitIndexFor(currentUnits, rowSeq, anchor.unit) } - .first { it != null }!! - listState.scrollToItem(index + 1, anchor.offset) - } - } - } catch (e: ApiException) { - // A page of history that never arrived. The reader is left at the newest end rather - // than where they were, which is the state this screen opens in anyway. - streamError = e.message - } - // Whatever happened above: an empty transcript is a state the screen can draw, and a - // permanently blank one is not. - restoring = false - ready = true - // The opening page is sized for time-to-first-frame, not for reading: it fills a viewport - // or two, so the first "still loading" boundary sat barely off-screen and the first upward - // scroll met it and waited a round trip. So the first full page goes right behind it, while - // the screen is already up. A restore skips this: it has just paged as deep as it needed. - if (savedAnchor == null && moreHistory && !loadingHistory) { - loadingHistory = true - try { - loadOlderPage() - } catch (_: ApiException) { - // The next scroll asks again. - } finally { - loadingHistory = false - } - } - // Last, and off this thread: this session is what must not be evicted, so it is marked as - // visited before the budget is applied, and both are a walk of the cache directory. - withContext(Dispatchers.IO) { - source.cache.touch() - cache.evictToBudget(keep = summary.id) - } - } - - // Only while the screen is actually on screen. Android stops the activity when somebody - // switches away and the socket dies with it, which arrived as "Lost the event stream" waiting - // at the top on their return. Switching apps is a choice somebody made, not a fault to report. - // Stopping the stream deliberately makes the drop a close rather than an error, and resuming - // reconnects from the same cursor. - LaunchedEffect(address, ready, epoch, lifecycleOwner) { - if (!ready) return@LaunchedEffect - // The opening effect draws cached rows and lifts `ready` *before* it has checked that the - // cursor under them is still the server's, so `ready` is no longer the whole gate. Without - // this the two run at once and race each other's answer -- two probes per warm open. - snapshotFlow { probing }.first { !it } - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - try { - while (true) { - try { - // A cached cursor whose probe never got an answer, because the server could - // not be reached when the screen opened. Resuming from an unchecked cursor - // is the one thing this must not do, so it is asked again here with the - // cached rows still on screen. False covers both answers that mean "open - // cold". - if (!probePassed) { - if (withContext(Dispatchers.IO) { source.probe() }) { - probePassed = true - } else { - dropLoadedTranscript() - lastSeq.set(0) - withContext(Dispatchers.IO) { source.fetchOpening() } - .forEach { apply(it) } - probePassed = true - } - } - withContext(Dispatchers.IO) { - source.follow( - after = lastSeq.get(), - // Connected, measured rather than inferred: this is what takes a - // failure off the screen. Clearing on the first event instead meant - // an idle session kept displaying an error it had recovered from. - onOpen = { streamError = null }, - onReset = { - // Too far behind to continue from: what is on screen is a stale - // prefix of a conversation that has moved on, and the window - // arriving next is not adjacent to it. Dropping the rows makes - // this the same as opening the screen. The cache needs no - // telling: the window's first seq is not the one it expected, - // which closes its live run and starts another. - dropLoadedTranscript() - }, - ) { entry -> - apply(entry) - } - } - } catch (e: kotlinx.coroutines.CancellationException) { - // Leaving the screen or going below STARTED. Not a failure, and swallowing - // it would leave this loop reconnecting forever. - throw e - } catch (e: Exception) { - // Any failure, not only an [ApiException]: the stream reconnects from its - // cursor, so there is nothing a failure here can cost that is worth closing - // the app over. - streamError = e.message ?: e::class.simpleName - } finally { - source.close() - } - delay(RECONNECT_DELAY_MS) - } - } finally { - // Cancellation cannot interrupt a blocking socket read. Closing is what unblocks - // it, and what marks the drop deliberate. - source.close() - } - } - } - // The screen going away entirely, which the lifecycle scope above does not cover: a composable - // can leave the composition while the activity stays started. Keyed on the epoch as well, so - // Reload's replacement source is the one a later disposal closes. - DisposableEffect(address, epoch) { onDispose { source.close() } } - - // Nothing gets announced about the session somebody is reading; see NotificationService. - // RESUMED rather than STARTED because "looking at it" means the foreground. - // - // Not for a subagent: it has no notifications of its own, and it is not the session this would - // otherwise mark as being read. - if (!isSubagent) { - LaunchedEffect(summary.id, lifecycleOwner) { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - NotificationService.showing(context, summary.id) - try { - awaitCancellation() - } finally { - NotificationService.stoppedShowing(summary.id) - } - } - } - } - - // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than - // paced out: they are at the bottom, which is the one place the list is allowed to follow new - // content. - LaunchedEffect(listState) { - snapshotFlow { atNewest && held.isNotEmpty() } - .collect { due -> - if (!due) return@collect - val backlog = held - held = listOf() - backlog.forEach { record(it) } - } - } - // Where the reader left off, written whenever the list settles somewhere new. - // - // Driven by the position rather than by the scroll flag, and that is the whole point: a - // *programmatic* scroll moves the list within one frame, so `isScrollInProgress` never - // observably changes and anything waiting for a settle never runs. Jump to latest is exactly - // that, and it left the old position recorded. - // - // The place is the first visible item -- in this reversed list, the one at the *bottom* of the - // viewport -- named by its row's seq and its unit within the row, which are the two things that - // survive a reopen. The index does not, and the key does not either; see [ScrollAnchor]. - LaunchedEffect(listState) { - snapshotFlow { - if (listState.isScrollInProgress) null - else - Triple( - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset, - listState.canScrollBackward, - ) - } - // The value `snapshotFlow` emits on collection is where the list sits before anybody - // has touched it, which is not somewhere they left off. Taking it as one wiped every - // saved anchor on the way in -- before the restore above could use it. - .drop(1) - .collect { settled -> - if (settled == null || restoring) return@collect - val (index, offset, awayFromNewest) = settled - saveScrollAnchor( - context, - address.cachePath, - // Nothing to restore at the newest end, which is where a session with no anchor - // opens anyway. One *before* the index, because item zero is the "below" slot. - if (!awayFromNewest) null - else - currentUnits.getOrNull(index - 1)?.let { - ScrollAnchor(it.seq, offset, it.ordinal) - }, - ) - } - } - // Reaching within a few screens of the far end of what is loaded fetches the page before it. - // - // The question is pixels of scroll -- how far can the reader keep going before they run out -- - // and a lazy list cannot answer it exactly, because it has never measured the items it has not - // composed. So the room ahead is added up from the real size of every unit the list *has* laid - // out, kept by key as units pass through the viewport, with the running average standing in for - // the ones it has never seen. It used to be the average of the units currently on screen, which - // is the worst possible sample: two tall blocks fill a viewport, multiply out over dozens of - // unseen one-line rows, and report screens of room when the end is one swipe away. - // - // There is no correction beside this one. Following the newest message is not an effect: the - // list is reversed, so an arriving message extends the end the viewport is pinned to. - val unitSizes = remember(address) { HashMap() } - LaunchedEffect(listState, moreHistory) { - snapshotFlow { listState.layoutInfo } - .collect { info -> - val visible = info.visibleItemsInfo - if (visible.isEmpty()) return@collect - // Before the guards below, so sizes keep accumulating while a page is in flight and - // the next estimate starts better informed. - visible.forEach { unitSizes[it.key] = it.size } - if (restoring || !moreHistory || loadingHistory) return@collect - val viewport = info.viewportSize.height - if (viewport == 0) return@collect - val loaded = currentUnits - val average = unitSizes.values.sum() / unitSizes.size - // From the last visible lazy index: item zero is the "below" slot, so lazy index - // equals units index plus one -- and a visible spinner makes the range empty, which - // is room of zero. - var room = 0L - val cushion = viewport.toLong() * HISTORY_SCREENS - for (index in visible.last().index until loaded.size) { - room += unitSizes[loaded[index].key] ?: average - if (room >= cushion) return@collect - } - loadingHistory = true - try { - // One page, and then this fires again if it was not enough -- the estimate is - // re-made from what the page actually added. - loadOlderPage() - } catch (_: ApiException) { - // Leave `moreHistory` alone: the next scroll asks again. - } finally { - loadingHistory = false - } - } - } - - // Only for the model picker, which a subagent does not have. - if (!isSubagent) { - LaunchedEffect(summary.setupName, summary.provider) { - offeredModels = - try { - withContext(Dispatchers.IO) { - fetchSetups(settings) - .firstOrNull { it.name == summary.setupName } - ?.providers - ?.firstOrNull { it.name == summary.provider } - ?.models - .orEmpty() - } - } catch (_: Exception) { - // Not worth reporting: the picker simply has nothing to offer, which is - // visible. - emptyList() - } - } - } - - /** - * Asks the server to take back a message the session has not read yet. - * - * Nothing is removed here. The bubble goes on the `messageDropped` the server records, which is - * what makes the cancellation the session's own fact rather than this screen's opinion of it -- - * a second device has to lose the bubble too, and this one has to still lose it after a - * reconnect. - * - * The refusal is kept on the message it was about rather than in [actionError]: the error row - * lives under the header, and a bubble at the foot of the transcript is the thing that was - * pressed. It is the ordinary answer here -- a Claude session writes a steer into the CLI the - * moment it arrives, so what is on screen as "waiting" is waiting to be *read*. - */ - fun takeBack(messageId: String) { - scope.launch { - val refusal = - try { - withContext(Dispatchers.IO) { unqueueMessage(settings, summary.id, messageId) } - null - } catch (e: ApiException) { - e.message ?: "this message could not be taken back" - } - queued = queued.map { if (it.id == messageId) it.copy(refusal = refusal) else it } - } - } - - /** Opens one image full screen, from whichever row drew it; see [SessionImageViewer]. */ - fun openImage(ref: String) { - fullImage = ref - } - - /** Opens or closes one memory note, wherever it is drawn; see [MemoryNote]. */ - fun toggleMemory(text: String) = expanding { - openMemories = if (text in openMemories) openMemories - text else openMemories + text - } - - /** - * Opens or closes one peer message, from whichever of its pieces was pressed. - * - * By seq rather than by unit, because an open message is several units and all of them shut it. - * - * No [toggleAnchored] here, and that is the difference between growing a row and adding items: - * the list is keyed, so it holds the item it is anchored on wherever the new ones land. - */ - fun togglePeer(seq: Long) = expanding { - expandedNotes = if (seq in expandedNotes) expandedNotes - seq else expandedNotes + seq - } - - fun act(onDone: () -> Unit = {}, action: () -> Unit) { - scope.launch { - try { - withContext(Dispatchers.IO) { action() } - actionError = null - } catch (e: ApiException) { - actionError = e.message - } finally { - // Whatever happened, including the failure above: a caller that re-enables a - // control here must get it back on the path where the request was refused too. - onDone() - } - } - } - - /** - * Sends every answer a question card handed over, and says when the last of them has settled. - * - * All of them in one go because a card asks its questions together and the tool is waiting on - * all of them; the completion is what turns the card's spinner back into a button. - */ - fun answerAll(answers: List, onSettled: () -> Unit) { - act(onDone = onSettled) { - answers.forEach { answerQuestion(settings, summary.id, it.questionId, it.answers) } - } - } - - fun send() { - val text = input.text.trim() - val attachments = pendingAttachments - if (text.isEmpty() && attachments.isEmpty()) return - // A command is not a message: it is an instruction to the session about itself, and one - // written into a running turn is read by the model instead. The server holds it until the - // turn ends and says so, which is where its waiting bubble comes from -- so nothing is held - // here, and there is no local guess to correct. - if (text.startsWith("/") && attachments.isEmpty()) { - input = atEnd("") - saveDraft(context, summary.id, "") - // The one command with a visible effect outside the transcript, applied when the server - // has accepted it rather than when it was typed: the name is this app's own datum and - // changes at once, and only telling the session waits for a boundary. - val renamed = - text.removePrefix("/rename ").trim().takeIf { - text.startsWith("/rename ") && it.isNotEmpty() - } - act { - runCommand(settings, summary.id, text) - renamed?.let { title = it } - } - return - } - input = atEnd("") - saveDraft(context, summary.id, "") - pendingAttachments = emptyList() - // Nothing is added here. The server says what is waiting -- it emits `messageQueued` when - // it takes a message it cannot deliver yet -- and this screen draws that. Holding a local - // copy as well was the bug: the two agreed only until the app was restarted. - act { sendMessage(settings, summary.id, text, attachments) } - } - - // One path for everything attached, however it arrived: the photo picker, the file chooser or - // another app's share sheet. It uploads as soon as it is chosen, so Send only has ids. - fun attach(uri: Uri) { - scope.launch { - try { - val id = - withContext(Dispatchers.IO) { - // An image is shrunk to what this session's provider takes before it is - // uploaded, so a twelve-megapixel photo does not cross the tunnel to be - // rejected at the far end; a file goes whole. - uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge) - } - pendingAttachments = pendingAttachments + id - actionError = null - } catch (e: ApiException) { - actionError = e.message - } - } - } - val pickImage = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - uri?.let(::attach) - } - val pickFile = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let(::attach) - } - // What another app shared in, attached the moment this screen has it. Taken off the request - // first, so a recomposition cannot attach it a second time. - LaunchedEffect(share) { - val incoming = share ?: return@LaunchedEffect - onShareTaken() - incoming.uris.forEach(::attach) - incoming.text?.let { shared -> - input = atEnd(if (input.text.isBlank()) shared else input.text + "\n" + shared) - saveDraft(context, summary.id, input.text) - } - } - - // One poll for the machines' limits, read by everything on this screen that reports them. - // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll. - val usageFeed = if (isSubagent) null else rememberUsageFeed(settings) - val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered - RecordFrames() - var usageOpen by remember { mutableStateOf(false) } - var settingsOpen by remember { mutableStateOf(false) } - - // The composer floats over the bottom of the screen instead of sitting under the transcript in - // one column, and the keyboard moves it by a layer translation rather than by relayout. With - // everything in one column under a root imePadding, every frame of the keyboard animation re- - // measured, re-placed and re-recorded the entire screen -- ~7.6ms of main-thread work per frame - // across ~34 frames per open on the emulator, and 82% late frames on the Pixel while the - // transcript itself cost 0.25ms. - var composerHeight by remember { mutableIntStateOf(0) } - val imeInsets = WindowInsets.ime - val navInsets = WindowInsets.navigationBars - // Ground truth for whether the keyboard is up, independent of `imeInsets` -- which is what - // rescues this from a real fault rather than merely reading the same thing twice. `imeInsets` - // is driven by the animation as it interpolates and is dispatched every frame; `isImeVisible` - // is dispatched once, from the platform's own start/end of the transition, over a different - // path. - // - // Reported from a phone: closing the keyboard on purpose, while a reply was streaming, left the - // composer floating above the bottom of the screen for the rest of the session. The likely - // cause is the animation callback that carries `imeInsets` back to zero being interrupted mid- - // flight -- a streaming reply invalidates the view every frame, which is exactly the condition - // known to starve a running `WindowInsetsAnimationCallback` of its `onEnd` -- and the stale - // partway value it leaves behind has nothing left to correct it. `isImeVisible` is not - // interpolated, so there is nothing for a dropped frame to interrupt. - val imeVisible = WindowInsets.isImeVisible - - // What this session is costing to draw, copied out to somewhere it can be read. - // - // Written here rather than beside the control that runs it, because everything it measures is - // this composable's own state and a control in a dialog cannot reach it. The control is a row - // in [SessionSettingsDialog], where the session's other about-the-session controls are. It - // copies rather than opens, because what it produces is a message to whoever is looking at the - // code. - // - // Whatever presses this, it is found by its **name**: `ui-trace`'s tap-by-label action resolves - // "Session settings" and then "Copy render timings" from what is on screen at that moment, so - // the bench scripts keep working when this moves again. They pressed it at a hand-measured - // coordinate until 2026-09-03, and anything that moved the header made that tap land on - // whatever now sat there -- reporting a number that was never measured. - // Shared by the ordinary "Copy" button and (bench build only) "Run benchmark": what differs - // between them is only whether there is a [extra] section, built by BenchRun.run beforehand -- - // everything about assembling, copying and logging the report is exactly the same act either - // way, and a second copy of it beside `onRunBenchmark` below would be the two silently - // disagreeing about what "the report" contains the first time either one changed. - fun buildAndCopyReport(extra: List = emptyList()) { - val report = - debugReport( - device = - "device: ${Build.MODEL} (${Build.MANUFACTURER})," + - " Android ${Build.VERSION.RELEASE}\n" + - // A debuggable build runs Compose at a fraction of release speed, so a - // report that did not say which it came from was read as the app's own - // cost. - "build: ${if (debuggable(context)) "debug" else "release"}", - transcript = - listOf( - " ${items.size} events, ${rows.size} rows, ${units.size} units loaded", - " viewport ${listState.layoutInfo.viewportSize.height}px," + - " ${listState.layoutInfo.visibleItemsInfo.size} units visible", - visibleUnits(units, listState.layoutInfo.visibleItemsInfo, UNITS_START), - " ${expandedTools.size} tool calls and ${expandedGroups.size} groups open", - ), - frames = FrameStats.lines(context.refreshHz()), - accounting = - FrameStats.drawPhase().let { (nanos, count) -> drawAccounting(nanos, count) }, - crash = lastCrash(context), - extra = extra, - // Empty outside a BenchRun.run pass -- copyRenderReport's own reset below clears - // the - // marks along with everything else, so an ordinary copy never has any to show. - phaseFrames = FrameStats.phaseLines(context.refreshHz()), - ) - context.copyToClipboard("ai-app render report", report) - // Also to the log, so a session driving the app over adb can read the same report the - // button copies. The clipboard is not reachable from a shell. - Log.i("ai-app", report) - // Only once it is somewhere it can be read from, so a copy that never happened does not - // throw the stack away with it. - clearCrash(context) - // Emptied by the copy, so pressing it twice measures two separate stretches of scrolling. - FrameStats.reset() - DebugStats.reset() - Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT).show() - } - val copyRenderReport = { buildAndCopyReport() } - // Bench build only: P0's scripted fling/stream/type/keyboard benchmark (BenchRun.kt), against - // the fixture session opened below instead of a real server. Null everywhere else -- see - // [SessionSettingsDialog]'s onRunBenchmark. - val runBenchmark: (() -> Unit)? = - if (BuildConfig.FIXTURE_MODE) { - { - settingsOpen = false - scope.launch { - val extra = - BenchRun.run( - context = context, - scope = scope, - listState = listState, - flingBehavior = flingBehavior, - composerFocus = composerFocus, - setComposerText = { text -> input = atEnd(text) }, - view = view, - ) - buildAndCopyReport(extra) - } - } - } else null - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - GlyphButton(BACK_GLYPH, "Back", onBack) - // A ring's worth, which is what the arrow already keeps on its other three sides. - Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) - Column(Modifier.weight(1f)) { - // A subagent's own title, with the session's beneath it in a smaller style -- - // the header says whose conversation this is as well as what it is. Otherwise - // just the session's title, as before. - if (subagent != null) { - Text(subagent.title, style = MaterialTheme.typography.titleMedium) - Text( - title, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - Text(title, style = MaterialTheme.typography.titleMedium) - // Machine first, then what runs on it -- the same order and the same - // wording everywhere this pair appears, so it reads as one fact rather than - // two sentences with different grammar. - // - // No model. The picker in the footer already shows what this session is set - // to, and showing it twice means two things to keep in step -- they - // disagreed for a moment on every model change. - Text( - "${summary.setupName} · ${summary.provider}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - // None of this is a subagent's: it has no files of its own to browse, no settings, - // and nothing meters it -- see SUBAGENTS.md's "Phone". - // - // Beside the provider it reports on, which is the line directly to its left. Its - // real home is this provider's settings, which do not exist yet. A session on a - // provider with no such service gets an honest "unavailable" rather than a hidden - // button -- a control that comes and goes makes its absence the signal, and absence - // cannot say why. - // - // Coloured by the worst window behind it, so the row says whether the limits are - // worth opening before anybody opens them. The theme's plain control colour - // whenever there is no measurement, since blue is the low end of the scale here and - // would read as "checked, and fine" about a machine nobody could reach. - // - // Usage, files, settings -- widest scope first, narrowing to the right, so the cog - // stays at the end where every other screen keeps it. Asked for in this order by - // Iris on 2026-09-03. - if (!isSubagent) { - Row { - GlyphButton( - USAGE_GLYPH, - "Usage", - { usageOpen = true }, - colour = usageGlyphColour(usage), - ) - // The machine's files, which is where the answer to "what did it actually - // change" is. It opens *over* this screen rather than replacing it. - GlyphButton( - FOLDER_GLYPH, - "Files", - onClick = { - onFiles( - FilesTarget( - setup = summary.setup, - setupName = summary.setupName, - // Where this session works, and the machine's own home when - // it was never given a directory -- resolved there rather - // than guessed at here, since this app does not know that - // home. - start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", - ) - ) - }, - ) - // What it opens is about this session, so it sits at the end of the - // session's own row. A cog and not a word because there will be more, and a - // bar of words has nowhere to put it. - GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) - } - } - } - - // Under the header, above everything the session itself says: it is a fact about the - // machine rather than a turn in the conversation, and it is the number that decides - // whether to keep going. Nothing meters a subagent. - if (!isSubagent) { - SessionUsageBar(usage) - } - - (streamError ?: actionError)?.let { message -> - Text( - message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - // The transcript, reversed: item zero is the newest message and sits at the bottom, so - // the first frame of a session is already the right one; see [TranscriptList]. - // - // Drawn only once there is nothing left to put back. Held out of the drawing rather - // than out of the composition, so the restore's scroll is applied against a list that - // is fully built. - val settled = !restoring - Box( - Modifier.weight(1f) - .fillMaxWidth() - // The room the floating composer needs, measured off it below -- reserving it - // here is what lets the composer be an overlay without covering the newest - // message. This modifier is the whole of what the keyboard re-measures: the - // box's own size never changes, so nothing above it is touched. - .padding(bottom = with(LocalDensity.current) { composerHeight.toDp() }) - // The keyboard's room, and only while the platform says there is a keyboard -- - // dropping the modifier is what coerces the stuck-open animated value to zero. - // It has to stay a *modifier* rather than a padding computed here: `imePadding` - // reads the inset in the layout phase, so a keyboard frame re-measures this box - // and nothing else, while reading `imeInsets` in this composable body - // subscribes the whole of `SessionScreen` to a value that changes every frame - // -- 16 full recompositions per keyboard open against one, and the transcript's - // position behind a recomposition while the composer's stayed a draw-phase - // read. - .then(if (imeVisible) Modifier.imePadding() else Modifier) - ) { - Box(Modifier.fillMaxSize()) { - TranscriptList( - units = units, - state = listState, - moreHistory = moreHistory, - selection = selection, - modifier = - Modifier.fillMaxSize().drawWithContent { if (settled) drawContent() }, - below = { - // The last thing in the transcript, because that is where they are in - // the session's reading of events: after everything it has taken in, - // and not yet taken in themselves. What the session is *doing* about - // them is a line below, in [SessionStatusRow]. - if (queued.isNotEmpty() || waitingCommands.isNotEmpty()) { - // The gap the arrangement no longer provides: this item sits flush - // against the newest message otherwise. - Column( - Modifier.padding(top = TRANSCRIPT_SPACING), - horizontalAlignment = Alignment.End, - ) { - waitingCommands.forEach { (_, text) -> - CommandBubble(text, waiting = true) - } - queued.forEach { waiting -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = waiting.text, - attachments = waiting.attachments, - onOpenImage = ::openImage, - pending = true, - refusal = waiting.refusal, - // The bubble goes away on the `messageDropped` this - // produces, not here: the server knows whether the - // message was still its to take back, and the other - // devices have to be told by the same event. - onTakeBack = { takeBack(waiting.id) }, - ) - } - } - } - }, - ) { unit -> - when (unit) { - is TranscriptUnit.Block -> MarkdownPiece(unit.text, unit.piece, replies) - is TranscriptUnit.ShowAll -> - ShowAllRow(unit.lines) { - // Anchored like every other control that changes a row's - // height: the reader is looking at the row this belongs to, and - // it is about to get much taller. - expandAnchored(unit.row) { - shownWholeRows = shownWholeRows + unit.row - } - } - is TranscriptUnit.PeerHead -> - PeerHeadRow( - unit.item, - unit.open, - onToggle = { togglePeer(unit.item.seq) }, - ) - is TranscriptUnit.PeerBlock -> - PeerBlockRow(unit, replies, onToggle = { togglePeer(unit.seq) }) - is TranscriptUnit.UserChunk -> - UserChunkRow(unit, settings, summary.id, ::openImage) - is TranscriptUnit.Memory -> - MemoryNote( - unit.part, - replies, - unit.part.text in openMemories, - ) { - toggleMemory(unit.part.text) - } - is TranscriptUnit.Whole -> { - val row = unit.row - Box( - Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> - // A *request*, not a raw scroll delta: this runs inside - // the measure pass that discovered the new height, and - // a raw delta forces a synchronous remeasure from - // within measure, which is fatal. The request is - // applied by the same frame's next remeasure, so the - // correction still lands before anything is drawn. - // Reads unobserved, or this row's measure would inherit - // the scroll position as a dependency and remeasure on - // every frame. - Snapshot.withoutReadObservation { - listState.requestScrollToItem( - listState.firstVisibleItemIndex, - (listState.firstVisibleItemScrollOffset + grew) - .coerceAtLeast(0), - ) - } - } - // Which half of this row the touch landed in, for - // [toggleAnchored]. On the initial pass and consuming - // nothing, so every control inside still gets the gesture; - // only visible rows have one, which is what makes a - // detector per row affordable. - .pointerInput(row.key) { - awaitEachGesture { - val down = - awaitFirstDown( - requireUnconsumed = false, - pass = PointerEventPass.Initial, - ) - lastTouch.key = row.key - lastTouch.high = down.position.y < size.height / 2f - } - } - ) { - when (row) { - is TranscriptRow.Tools -> - ToolGroup( - group = row, - expanded = row.id in expandedGroups, - onToggle = { - toggleAnchored(row) { - expandedGroups = - if (row.id in expandedGroups) - expandedGroups - row.id - else expandedGroups + row.id - } - }, - isToolExpanded = { it in expandedTools }, - // Anchored on the group, not the call: opening one - // call makes the whole group taller, and the - // heading the reader is under is the group's. - onToolToggle = { id -> - toggleAnchored(row) { - expandedTools = - if (id in expandedTools) - expandedTools - id - else expandedTools + id - } - }, - onAnswer = ::answerAll, - image = { ref -> - SessionImage( - settings, - summary.id, - ref, - ::openImage, - ) - }, - isWhole = { it in shownWholeCalls }, - onShowAll = { capped -> - toggleAnchored(row) { - shownWholeCalls = shownWholeCalls + capped - } - }, - ) - is TranscriptRow.Single -> - when (val item = row.item) { - is TranscriptItem.UserMsg -> - UserBubble( - settings = settings, - sessionId = summary.id, - text = item.text, - attachments = item.attachments, - onOpenImage = ::openImage, - ) - is TranscriptItem.AssistantMsg -> - // A whole assistant row is only ever the reply - // still arriving -- every settled reply is - // flattened into block units instead. Live is - // what earns its blocks a layer each while - // deltas land. - AssistantMessage( - item.text, - replies, - openNotes = openMemories, - onToggleNote = ::toggleMemory, - live = true, - ) - is TranscriptItem.ToolRun -> - ToolCard( - tool = item, - expanded = item.id in expandedTools, - onToggle = { - toggleAnchored(row) { - expandedTools = - if (item.id in expandedTools) - expandedTools - item.id - else expandedTools + item.id - } - }, - onAnswer = ::answerAll, - image = { ref -> - SessionImage( - settings, - summary.id, - ref, - ::openImage, - ) - }, - isWhole = { part -> - Capped(item.id, part) in shownWholeCalls - }, - onShowAll = { part -> - toggleAnchored(row) { - shownWholeCalls = - shownWholeCalls + - Capped(item.id, part) - } - }, - ) - is TranscriptItem.QuestionCard -> - QuestionRow(item, ::answerAll) - is TranscriptItem.ErrorMsg -> - Text( - item.message, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - is TranscriptItem.ImageItem -> - SessionImage( - settings, - summary.id, - item.ref, - ::openImage, - ) - is TranscriptItem.Note -> - Text( - item.text, - style = MaterialTheme.typography.bodySmall, - color = - MaterialTheme.colorScheme - .onSurfaceVariant, - ) - is TranscriptItem.CommandRow -> - CommandBubble(item.text) - is TranscriptItem.ClearedNote -> ClearedRow() - is TranscriptItem.CompactedNote -> - CompactedRow(item) - is TranscriptItem.LimitNote -> LimitRow(item) - // Never reached: a peer message is flattened into - // its own units. Here because a `when` over the - // item kinds has to stay exhaustive. - is TranscriptItem.PeerNote -> - PeerHeadRow(item, open = false, onToggle = {}) - } - } - } - } - } - } - } - - // Still finding out what this conversation is: the newest page has not arrived, or - // it has and the list is being put back where reading stopped. Both draw no rows at - // all, and a blank page is what this screen otherwise means by "there is nothing - // here" -- so the state that does not know needs its own appearance. - // - // In the middle of the transcript rather than at either end, because it is standing - // in for all of the rows. `settled` and not `restoring` alone, so the spinner - // covers the whole wait: fetching the history a saved position needs, and then the - // frames between those rows arriving and the layout that puts the position back. - if (!ready || !settled) { - CircularProgressIndicator( - Modifier.align(Alignment.Center).size(LOADING_SPINNER) - ) - } - - // Only while the newest message is off-screen. Reading back through a conversation - // is a place to be, not a state to be rescued from. - // - // Down, and the same chevron a tool group collapses with: the list is built upside - // down internally, but nobody reading it knows that. The name is carried in the - // description, since an arrow alone says nothing to a screen reader. - if (!atNewest) { - Surface( - // Instantly. An animated scroll travels the whole transcript, so the - // further back somebody has read the longer this takes -- the one press - // whose cost grows with how much there is to skip, which is backwards. - // - // Arriving there is all this has to do: the newest end is where the content - // hangs from, so being at it is the whole of following it. That is what - // this press used to forget, landing the reader at the bottom with new - // messages not bringing the view with them. - onClick = { scope.launch { listState.scrollToItem(0) } }, - shape = CircleShape, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = - Modifier.align(Alignment.BottomCenter) - .padding(bottom = 12.dp) - .semantics { contentDescription = "Jump to latest" }, - ) { - Chevron( - Pointing.Down, - colour = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - } - } - } - } - - // Everything from here down floats: bottom-aligned over the transcript, moved up with the - // keyboard by a translation on its own layer. The translation is read inside the - // graphicsLayer block, so a keyboard frame invalidates layer properties only. Its height is - // reported to the transcript box above, which reserves that much room; the opaque - // background covers the one frame between this growing and that reservation catching up. - Column( - Modifier.align(Alignment.BottomCenter) - .fillMaxWidth() - .onSizeChanged { composerHeight = it.height } - .graphicsLayer { - // See `imeVisible` above: a callback interrupted mid-close leaves this stuck - // reading a stale height, and without the guard the composer floats above the - // bottom of the screen for good. - translationY = - if (imeVisible) { - -(imeInsets.getBottom(this) - navInsets.getBottom(this)) - .coerceAtLeast(0) - .toFloat() - } else { - 0f - } - } - .background(MaterialTheme.colorScheme.background) - ) { - pendingModel?.let { chosen -> - ModelSwitchWarning( - from = modelLabel(model), - to = modelLabel(chosen), - onDismiss = { pendingModel = null }, - onConfirm = { - pendingModel = null - act { setSessionModel(settings, summary.id, chosen) } - }, - ) - } - - // Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns - // "exited" into "finished" for one, since it has no process to leave running or stop. - SessionStatusRow( - status = status, - compactingFor = compactingFor, - contextTokens = contextTokens, - subagent = isSubagent, - ) - - // Everything from here down is the composer: a subagent cannot be messaged, so none of - // it applies -- see SUBAGENTS.md's "Phone". - if (!isSubagent) { - // Between the transcript and the box: above what is being typed, so the list does - // not cover the thing the command is about, and below everything that explains it. - CommandSuggestions( - // Nothing to suggest about a suggestion that was just taken. `/compact` is a - // whole command *and* a prefix of itself, so picking it left the list standing - // there with the one row already chosen. Held by what was picked rather than by - // a flag, so typing anything else brings the list back without a second thing - // to - // reset. - commands = - if (input.text == picked) emptyList() else suggestedCommands(input.text), - onPick = { command -> - // At the end of what was inserted, which is where the reader carries on - // typing: a command with an argument is put in the box half-written, and a - // cursor left at the front makes the next keystroke the first character of - // "/rename". - input = atEnd(command.typed()) - picked = command.typed() - }, - ) - - // Always enabled -- a send while the session is running becomes a steering message - // injected at the next tool boundary, which is the point of the whole app. - // - // The field gets a row of its own, above the buttons: sharing one put the full - // width - // behind three controls, so the thing being typed into was the narrowest on the - // row. - Column(Modifier.fillMaxWidth().padding(8.dp)) { - // Directly above the box they will be sent from, so what is attached is visible - // rather than counted: the "+2" on the button below said how many and never - // which. - PendingAttachments( - settings = settings, - sessionId = summary.id, - refs = pendingAttachments, - onRemove = { pendingAttachments = pendingAttachments - it }, - ) - OutlinedTextField( - value = input, - onValueChange = { - input = it - saveDraft(context, summary.id, it.text) - }, - // BenchRun's type phase requests focus on this exact field - // (`composerFocus`) - // so it types through the real composer rather than a stand-in. - modifier = Modifier.fillMaxWidth().focusRequester(composerFocus), - // No longer "(+image)": the images are on screen above this, and a - // placeholder saying so said it in words beside the thing itself. - placeholder = { Text("Message") }, - maxLines = 4, - ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Photo or file, asked here rather than by two buttons: the row is full, - // and - // attaching is one action whichever picker answers it. - var attaching by remember { mutableStateOf(false) } - Box { - // Just "+". The count it used to carry was standing in for showing - // them. - BubbleButton(onClick = { attaching = true }) { Text("+") } - DropdownMenu( - expanded = attaching, - onDismissRequest = { attaching = false }, - // See PickerButton: without this the menu opens a status bar's - // height away from the button in an edge-to-edge activity. - properties = PopupProperties(clippingEnabled = false), - shape = BubbleMenuShape, - ) { - DropdownMenuItem( - text = { Text("Photo") }, - onClick = { - attaching = false - pickImage.launch( - PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageOnly - ) - ) - }, - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - attaching = false - pickFile.launch(arrayOf("*/*")) - }, - ) - } - } - // The settings share what is left after the actions have taken what they - // need. A Row hands out intrinsic widths in order and clips whatever runs - // past the edge, so with these laid out first the arrival of Stop pushed - // Send off the screen entirely -- the app's central control, gone at the - // moment it is most in use. - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), - ) { - if (offeredModels.isNotEmpty()) { - PickerButton( - current = modelLabel(model), - // What the machine offers, plus the state a session is in when - // it has chosen none of them. The button has always been able - // to - // say "default"; until this the list could not, so leaving it - // was a one-way trip. - options = listOf(DEFAULT_MODEL) + offeredModels, - // Not set here. The button follows what the session reports it - // is set to, which arrives a moment later and is sometimes a - // different answer -- a name the CLI resolved, or no change at - // all on a provider whose model is fixed. Asked about first, - // unless there is nothing to lose by it -- see - // [ModelSwitchWarning]. - onPick = { chosen -> - if ( - modelLabel(chosen) == modelLabel(model) || - !worthWarningAbout(status, contextTokens, items) - ) { - act { setSessionModel(settings, summary.id, chosen) } - } else { - pendingModel = chosen - } - }, - ) - } - PickerButton( - current = permissionMode, - options = PERMISSION_MODES, - onPick = { chosen -> - act { setSessionPermissionMode(settings, summary.id, chosen) } - }, - ) - } - // The same filled shape as the button beside it, not an outlined one: these - // are two things you can do about the session, and weighting one as - // secondary said they were a primary action and its qualifier. What - // separates them is the colour and the mark, which is what they mean. - // - // Always here, rather than arriving with the turn as it used to. A control - // that comes and goes makes its own presence the signal, and a button - // always - // in the same place also cannot push Send off the end of the row by turning - // up. - val process = - when { - running -> ProcessAction.Pause - status == "exited" -> ProcessAction.Start - else -> ProcessAction.Stop - } - Button( - onClick = { - processInFlight = true - act(onDone = { processInFlight = false }) { - process.perform(settings, summary.id) - } - }, - enabled = !processInFlight, - colors = actionButtonColors(process.colour()), - ) { - Glyph( - process.glyph, - colour = LocalContentColor.current, - modifier = - Modifier.semantics { contentDescription = process.label }, - ) - } - Spacer(Modifier.width(8.dp)) - // The paper plane, with a clock on it while a turn is in flight: sending - // then queues the message for the next tool boundary rather than starting a - // turn of its own, and the two have to be told apart at a glance. The label - // says the same thing to a screen reader. - // - // Disabled while there is nothing to send, rather than pressable and - // silent: - // `send` has always returned early on an empty composer, so the button - // promised something it would not do. Disabled and not hidden, for the - // reason above. - Button( - onClick = { send() }, - enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), - colors = actionButtonColors(if (running) queueColor else sendColor), - ) { - Glyph( - if (running) QUEUE_GLYPH else SEND_GLYPH, - colour = LocalContentColor.current, - modifier = - Modifier.semantics { contentDescription = sendLabel(running) }, - ) - } - } - } - } - } - } - - // Beside the other two dialogs, and outside the list for the same reason as them: what is open - // is the screen's business rather than any row's. See [SessionImageViewer]. - fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } - if (usageOpen) { - usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) } - } - if (settingsOpen) { - // Measured when the dialog opens rather than kept up to date: what the reader is being told - // is what pressing the button now would discard, and null until the walk of the directory - // returns is what not knowing looks like. - var cachedBytes by remember(summary.id, epoch) { mutableStateOf(null) } - LaunchedEffect(summary.id, epoch) { - cachedBytes = withContext(Dispatchers.IO) { source.cache.bytes() } - } - SessionSettingsDialog( - settings = settings, - sessionId = summary.id, - title = title, - effort = summary.effort.takeIf { summary.takesEffort }, - takesEffort = summary.takesEffort, - cachedBytes = cachedBytes, - // The purge finishes before the epoch moves, because the relaunched opening effect - // reads the same directory and would otherwise draw what is about to be deleted. The - // epoch is what makes the rest a cold open. - onReload = { - settingsOpen = false - scope.launch { - withContext(Dispatchers.IO) { source.cache.purge() } - dropLoadedTranscript() - lastSeq.set(0) - ready = false - epoch++ - } - }, - // The header takes the new name at once and the dialog closes on it, because the rename - // has already been accepted by the server -- see [title], which is this app's own - // datum. - onRenamed = { - title = it - settingsOpen = false - }, - onDismiss = { settingsOpen = false }, - onCopyRenderReport = copyRenderReport, - onRunBenchmark = runBenchmark, - ) - } -} - -/** What pressing Send does right now, said the same way to the eye and to a screen reader. */ -private fun sendLabel(running: Boolean) = if (running) "Queue" else "Send" - -/** - * What the composer's process button would do if it were pressed now. - * - * One value rather than four parallel conditions over the status, because the mark, the colour, the - * name a screen reader is given and the request that goes out are four halves of one decision. A - * button drawn as a pause that terminates the CLI is the worst bug available here, and separate - * branches over the same condition are how that happens. - */ -private enum class ProcessAction(val glyph: String, val label: String) { - /** A turn is running: take it back, and leave the process holding the conversation. */ - Pause(PAUSE_GLYPH, "Pause"), - /** Nothing is running, but the process behind the session is: end it. */ - Stop(STOP_GLYPH, "Stop"), - /** The process is gone: start it again, on the conversation it left. */ - Start(PLAY_GLYPH, "Start"), -} - -@Composable -private fun ProcessAction.colour() = - when (this) { - ProcessAction.Pause -> pauseColor - ProcessAction.Stop -> stopColor - ProcessAction.Start -> startColor - } - -private fun ProcessAction.perform(settings: ServerSettings, sessionId: String) = - when (this) { - ProcessAction.Pause -> interruptSession(settings, sessionId) - ProcessAction.Stop -> stopSession(settings, sessionId) - ProcessAction.Start -> startSession(settings, sessionId) - } - -/** - * One slice of a long user message, on the same bubble the first slice starts. - * - * Full width, unlike the wrapping bubble: slices have to share a width to read as one card, and a - * message long enough to be sliced has lines that wrap anyway -- see [USER_SPLIT_CHARS]. - */ -@Composable -private fun UserChunkRow( - unit: TranscriptUnit.UserChunk, - settings: ServerSettings, - sessionId: String, - onOpenImage: (String) -> Unit, -) { - Column( - Modifier.padding(start = 48.dp) - .cardPiece( - top = unit.first, - bottom = unit.last, - fill = MaterialTheme.colorScheme.primaryContainer, - ) - ) { - Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer) - // The same arrangement [UserBubble] gives them: under the words, on the last slice because - // that is the bubble's bottom. - unit.attachments.forEachIndexed { index, ref -> - if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp)) - Attachment(settings, sessionId, ref, onOpenImage) - } - } -} - -/** - * A message the person holding the phone sent, in a bubble at their end of the conversation. - * - * [pending] is one the server has taken and the session has not read yet -- drawn quieter, because - * "said" and "heard" are different claims and the transcript must not merge them. - * - * A pending bubble is tappable: [onTakeBack] asks the server to drop the message before the session - * reads it, and [refusal] is what came back when it would not. The refusal is drawn here rather - * than with the screen's other errors because this is where the reader pressed. - * - * A settled message longer than [USER_SPLIT_CHARS] is drawn as [UserChunkRow] slices instead -- one - * `Text` holding a pasted log is a hundred-thousand-pixel layout in the frame the row scrolls into. - */ -@Composable -private fun UserBubble( - settings: ServerSettings, - sessionId: String, - text: String, - attachments: List = emptyList(), - onOpenImage: (String) -> Unit, - pending: Boolean = false, - refusal: String? = null, - onTakeBack: (() -> Unit)? = null, -) { - Box(Modifier.fillMaxWidth()) { - Card( - // A message the session has not read yet is drawn quieter than one it has. The - // difference is in degree -- said, not yet heard -- which is what colour alone can - // carry. - colors = - CardDefaults.cardColors( - containerColor = - if (pending) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.primaryContainer - ), - modifier = - Modifier.align(Alignment.CenterEnd) - .padding(start = 48.dp) - .then( - if (onTakeBack == null) Modifier - else - Modifier.clickable(onClick = onTakeBack).semantics { - // The bubble is its own control and its own label; without this the - // only thing to read is the message, which does not say what - // pressing it does. - contentDescription = "Waiting to be read; tap to take it back" - } - ), - ) { - Column(Modifier.padding(12.dp)) { - // A message can be nothing but an attachment, and an empty line above a picture is - // a bubble with a gap in it for a sentence nobody wrote. - if (text.isNotEmpty()) { - Text( - text, - color = - if (pending) MaterialTheme.colorScheme.onSurfaceVariant - else MaterialTheme.colorScheme.onPrimaryContainer, - ) - } - // Under the words: what somebody wrote is what the bubble is, and the picture is - // what they attached to it. It also keeps the first line of every bubble at the - // same place down the transcript. - attachments.forEachIndexed { index, ref -> - if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) - Attachment(settings, sessionId, ref, onOpenImage) - } - refusal?.let { - Spacer(Modifier.height(6.dp)) - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - } -} - -/** - * A message the server has accepted and the session has not read yet. - * - * [refusal] is why taking it back did not work, kept per message rather than on the screen: two - * bubbles can be waiting at once, and an error above them both would not say which. - */ -private data class QueuedMessage( - val id: String, - val text: String, - val attachments: List, - val refusal: String? = null, -) - -/** - * Whether a model switch has anything to warn about -- see [ModelSwitchWarning]. - * - * What the warning is about is a *cache* being dropped, so the question is whether there is one. - * Two answers say there is not, and both used to produce the dialog anyway: a session whose process - * has exited has nothing running to hold a cache, and a session reporting zero context is holding - * nothing. - * - * Where the figure is *unknown* rather than zero the fallback is whether anything has been said - * **since the last clear**. Unknown is not nothing, and treating it as nothing would drop the - * warning on exactly the sessions -- an import, a fresh reattach -- where nobody has measured yet. - * But a clear is the one case that makes the whole loaded transcript stop counting: it leaves the - * conversation on screen and takes it out of the session's context, and the server reports the - * context as unmeasured afterwards rather than as zero. So the reading that used the whole list - * warned about dropping a cache the clear had already dropped. - * - * With no clear anywhere in what is loaded this is the old reading exactly, which is the - * conservative answer for a clear further back than the loaded window. - */ -private fun worthWarningAbout( - status: String, - contextTokens: Long?, - items: List, -): Boolean = - when { - status == "exited" -> false - contextTokens != null -> contextTokens > 0 - else -> items.asReversed().takeWhile { it !is TranscriptItem.ClearedNote }.isNotEmpty() - } - -/** - * Asked before switching model, because switching is not free and the cost is invisible. - * - * A model change drops the cached context: the next turn re-reads the entire conversation and is - * charged for it. Measured on 2026-08-29 against a small session -- the turn before the switch read - * 30,771 tokens from cache and created 87; the turn after read **nothing** from cache and created - * 41,509. - * - * No number is offered here, deliberately. What it will cost depends on how long *this* - * conversation is, and this screen does not know that -- a figure worked out from what has been - * spent would be a guess in a measurement's clothes. - * - * The permission-mode picker beside it deliberately has no equivalent, which the same measurement - * decided: changing mode kept the cache (30,858 read, 75 created). Warning on both would teach the - * reader that these dialogs can be clicked through. - */ -@Composable -private fun ModelSwitchWarning( - from: String, - to: String, - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Switch to $to?") }, - text = { - Text( - "The session re-reads the whole conversation on its next turn: leaving $from " + - "drops the cached context, so that turn costs as much as the conversation " + - "is long. Nothing is lost -- it is read again, not forgotten." - ) - }, - confirmButton = { TextButton(onClick = onConfirm) { Text("Switch") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Keep $from") } }, - ) -} - -/** - * What the session is doing, and what the conversation has cost, on one line above the box. - * - * A row of its own because both are facts about the session rather than turns in it, and both were - * previously drawn over the transcript: the token total floated in its bottom corner, where a long - * message ran underneath it, and the working indicator was an item inside the list, so it scrolled - * away exactly when somebody reading back wanted to know whether anything was still happening. - * - * The row is drawn whether or not it has anything to say. An empty one costs a line; a row that - * came and went would move the text box under the reader's thumb every time a turn started. - * - * `exited` is here because a session whose process is gone cannot be typed at, and with the - * indicator gone from the list nothing else on this screen would say so. - */ -@Composable -private fun SessionStatusRow( - status: String, - /** Seconds since this device saw the compaction start; null if it did not see it. */ - compactingFor: Long?, - /** Context the session is holding, or null where nothing has measured it. */ - contextTokens: Long?, - modifier: Modifier = Modifier, - /** - * Whether this row is for a subagent rather than a session, which changes only one word: - * "exited" reads as "finished" there too, the same as the subagent list's own card -- a - * subagent's process was always its parent's, so "exited" would read as a fault rather than the - * ordinary way one of these ends. - */ - subagent: Boolean = false, -) { - DebugStats.count("status row recomposed") - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - ) { - when (status) { - // A bar rather than the spinner an ordinary turn gets, and it takes the row's whole - // free width: nothing arrives in the transcript during a compaction, so this is the - // only thing on screen that is moving, and at a spinner's width that reads as a session - // that has hung. - // - // Indeterminate, which is a statement rather than an omission. The CLI says a - // compaction has begun and then nothing at all until it has finished -- measured on a - // real 80,346-to-2,088-token compaction that took 23 seconds and produced not one line - // in between. So there is no fraction to fill, and a bar creeping along at the pace of - // the last one would be this screen inventing the part nobody sent it. - "compacting" -> { - Text( - compactingLabel(compactingFor), - style = MaterialTheme.typography.labelSmall, - // Stated beside the fill rather than inherited: a semantic colour has to carry - // its own contrast, since the surface under it will not change to rescue it. - color = commandColor, - ) - LinearProgressIndicator( - color = commandColor, - trackColor = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier.weight(1f).padding(horizontal = 8.dp), - ) - } - "running" -> { - CircularProgressIndicator( - // Smaller than the line beside it, so the row keeps the text's own height: a - // control taller than a line re-centres it and knocks it out of line with the - // total on the other end. - modifier = Modifier.width(12.dp).height(12.dp), - strokeWidth = 2.dp, - ) - Text( - "working", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp), - ) - Spacer(Modifier.weight(1f)) - } - // Every remaining state says which one it is, including the quiet one. The row used to - // name only `exited` and leave the rest blank, so a session sitting idle and one whose - // status nobody could read looked identical -- and a turn that had just been stopped - // showed nothing at all. The words are the session list's own, so one state is not - // called two things depending which screen you are on. - else -> - Text( - when (status) { - "idle" -> "idle" - "exited" -> if (subagent) "finished" else "exited" - "awaitingInput" -> "your turn" - "unknown" -> "can't tell" - else -> status - }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - } - // How full the session is, which is the number a reader is asking about -- how much room is - // left before the next compaction -- rather than what has been spent getting here. - // - // "unknown" in words, and always drawn. A context nobody has measured is not an empty one, - // and the two used to share an appearance: a session just cleared, one whose provider never - // reports usage, and one that has not run a turn all showed nothing at all, which reads as - // a conversation with room to spare. - Text( - contextTokens?.let { "context ${tokens(it)}" } ?: "context unknown", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } -} - -/** - * A question (or permission request -- same shape) inline in the transcript. Option buttons until - * answered; then the chosen answer, which the `answered` event also resolves on every other - * connected device. - */ -@Composable -private fun QuestionRow( - question: TranscriptItem.QuestionCard, - onAnswer: (List, onSettled: () -> Unit) -> Unit, -) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - // The same body the questions on a tool call get, down to the submit button: one - // question is the same thing whether or not something asked it, and two renderings of - // it would be two places for an answer to go missing. - AskUserQuestionBody(listOf(question), onAnswer) - } - } -} - -/** - * [text] in the message box, with the cursor after it. - * - * Everything that puts words in the box without the reader typing them goes through here: a - * restored draft, a share arriving from another app, a slash command taken from the suggestions. - * All three used to leave the cursor at whatever offset it happened to hold -- which for a box that - * has never been focused is the very start, so picking `/rename` and typing put the name in front. - */ -private fun atEnd(text: String) = TextFieldValue(text, TextRange(text.length)) - -/** - * How long after a menu closes a press on its own button still counts as the press that closed it. - * - * Sized to one tap, because one tap is all it has to span -- [PickerButton] explains the pair of - * events it separates. Deliberately not the platform's long-press timeout, which is the longest a - * tap can legally be: half a second of ignoring the button would swallow a deliberate reopen. - */ -private const val ONE_TAP_MS = 250L - -/** - * A control that reads as its own value. - * - * The button *is* the current setting rather than a label beside one, so the row says what the - * session is set to without spending a second line on saying it. - */ -@Composable -fun PickerButton(current: String, options: List, onPick: (String) -> Unit) { - var open by remember { mutableStateOf(false) } - // When an outside touch last closed the menu. - // - // Pressing this button while its own menu is open is such a touch. The menu is deliberately not - // focusable (see below), so the press that dismisses it is also delivered to the window - // underneath -- which is this button. The dismissal arrives with the press and the click with - // the release, measured 3ms apart on the emulator, so a button that simply opened on every - // click would reopen what the same finger had just closed. - var closedAt by remember { mutableLongStateOf(0L) } - Box { - BubbleButton( - onClick = { if (SystemClock.uptimeMillis() - closedAt > ONE_TAP_MS) open = true } - ) { - // One line, truncated rather than wrapped: this sits in a row whose height is the - // buttons beside it, and a second line would move them. - Text( - current, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Two departures from the defaults, both deliberate. - // - // Not focusable, so opening it does not take focus from the message field and dismiss the - // keyboard. Changing the model mid-sentence is an aside. - // - // Not clipped, which is what puts the menu on the button instead of floating above it. - // Compose measures the anchor in *window* coordinates -- this app draws edge to edge, so - // that window is the whole screen -- but asks whether the menu fits inside the *visible* - // frame, which is the screen less the status and navigation bars. Two spaces, one - // comparison: sitting just above a button near the bottom then looks like an overflow, and - // the menu falls back to a fixed 48dp above the bottom of the visible frame -- measured on - // the emulator as 142px, the status bar's height exactly, clear of the button that opened - // it. What this gives up is that the keyboard stops counting as an edge, so with the IME up - // the menu opens downwards over it. That is the lesser fault, and correcting it would mean - // supplying a position provider this menu takes no parameter for. - DropdownMenu( - expanded = open, - onDismissRequest = { - open = false - closedAt = SystemClock.uptimeMillis() - }, - properties = PopupProperties(focusable = false, clippingEnabled = false), - shape = BubbleMenuShape, - ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(option) }, - onClick = { - open = false - if (option != current) onPick(option) - }, - ) - } - } - } -} - -private fun debuggable(context: Context) = - context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt deleted file mode 100644 index 1ecb917..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt +++ /dev/null @@ -1,562 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * What can be changed about one session, as opposed to about this app. - * - * Over the session rather than a step down from it: everything here is about the conversation - * behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a - * screen of its own until 2026-08-30, which put a page transition and a back stack around two - * controls and hid the thing they act on. - * - * The model and the permission mode are deliberately still on the session's own bar, because those - * are changed *while* reading a turn -- "not this model, try that one". - * - * Captions are for what a control costs rather than for what it is. A paragraph under every control - * made the dialog longer than the conversation it covers -- so Notifications has none, while Move - * and Reload do, because what those two take away is not visible from here. - */ -@Composable -fun SessionSettingsDialog( - settings: ServerSettings, - sessionId: String, - /** - * What the session is called now, as the screen behind this knows it -- see the rename below. - */ - title: String, - onRenamed: (String) -> Unit, - /** - * How hard the model thinks, as the session reports it, or null for the CLI's own default. - * - * Taken from the row this dialog was opened over rather than fetched, because unlike the - * notification switch there is nothing else that changes it: the level is this app's to set and - * the server does not resolve it into something else. - */ - effort: String?, - /** Whether a level does anything here; the row is left out entirely where it does not. */ - takesEffort: Boolean, - /** - * What this phone is holding of the conversation, or null while that is being measured -- see - * the Reload row below, which is what would discard it. - */ - cachedBytes: Long?, - onReload: () -> Unit, - onDismiss: () -> Unit, - /** - * Copies what this session costs to draw. Built by the session screen, because everything it - * measures is that screen's own state. - */ - onCopyRenderReport: () -> Unit, - /** - * Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on - * every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than - * always-present-but-disabled: this has no meaning at all outside the bench build, and a - * control with nothing behind it on every other build is not a state worth drawing. - */ - onRunBenchmark: (() -> Unit)? = null, -) { - val scope = rememberCoroutineScope() - var name by remember(sessionId) { mutableStateOf(title) } - var level by remember(sessionId) { mutableStateOf(effort) } - var effortError by remember { mutableStateOf(null) } - var saving by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - // Null until the server has been asked. The row this dialog was opened over is a snapshot of - // whenever the list was last fetched, so drawing the switch straight from it would show a - // position that may have been changed since. Until the answer arrives the switch is disabled - // and a spinner sits beside it, which is what not knowing looks like. - var notify by remember(sessionId) { mutableStateOf(null) } - var notifyError by remember { mutableStateOf(null) } - // The same three-state shape the notification switch has, for the same reason: until the - // server has answered, the switch is disabled rather than showing a position nothing confirmed. - var autoResume by remember(sessionId) { mutableStateOf(null) } - var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) } - // When the server next intends to ask whether the limit has lifted, or null when nothing is - // waiting. Read once with everything else: it moves on the server's schedule, not this - // screen's, and a figure that redrew itself here would be this app re-measuring what it was - // told. - var resumeAt by remember(sessionId) { mutableStateOf(null) } - var resumeError by remember { mutableStateOf(null) } - // Where the session works. Null until the server has been asked, for the same reason the switch - // above is. An empty answer is a session that was never given a directory, which is not the - // same as one whose directory is unknown -- the field is only enabled once one of those is - // settled. - var cwd by remember(sessionId) { mutableStateOf(null) } - var typedCwd by remember(sessionId) { mutableStateOf("") } - var cwdError by remember { mutableStateOf(null) } - var movingCwd by remember { mutableStateOf(false) } - - LaunchedEffect(sessionId) { - try { - val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) } - notify = fresh.notify - autoResume = fresh.autoResume - resumeMessage = fresh.autoResumeMessage - resumeAt = fresh.resumeAt - cwd = fresh.cwd.orEmpty() - typedCwd = fresh.cwd.orEmpty() - } catch (e: ApiException) { - // Left unknown rather than falling back to the stale row: the switch stays disabled, - // instead of offering a position nothing confirmed. - notifyError = e.message - notify = null - resumeError = e.message - autoResume = null - } - } - - /** - * Moves the session, which ends the process that is in the old directory. - * - * Said plainly beside the field rather than confirmed in a second dialog: what it costs is a - * process, and a stopped session is a state this app already has a word and a button for. - */ - fun moveCwd() { - val chosen = typedCwd.trim() - if (movingCwd || chosen.isEmpty() || chosen == cwd) return - movingCwd = true - cwdError = null - scope.launch { - try { - withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) } - cwd = chosen - } catch (e: ApiException) { - // Where it happened: this field is the only thing on screen that knows a move was - // asked for, and the reason is usually the path itself. - cwdError = e.message - } finally { - movingCwd = false - } - } - } - - /** - * Chooses a thinking level, which ends the process the old level was launched with. - * - * Put back if the request is refused, for the reason the notification switch below gives: a - * control that stays where it was put after a refusal is stating something untrue. - */ - fun setEffort(chosen: String?) { - val was = level - level = chosen - effortError = null - scope.launch { - try { - withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) } - } catch (e: ApiException) { - level = was - effortError = e.message - } - } - } - - // Moved optimistically so the switch answers the finger that moved it, and put back if the - // request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel, - // and one that stays moved after a refusal lies. - fun setNotify(wanted: Boolean) { - val was = notify - notify = wanted - notifyError = null - scope.launch { - try { - withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) } - } catch (e: ApiException) { - notify = was - notifyError = e.message - } - } - } - - /** - * Turns auto-resume on or off, or changes what it would say. - * - * One request for both, because the server takes one: switching it on and typing the message - * are two halves of the same decision, and sending them separately would leave a moment where - * the session is armed with the old words. - * - * Put back if refused, like the notification switch. Turning it off also clears what was - * scheduled -- said here rather than only on the server, or the row would go on naming a time - * that no longer exists. - */ - fun setAutoResume(on: Boolean, message: String) { - val wasOn = autoResume - val wasMessage = resumeMessage - val wasAt = resumeAt - autoResume = on - resumeMessage = message - if (!on) resumeAt = null - resumeError = null - scope.launch { - try { - withContext(Dispatchers.IO) { - setSessionAutoResume(settings, sessionId, on, message) - } - } catch (e: ApiException) { - autoResume = wasOn - resumeMessage = wasMessage - resumeAt = wasAt - resumeError = e.message - } - } - } - - // Nothing to do when the name has not changed, so the button says so rather than sending a - // request whose success would look exactly like the failure of having typed nothing. - val changed = name.trim().isNotEmpty() && name.trim() != title - - fun save() { - if (!changed || saving) return - val chosen = name.trim() - saving = true - error = null - scope.launch { - try { - withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) } - onRenamed(chosen) - } catch (e: ApiException) { - // Reported here, where it happened, because this dialog is the only place that - // knows a rename was attempted. - error = e.message - saving = false - } - } - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Session settings") }, - text = { - // Scrollable, because this dialog grew past a screenful: a Material dialog constrains - // its own height and clips what does not fit, so the last control on the list is one - // large system font away from being unreachable with nothing on screen to say so. - Column(Modifier.verticalScroll(rememberScrollState())) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - enabled = !saving, - modifier = Modifier.fillMaxWidth(), - // The keyboard's own action does what the button does: a one-field form where - // the return key does nothing is a form people press return at anyway. - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { save() }), - ) - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.width(8.dp)) - Text("Notifications", modifier = Modifier.weight(1f)) - if (notify == null && notifyError == null) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - } - Switch( - checked = notify == true, - onCheckedChange = { setNotify(it) }, - enabled = notify != null, - ) - } - // Beside the switch that failed, not with the rename's error: they are two requests - // and a reader has to be able to tell which one the server refused. - notifyError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Resume after a usage limit", modifier = Modifier.weight(1f)) - if (autoResume == null && resumeError == null) { - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - } - Switch( - checked = autoResume == true, - onCheckedChange = { setAutoResume(it, resumeMessage) }, - enabled = autoResume != null, - ) - } - // Disabled rather than hidden while the switch is off: a field that comes and goes - // makes its own presence the signal, and a visible one teaches what the switch will - // do. Committed on the keyboard's Done rather than on every keystroke, so typing a - // sentence is one request instead of one per letter. - OutlinedTextField( - value = resumeMessage, - onValueChange = { resumeMessage = it }, - label = { Text("Message to send") }, - // What an empty field means, in the field: the server's own word rather than a - // session poked with nothing to read. - placeholder = { Text(DEFAULT_RESUME_MESSAGE) }, - singleLine = true, - enabled = autoResume == true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = - KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }), - ) - // What it does and what it costs, in the order it happens. The last sentence is the - // one that matters: the time below is when the server will *ask*, not a promise - // about when the session speaks. - Text( - "When this session stops because the account is out of quota, the server " + - "checks the limit and sends this message once it has lifted. It checks " + - "again if the limit is still on.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // Only where something is actually waiting. Absent is not a state worth a row: a - // session that has not hit a limit has nothing scheduled, which the reader can see - // from the switch. - resumeAt?.let { at -> - Text( - "Waiting now -- next check ${formatCheckTime(at)}.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - resumeError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - OutlinedTextField( - value = typedCwd, - onValueChange = { typedCwd = it }, - label = { Text("Working directory") }, - // What the field cannot say by being empty: a session that was never given - // one starts wherever its launcher does, and this names that rather than - // showing a path nobody chose. - placeholder = { Text("wherever the session was started") }, - singleLine = true, - enabled = cwd != null && !movingCwd, - modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { moveCwd() }), - ) - TextButton( - onClick = { moveCwd() }, - enabled = - cwd != null && - !movingCwd && - typedCwd.trim().isNotEmpty() && - typedCwd.trim() != cwd, - ) { - Text(if (movingCwd) "Moving..." else "Move") - } - } - // The whole of what pressing Move does, where it is about to be pressed. A - // directory is settled when the process is spawned, so it is ended and the next - // thing said to the session starts it in the new place. - Text( - "Moving stops the session's process. It starts again in the new directory " + - "with the next message, or with Start.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - cwdError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - // Left out rather than disabled, the one place this dialog does that: a disabled - // control teaches what the thing can do, and a llama session cannot do this at all - // -- the row would be teaching something false about it. - if (takesEffort) { - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Thinking", modifier = Modifier.weight(1f)) - PickerButton( - current = level ?: DEFAULT_EFFORT, - // The level the CLI picks for itself is in the list as well as in the - // button, so leaving a level is not a one-way trip -- the same - // correction the model picker carries. - options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS, - onPick = { chosen -> - setEffort(chosen.takeIf { it != DEFAULT_EFFORT }) - }, - ) - } - // What it costs, said where it is about to be pressed, like Move above: the - // CLI reads the level when it launches and has no control request for - // changing one. - Text( - "Changing this stops the session's process. It starts again with the " + - "next message, or with Start.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - effortError?.let { - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - } - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Transcript", modifier = Modifier.weight(1f)) - // The size is what the button discards, and the unknown state is drawn rather - // than guessed: a spinner while the directory is being measured, and words when - // there is nothing there, because "nothing cached" and "0 B" read as different - // claims. - when { - cachedBytes == null -> - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - ) - else -> - Text( - humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(12.dp)) - // Enabled whether or not anything is cached: "what I see disagrees with the - // machine" is a state an empty cache can be in too, and a control that comes - // and goes makes its own presence the signal. - TextButton(onClick = onReload) { Text("Reload") } - } - // Captioned, unlike the controls above it, for the same reason Move is: what it - // costs is not visible, and neither is the case it exists for. - Text( - "Reload throws away this phone's copy and fetches the transcript from the " + - "server again. Use it when what is shown here disagrees with the file " + - "on the machine.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - error?.let { - Spacer(Modifier.height(8.dp)) - Text( - it, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) - } - Spacer(Modifier.height(8.dp)) - // About this session, which is what everything in here is -- and it was on the - // header until 2026-09-03, where the folder button now is. It copies rather than - // opening anything, so it says so and then says it happened: a row that looks like - // a control and gives no sign of having run is one people press twice. - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.width(8.dp)) - Text("Render timings", modifier = Modifier.weight(1f)) - TextButton(onClick = onCopyRenderReport) { Text("Copy") } - } - // Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because - // ui-trace and the emulator smoke run find it by that label, the same way every - // other control here is found -- see AGENTS.md's "Driving the UI". - onRunBenchmark?.let { run -> - Spacer(Modifier.height(8.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.width(8.dp)) - Text("P0 benchmark", modifier = Modifier.weight(1f)) - TextButton(onClick = run) { Text("Run benchmark") } - } - } - } - }, - // Disabled rather than absent while there is nothing to save: a button that comes and goes - // makes its own presence the signal, and its absence cannot say why. - confirmButton = { - TextButton(onClick = { save() }, enabled = changed && !saving) { - Text(if (saving) "Saving..." else "Save") - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } }, - ) -} - -/** - * When the server will next look, as a local time. - * - * A time rather than a countdown, for the reason the transcript's own limit row gives: this screen - * reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody - * is looking at it. - */ -private fun formatCheckTime(epochSeconds: Double): String = - try { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) - .withZone(ZoneId.systemDefault()) - .format(Instant.ofEpochSecond(epochSeconds.toLong())) - } catch (_: Exception) { - // A time that cannot be read is not a time to show: the sentence above still says a check - // is coming, which is the part the reader can act on. - "soon" - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt deleted file mode 100644 index fcd0814..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt +++ /dev/null @@ -1,273 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import java.time.Duration -import java.time.OffsetDateTime -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -/** What one machine's rate limits came back as, or why they didn't. */ -sealed class SessionUsage { - /** Nothing has come back yet. Distinct from every answer, including an empty one. */ - data object Waiting : SessionUsage() - - /** Every window the machine reported, in the order it reported them. */ - data class Known(val windows: List) : SessionUsage() - - /** - * This machine meters nothing, so there is no window to show. - * - * Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a - * local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a - * failure to find something out. The backend never asks such a machine, and reading that - * silence as "couldn't find out" is answering with the nearest available word. - */ - data object NotMetered : SessionUsage() - - /** - * The question could not be answered, and why. - * - * Its own state because "we couldn't find out" and "none of it is used" must never share an - * appearance: a bar sitting at zero because a machine is unreachable reads as plenty of - * headroom. - */ - data class Unavailable(val why: String) : SessionUsage() -} - -/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */ -private const val REFRESH_MS = 60_000L - -/** - * One poll of every machine's limits, and the handle to ask again. - * - * A screen shows this answer in more than one place -- the bar under the session header, the colour - * of the button beside it, and the dialog that button opens -- and each used to fetch for itself. - * Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval - * old when the dialog opens with a fresh one, so the header read 42% while the screen over it read - * 47%. - */ -class UsageFeed( - val snapshots: LoadState>, - /** A fetch is outstanding. Only ever true over an answer already shown. */ - val refreshing: Boolean, - /** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */ - val refresh: () -> Unit, -) { - /** - * What meters [session], and what that meter came back as. See [usageFor] for the states. - * - * A session rather than a machine, because a machine is not what is metered: one machine runs - * the Claude CLI and an echo session side by side, and only the first of them spends anything. - */ - fun forSession(session: SessionSummary): SessionUsage { - // Settled without asking anybody: a session nothing meters has nothing to check, and - // "checking" is what the fetch's own states would say about it for as long as one is out. - val provider = session.usageProvider ?: return SessionUsage.NotMetered - return when (val state = snapshots) { - is LoadState.Loading -> SessionUsage.Waiting - is LoadState.Error -> SessionUsage.Unavailable(state.message) - is LoadState.Loaded -> usageFor(state.value, session.setup, provider) - } - } -} - -/** - * The one poll of the machines' rate limits, polled and refreshable. - * - * Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage - * has to be reporting the same measurement; see [UsageFeed]. - */ -@Composable -fun rememberUsageFeed(settings: ServerSettings): UsageFeed { - var snapshots by remember { mutableStateOf>>(LoadState.Loading) } - var refreshing by remember { mutableStateOf(true) } - // Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also - // resets the countdown rather than leaving one due immediately after. - var asked by remember { mutableIntStateOf(0) } - LaunchedEffect(asked) { - while (true) { - refreshing = true - // Replaces the answer only once the next one is in hand: dropping back to Loading would - // blank a bar somebody is reading for the length of a round trip, and what was on - // screen is still the last thing the machine actually said. - snapshots = - try { - LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) }) - } catch (e: ApiException) { - LoadState.failed(e) - } - refreshing = false - delay(REFRESH_MS) - } - } - return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } } -} - -/** - * The colour for a control that reports on [usage] as a whole: the worst window's. - * - * Worst rather than the five-hour one, because the button it colours opens *all* of them, and a - * blue icon over a weekly quota at 97% would be the interface answering a question nobody asked. - * Taken over however many windows came back rather than the three Claude sends today -- the backend - * passes windows it does not recognise straight through. - * - * Every state that is not a measurement takes the ordinary control colour instead. That is the - * point where colour stops being able to help: blue is the low end of a scale here, so colouring an - * unknown blue would say "measured, and fine" about a machine nobody could reach. - */ -@Composable -fun usageGlyphColour(usage: SessionUsage): Color = - when (usage) { - is SessionUsage.Known -> - usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) } - ?: MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.primary - } - -/** - * The five-hour window for the machine this session runs on, under the session's own header. - * - * Here rather than only in the usage dialog because it is the number that decides whether to keep - * going, and it was a screen away from the place that decision gets made. It reports on this - * session's machine alone -- the dialog is still where every machine is compared. - * - * What it shows is the paid service's own metering, never derived from what this app has watched go - * past: the transcript's token counts are a different quantity, measured differently, and a bar - * built out of them would be a guess wearing a measurement's clothes. - */ -@Composable -fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) { - DebugStats.count("usage bar recomposed") - // The countdown moves even when the numbers do not, so it is driven by a clock of its own - // rather than recomputed at draw time: a percentage that comes back unchanged is an equal - // value, Compose skips the recomposition, and a "left" that only ticked when the quota moved - // would sit at a stale figure for hours. - var now by remember { mutableStateOf(OffsetDateTime.now()) } - LaunchedEffect(Unit) { - while (true) { - delay(REFRESH_MS) - now = OffsetDateTime.now() - } - } - - // Nothing at all for a session that meters nothing: a row saying "unknown" there would report - // a problem about a setup somebody chose, on every screen, forever. - // - // And nothing while the first fetch is out, which is a different silence. A request in flight - // is not a state to report -- and the session that meters nothing is exactly the one this - // cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a - // second and was then taken away. A row that has to be withdrawn is worse than one that - // arrives late. - if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) { - return - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp), - ) { - // Words, not a colour and not an empty bar: every one of these is a different kind of - // answer from "this much is used", and only words carry a difference in kind. - when (val state = usage) { - // Both handled above, before the row exists at all. - SessionUsage.NotMetered, - SessionUsage.Waiting -> Unit - is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") - is SessionUsage.Known -> { - val window = state.windows.firstOrNull { it.kind == "session" } - if (window == null) { - UsageNote("5-hour usage unknown -- no five-hour window reported") - } else { - LinearProgressIndicator( - progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - // The same step at the same percentages as the dialog's bars: this is the - // same measurement, and a reader who learned the colour there has to be - // able to read it here without checking which screen they are on. - color = quotaColor(window.percent), - modifier = Modifier.weight(1f), - ) - Text( - fiveHourLabel(window, now), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - } - } -} - -/** Anything this row says instead of drawing a bar, so all of them look the same. */ -@Composable -private fun UsageNote(text: String) { - Text( - text, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -/** - * "42% -- 2h 15m left": how much is gone, then how long what is left has to last. - * - * The percentage on its own does not answer the question it gets asked, which is whether to start - * something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers. - * - * The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window - * that is not running gets the percentage and nothing else. - */ -private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String { - val percent = "${window.percent.toInt()}%" - return when (val end = windowEnd(window.resetsAt, now)) { - // Between blocks the five-hour window has no reset time, and saying so is a fact about - // nothing: there is no window to run out. The percentage is the whole answer. - WindowEnd.NotRunning -> percent - WindowEnd.Unreadable -> "$percent · reset time unreadable" - is WindowEnd.Ends -> - // Under a minute, including past the end: the number would round to "0m left", which - // reads as a measurement rather than as the window having run out. - if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon" - else "$percent · ${formatSpan(end.until)} left" - } -} - -/** - * One meter's snapshot, out of every machine's: [setup]'s row for [provider]. - * - * Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's - * account and, while a test has one set, an echo session's invented one -- and a snapshot is one - * service on one machine. - * - * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it. - * None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the - * machine having no quota rather than the question going unanswered. - */ -fun usageFor(snapshots: List, setup: String, provider: String): SessionUsage { - // No snapshot at all means the backend never asked, which it only does where there is nothing - // to ask about. That is a different answer from having asked and failed. - val mine = - snapshots.firstOrNull { it.setup == setup && it.provider == provider } - ?: return SessionUsage.NotMetered - if (mine.state != "ok") { - return SessionUsage.Unavailable(mine.detail ?: mine.state) - } - return SessionUsage.Known(mine.windows) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt deleted file mode 100644 index b067e57..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt +++ /dev/null @@ -1,197 +0,0 @@ -package com.example.aiapp - -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import com.example.wgapplink.EnrollmentScanActivity -import com.google.zxing.client.android.Intents -import com.journeyapps.barcodescanner.ScanContract -import com.journeyapps.barcodescanner.ScanIntentResult -import com.journeyapps.barcodescanner.ScanOptions - -/** - * Server address and token. The normal path is the "Scan QR code" button below, which decodes the - * server's terminal QR itself; these fields are the fallback for typing the same three values by - * hand. [onBack] is null on first run, when there is nothing to go back to. - */ -@Composable -fun SettingsScreen( - existing: ServerSettings?, - onSaved: (ServerSettings) -> Unit, - onBack: (() -> Unit)?, -) { - val context = LocalContext.current - var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") } - var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) } - // Never pre-filled from the stored token: this screen shouldn't be a way to read the credential - // back off the device. - var token by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - val scanLauncher = - rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult -> - // Null contents means the user backed out of the scanner -- not an error. - val contents = result.contents ?: return@rememberLauncherForActivityResult - val settings = parseEnrollmentUri(contents.toUri()) - if (settings == null) { - error = "Not a valid enrollment code" - } else { - saveServerSettings(context, settings) - onSaved(settings) - } - } - - val requestCamera = - rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - scanLauncher.launch(enrollmentScanOptions()) - } else { - error = - "Scanning needs the camera. Grant it in the system settings, " + - "or type the host, port and token in below." - } - } - - Column(Modifier.fillMaxSize().padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - // Leading, where a back arrow points at what it returns to. Trailing it would put a - // left-pointing arrow at the right edge, aimed across the title it sits beside. - // - // Absent rather than disabled on first run, which is the one place this app lets a - // control come and go: there is no screen underneath yet, so a Back here would not be a - // capability being withheld but a promise it could not keep. - if (onBack != null) { - GlyphButton(BACK_GLYPH, "Back", onBack) - Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) - } - Text( - "Server", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - } - Spacer(Modifier.height(8.dp)) - Text( - "The easy way: run ai-server on the backend and scan the QR it prints. " + - "Or type the same values here.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - - OutlinedButton( - onClick = { - // Hold the camera permission before the scanner starts. Letting its activity ask on - // our behalf is what the library does by default, and it opens the camera without - // waiting for the answer: the first-ever scan comes up as a live preview with - // "Sorry, the Android camera encountered a problem" over it, and works on the - // second try. - if ( - context.checkSelfPermission(Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED - ) { - scanLauncher.launch(enrollmentScanOptions()) - } else { - requestCamera.launch(Manifest.permission.CAMERA) - } - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Scan QR code") - } - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = host, - onValueChange = { host = it }, - label = { Text("Host") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = port, - onValueChange = { port = it }, - label = { Text("Port") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = token, - onValueChange = { token = it }, - label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(24.dp)) - - error?.let { - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - val portNumber = port.trim().toIntOrNull() - val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" } - when { - host.isBlank() -> error = "Host is required" - portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535" - effectiveToken.isEmpty() -> - error = "Token is required -- scan the server's QR or paste it" - else -> { - val settings = ServerSettings(host.trim(), portNumber, effectiveToken) - saveServerSettings(context, settings) - onSaved(settings) - } - } - } - ) { - Text("Save") - } - } -} - -/** - * How the enrollment QR is scanned, in one place because two callers reach it -- straight from the - * button when the camera permission is already held, and from the permission result when it has - * just been granted. - * - * MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light - * ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark- - * themed terminal it comes out as a photographic negative the scanner silently never matches. The - * mixed decoder alternates normal and inverted frames, costing half the frame rate at each - * polarity. - */ -private fun enrollmentScanOptions(): ScanOptions = - ScanOptions() - .setDesiredBarcodeFormats(ScanOptions.QR_CODE) - .setCaptureActivity(EnrollmentScanActivity::class.java) - // Follow the phone, not the library's landscape pin. - .setOrientationLocked(false) - .addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt deleted file mode 100644 index 5b127cc..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt +++ /dev/null @@ -1,410 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * The machines this backend can run things on. - * - * Note what this screen cannot do: name a program. Providers are what the server found when it - * asked the machine, so adding one is "here is how to reach it" and never "here is what to run" -- - * which is what keeps the enrolled token from being able to introduce commands. - */ -@Composable -fun SetupsScreen(settings: ServerSettings, reloadToken: Int) { - val scope = rememberCoroutineScope() - var state by remember { mutableStateOf>>(LoadState.Loading) } - var adding by remember { mutableStateOf(false) } - var renaming by remember { mutableStateOf(null) } - var confirmingDelete by remember { mutableStateOf(null) } - var busy by remember { mutableStateOf(null) } - var actionError by remember { mutableStateOf(null) } - - suspend fun reload() { - state = - try { - withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) } - } catch (e: ApiException) { - LoadState.failed(e) - } - } - - LaunchedEffect(reloadToken) { reload() } - - Column(Modifier.fillMaxSize().padding(16.dp)) { - // The heading and Back are the tab row's now; adding a machine is this tab's own work and - // stays with the list it adds to. - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - TextButton(onClick = { adding = true }) { Text("Add machine") } - } - Spacer(Modifier.height(8.dp)) - - actionError?.let { - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - busy?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall) - } - Spacer(Modifier.height(8.dp)) - } - - when (val current = state) { - is LoadState.Loading -> CircularProgressIndicator() - is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> - LazyColumn(Modifier.fillMaxSize()) { - uniqueItems(current.value, key = { it.id }) { setup -> - SetupCard( - setup = setup, - onRename = { renaming = setup }, - onRediscover = { - scope.launch { - busy = "Asking ${setup.name} what it has…" - actionError = - runCatching { - withContext(Dispatchers.IO) { - updateSetup( - settings, - setup.id, - rediscover = true, - ) - } - } - .exceptionOrNull() - ?.message - busy = null - reload() - } - }, - onDelete = { confirmingDelete = setup }, - ) - } - } - } - } - - if (adding) { - AddSetupDialog( - onDismiss = { adding = false }, - onAdd = { name, ssh -> - adding = false - scope.launch { - busy = "Asking $name what it has…" - actionError = - runCatching { - withContext(Dispatchers.IO) { addSetup(settings, name, ssh) } - } - .exceptionOrNull() - ?.message - busy = null - reload() - } - }, - onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } }, - ) - } - - renaming?.let { setup -> - RenameDialog( - setup = setup, - onDismiss = { renaming = null }, - onRename = { name -> - renaming = null - scope.launch { - actionError = - runCatching { - withContext(Dispatchers.IO) { - updateSetup(settings, setup.id, name = name) - } - } - .exceptionOrNull() - ?.message - reload() - } - }, - ) - } - - confirmingDelete?.let { setup -> - AlertDialog( - onDismissRequest = { confirmingDelete = null }, - title = { Text("Remove \"${setup.name}\"?") }, - text = { - Text( - "The machine is left alone -- this only stops this app offering it. " + - "Sessions still running on it must be deleted first." - ) - }, - confirmButton = { - TextButton( - onClick = { - confirmingDelete = null - scope.launch { - actionError = - runCatching { - withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) } - } - .exceptionOrNull() - ?.message - reload() - } - } - ) { - Text("Remove") - } - }, - dismissButton = { - TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") } - }, - ) - } -} - -@Composable -private fun SetupCard( - setup: Setup, - onRename: () -> Unit, - onRediscover: () -> Unit, - onDelete: () -> Unit, -) { - Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Column(Modifier.padding(12.dp)) { - Text(setup.name, style = MaterialTheme.typography.titleSmall) - Text( - // Not "this machine": the seeded setup is *called* that, and the card read "this - // machine / this machine". - setup.address ?: "runs where the backend does", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(4.dp)) - Text( - if (setup.providers.isEmpty()) { - "Nothing found on it. Install something and rediscover." - } else { - setup.providers.joinToString(" · ") { it.name } - }, - style = MaterialTheme.typography.bodySmall, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - TextButton(onClick = onRename) { Text("Rename") } - TextButton(onClick = onRediscover) { Text("Rediscover") } - Spacer(Modifier.weight(1f)) - TextButton(onClick = onDelete) { Text("Remove") } - } - } - } -} - -@Composable -private fun AddSetupDialog( - onDismiss: () -> Unit, - onAdd: (String, SshDetails?) -> Unit, - onTest: suspend (SshDetails?) -> List, -) { - val scope = rememberCoroutineScope() - var name by remember { mutableStateOf("") } - var address by remember { mutableStateOf("") } - var identity by remember { mutableStateOf("") } - var attachmentsDir by remember { mutableStateOf("") } - var modelsDir by remember { mutableStateOf("") } - var tested by remember { mutableStateOf(null) } - var testing by remember { mutableStateOf(false) } - - fun details(): SshDetails? = - address - .trim() - .takeIf { it.isNotEmpty() } - ?.let { typed -> - val (host, typedPort) = splitHostAndPort(typed) - SshDetails( - address = host, - port = typedPort, - identityFile = identity.trim().ifEmpty { null }, - attachmentsDir = attachmentsDir.trim().ifEmpty { null }, - modelsDir = modelsDir.trim().ifEmpty { null }, - ) - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Add a machine") }, - text = { - Column { - Text( - "Leave the address blank for the machine the backend runs on. " + - "What it can run is discovered, not typed.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - ) - OutlinedTextField( - value = address, - onValueChange = { address = it }, - // Just the shape. What a blank one means is said once, in the text above this - // form -- repeating it here wrapped the label onto a second line. - label = { Text("user@host[:port]") }, - singleLine = true, - ) - OutlinedTextField( - value = identity, - onValueChange = { identity = it }, - label = { Text("Key path on the backend") }, - singleLine = true, - ) - // Where a file attached from the phone lands on that machine. Blank means the - // session's own directory, which is what most people want. - OutlinedTextField( - value = attachmentsDir, - onValueChange = { attachmentsDir = it }, - label = { Text("Folder for attached files (optional)") }, - singleLine = true, - ) - // Where that machine's GGUFs are, for a llama.cpp session on it. Blank means - // the same place this backend keeps its own downloads, read on that machine. - OutlinedTextField( - value = modelsDir, - onValueChange = { modelsDir = it }, - label = { Text("Folder for models (optional)") }, - singleLine = true, - ) - tested?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall) - } - } - }, - confirmButton = { - TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) { - Text("Add") - } - }, - dismissButton = { - Row { - // Tried before saving, so a wrong address or an unauthorised key is caught while - // this form is still on screen rather than at the first spawn. - TextButton( - enabled = !testing, - onClick = { - testing = true - tested = "Asking…" - scope.launch { - tested = - runCatching { onTest(details()) } - .fold( - onSuccess = { found -> - if (found.isEmpty()) { - "Reached it, but found nothing it can run." - } else { - "Found ${found.joinToString(", ") { it.name }}" - } - }, - onFailure = { it.message ?: "Couldn't reach it" }, - ) - testing = false - } - }, - ) { - Text("Test") - } - TextButton(onClick = onDismiss) { Text("Cancel") } - } - }, - ) -} - -@Composable -private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) { - var name by remember { mutableStateOf(setup.name) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Rename") }, - text = { - Column { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - singleLine = true, - ) - Spacer(Modifier.height(8.dp)) - Text( - "Sessions already running on it keep working -- they refer to the machine, " + - "not to what it is called.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) { - Text("Rename") - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -/** - * Splits `user@host:port` into its two halves, with the port left null when none was typed. - * - * One field rather than two because that is how an address is written and read everywhere else, and - * because a port that is almost always 22 does not deserve a box of its own on a phone keyboard. - * Null rather than 22: the backend already decides the default. - * - * A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it, - * `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon - * followed by digits. - */ -private fun splitHostAndPort(typed: String): Pair { - if (typed.startsWith("[")) { - val close = typed.indexOf(']') - if (close > 0) { - val host = typed.substring(1, close) - val rest = typed.substring(close + 1) - val port = rest.removePrefix(":").toIntOrNull().takeIf { rest.startsWith(":") } - return host to port - } - } - if (typed.count { it == ':' } == 1) { - val host = typed.substringBeforeLast(':') - val port = typed.substringAfterLast(':').toIntOrNull() - if (port != null && host.isNotEmpty()) return host to port - } - return typed to null -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt deleted file mode 100644 index e21d3d6..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Share.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.example.aiapp - -import android.content.Intent -import android.net.Uri -import androidx.core.content.IntentCompat - -/** - * What another app handed this one through the share sheet, waiting to be attached to a session. - * - * Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the - * share arrives before anyone has said which. [serial] makes two shares of the same thing two - * requests, for the reason [SessionOpenRequest] carries one. - */ -data class ShareRequest(val uris: List, val text: String?, val serial: Int) - -/** The share in [intent], or null when it is some other intent. */ -fun sharedContent(intent: Intent, serial: Int): ShareRequest? { - val uris = - when (intent.action) { - Intent.ACTION_SEND -> - listOfNotNull( - IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java) - ) - Intent.ACTION_SEND_MULTIPLE -> - IntentCompat.getParcelableArrayListExtra( - intent, - Intent.EXTRA_STREAM, - Uri::class.java, - ) - .orEmpty() - else -> return null - } - val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() } - if (uris.isEmpty() && text == null) return null - return ShareRequest(uris, text, serial) -} - -/** What is waiting, for the banner that says so. */ -fun ShareRequest.summary(): String = - when { - uris.size == 1 -> "1 file to attach" - uris.isNotEmpty() -> "${uris.size} files to attach" - else -> "Text to attach" - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt deleted file mode 100644 index 081bda6..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sizes.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.example.aiapp - -/** - * A byte count at the coarsest unit that still says something, so rows stay comparable. - * - * Null at zero and below, because the screens that ask disagree about what nothing means and only - * the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no - * bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached - * transcript says "nothing cached", because a figure of none would read as a measurement. - * - * Its own file rather than the import screen's, where it started: three screens now say a size, and - * a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B. - */ -fun humanSize(bytes: Long): String? = - when { - bytes <= 0L -> null - bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB" - bytes >= 1_000L -> "${bytes / 1_000L} kB" - else -> "$bytes B" - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt deleted file mode 100644 index 4077cfa..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt +++ /dev/null @@ -1,386 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * The spawn screen: what to run, where to run it, and the per-kind fields. - * - * Providers and hosts both come from the server, so adding either to its config.ron shows up here - * with no app rebuild. - */ -@Composable -fun SpawnScreen( - settings: ServerSettings, - onSpawned: (SessionSummary) -> Unit, - onBack: () -> Unit, -) { - val scope = rememberCoroutineScope() - // What the form is made of, and whether we have it yet. A failure here is not the same as a - // server with nothing to offer, so it must not reach the pickers as empty lists. - var options by remember { mutableStateOf>>(LoadState.Loading) } - - // Setup first, then one of its providers. Choosing a setup can invalidate the provider, so the - // provider is stored by name and resolved against the current setup rather than held as an - // object that could outlive the list it came from. - var setupName by remember { mutableStateOf(null) } - var providerName by remember { mutableStateOf(null) } - var title by remember { mutableStateOf("") } - var model by remember { mutableStateOf("") } - var cwd by remember { mutableStateOf("") } - // "auto" rather than "manual": on a phone every ask is a round trip to a question card, and - // answering "allow Bash?" dozens of times per task is what this app exists to avoid. - var permissionMode by remember { mutableStateOf("auto") } - // Null until the server has been asked, and null again if it answers "no level chosen" -- the - // two are told apart by [defaultsAsked], because a picker that shows a level before the answer - // arrives is one you can spawn at without having chosen it. - var effort by remember { mutableStateOf(null) } - var defaultsAsked by remember { mutableStateOf(false) } - var busy by remember { mutableStateOf(false) } - // Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in - // form worth keeping, and that one leaves nothing to fill in. - var spawnError by remember { mutableStateOf(null) } - // The models on the *chosen machine*, for a llama provider to choose between. Kept separate - // from the setups: a Claude session needs none, so failing to list them must not stop the - // screen rendering. Refetched when the machine changes, because a model is a file on one - // machine -- see [fetchSetupModels]. - var models by remember { mutableStateOf>(emptyList()) } - var modelKey by remember { mutableStateOf(null) } - var contextSize by remember { mutableStateOf("") } - var temperature by remember { mutableStateOf("") } - - LaunchedEffect(Unit) { - // Separate from the setups fetch below and deliberately not fatal: failing to learn the - // default must leave a screen you can still spawn from, so the picker stays on "default" - // and says so rather than the whole form refusing to draw. - runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } } - .onSuccess { effort = it } - defaultsAsked = true - options = - try { - val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) } - val first = fetched.firstOrNull() - setupName = first?.name - providerName = first?.providers?.firstOrNull()?.name - LoadState.Loaded(fetched) - } catch (e: ApiException) { - LoadState.failed(e) - } - } - - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - "New session", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onBack) { Text("Cancel") } - } - Spacer(Modifier.height(16.dp)) - - // Nothing below is fillable until the options are here, and a failure to fetch them leaves - // no form worth showing -- so this reports and stops, rather than offering empty pickers - // under an error message. - val setups = - when (val state = options) { - is LoadState.Loading -> { - CircularProgressIndicator() - return@Column - } - is LoadState.Error -> { - Text(state.message, color = MaterialTheme.colorScheme.error) - return@Column - } - is LoadState.Loaded -> state.value - } - val setup = setups.firstOrNull { it.name == setupName } - // Whichever machine is chosen now, asked again when that changes. The old machine's list - // is dropped first rather than left on screen: a file name from another machine looks - // exactly like one from this one. - LaunchedEffect(setup?.id) { - models = emptyList() - modelKey = null - val id = setup?.id ?: return@LaunchedEffect - models = - runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } } - .getOrDefault(emptyList()) - } - val current = setup?.providers?.firstOrNull { it.name == providerName } - // Only the Claude CLI has models, a working directory and permission modes; keying the - // extra fields on the kind rather than the provider name keeps a second Claude provider - // from needing anything here. - val isClaude = current?.kind == "claude_cli" - val isLlama = current?.kind == "llama_cpp" - - // The machine first, because it decides what can be run at all. - ChipGroup( - label = "Setup", - options = setups.map { it.name }, - selected = setupName, - onSelect = { name -> - setupName = name - // The provider list changes with the machine, so a name carried over from the - // previous one would be a selection that isn't in the picker. Take that machine's - // first. - providerName = - setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name - }, - ) - setup?.address?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // The address belongs to the setup above it, not to the provider label below; without - // this they read as one block. - Spacer(Modifier.height(8.dp)) - } - - // Only what this machine actually has. A setup with none says so rather than showing an - // empty row that reads as a failure. - if (setup != null && setup.providers.isEmpty()) { - Text( - "\"${setup.name}\" has no providers configured.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - ChipGroup( - label = "Provider", - options = setup?.providers?.map { it.name }.orEmpty(), - selected = providerName, - onSelect = { providerName = it }, - ) - } - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("Title") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - - if (isLlama) { - // A llama session names one of the models on the machine it will run on, so the - // choice is that list rather than free text -- a name that is not on that machine's - // disk is a session that cannot start. - if (models.isEmpty()) { - Text( - "No models on ${setup?.name ?: "this machine"}. The Models screen downloads " + - "to the backend; another machine needs the file put there itself.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - ChipGroup( - label = "Model", - // The file, not the whole key: the repository is the same for every - // quantisation of a model, so the file name is what tells two of them apart. - options = models.map { it.file }, - selected = models.firstOrNull { it.key == modelKey }?.file, - onSelect = { file -> modelKey = models.first { it.file == file }.key }, - ) - } - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = contextSize, - onValueChange = { contextSize = it }, - label = { Text("Context size (blank = the model's default)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = temperature, - onValueChange = { temperature = it }, - label = { Text("Temperature (blank = llama.cpp's default)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - } - - if (isClaude) { - if (current.models.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - ChipGroup( - label = "Model", - options = current.models, - selected = model.ifEmpty { null }, - onSelect = { chosen -> model = if (model == chosen) "" else chosen }, - ) - } - Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = model, - onValueChange = { model = it }, - label = { Text("Model (blank = the CLI's default)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = cwd, - onValueChange = { cwd = it }, - label = { Text("Working directory") }, - placeholder = { Text("/home/…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(16.dp)) - - ChipGroup( - label = "Permissions", - options = PERMISSION_MODES, - selected = permissionMode, - onSelect = { permissionMode = it }, - ) - Spacer(Modifier.height(16.dp)) - - // Says what it does to *later* spawns as well, because it does: the level chosen here - // is stored as the default, which is the whole way that default is set. A picker that - // quietly changed a global would be the same control with the fact left out. - ChipGroup( - label = "Thinking (kept as the default for new sessions)", - options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS, - // The CLI's own default is a level in the list, so this cannot be a one-way trip. - // Disabled-looking until the server has answered, for the reason above. - selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null, - onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } }, - ) - } - Spacer(Modifier.height(24.dp)) - - // Beside the button that produced it. - spawnError?.let { - Text(it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - Button( - onClick = { - val chosen = current ?: return@Button - busy = true - scope.launch { - try { - val spawned = - withContext(Dispatchers.IO) { - // Stored before the spawn and not after it: choosing a level is - // an intent about new sessions in general, so a spawn that then - // fails must not also lose the choice. Non-fatal for the same - // reason the fetch above is -- the session is what was asked for. - if (isClaude) { - runCatching { setDefaultEffort(settings, effort) } - } - spawnSession( - settings, - // The id, not the label: labels are editable and the server - // resolves by id. Non-null here, since `chosen` came from - // `setup`'s own provider list. - setup = setup.id, - provider = chosen.name, - title = title.trim(), - model = - if (isLlama) modelKey else model.trim().takeIf { isClaude }, - cwd = cwd.trim().takeIf { isClaude }, - permissionMode = permissionMode.takeIf { isClaude }, - effort = effort.takeIf { isClaude }, - // Sent only when set, so blank means "whatever llama.cpp does - // by default" rather than a zero. - params = - buildMap { - if (isLlama) { - contextSize - .trim() - .takeIf { it.isNotEmpty() } - ?.let { put("contextSize", it) } - temperature - .trim() - .takeIf { it.isNotEmpty() } - ?.let { put("temperature", it) } - } - }, - ) - } - onSpawned(spawned) - } catch (e: ApiException) { - spawnError = e.message - busy = false - } - } - }, - enabled = !busy && current != null && !(isLlama && modelKey == null), - ) { - Text(if (busy) "Spawning..." else "Spawn") - } - } -} - -/** - * A labeled row of choices that wraps onto as many lines as it needs. - * - * FlowRow rather than Row: a plain Row gives every chip an equal share of a single line, so once - * the options don't fit, the text inside each one wraps to one character per line instead of the - * row wrapping. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun ChipGroup( - label: String, - options: List, - selected: String?, - onSelect: (String) -> Unit, -) { - Text(label, style = MaterialTheme.typography.labelLarge) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth(), - ) { - options.forEach { option -> - FilterChip( - selected = selected == option, - onClick = { onSelect(option) }, - label = { Text(option) }, - ) - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt deleted file mode 100644 index ecb75bf..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Sse.kt +++ /dev/null @@ -1,100 +0,0 @@ -package com.example.aiapp - -import java.io.IOException -import java.net.HttpURLConnection -import java.net.URL - -/** - * How long to wait before opening a dropped stream again. - * - * Shared by every screen that follows one, so a reconnect is not paced differently depending on - * which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a - * server which is genuinely down is not being asked several times a second. - */ -const val RECONNECT_DELAY_MS = 1500L - -/** - * One server-sent-events connection, framed. - * - * The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank - * line ends the frame, comments start with `:`, and a frame is either named with no payload or a - * payload with no name. Two screens follow two different streams and neither should re-derive that. - * - * Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the - * cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns - * rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where - * to resume from. - */ -class Sse(private val settings: ServerSettings) { - @Volatile private var connection: HttpURLConnection? = null - @Volatile private var closed = false - - fun close() { - closed = true - connection?.disconnect() - } - - /** - * Follows the stream at [path], handing each frame to [onFrame] as its name (null for an - * ordinary data frame) and its payload. The path is given here rather than at construction - * because a caller that reconnects usually resumes from somewhere new. - * - * [onOpen] fires once the server has accepted the connection. That is the measured moment the - * stream is live, and the only honest thing to clear a previous failure on: clearing on the - * first *event* instead left an idle stream displaying an error it had already recovered from. - */ - fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) { - // Opening is inside the try, not before it. Everything this method can fail at owes the - // caller the same kind of failure, and a connection that could not even be constructed used - // to escape as a raw `IOException` from a line no `catch` covered. - var connection: HttpURLConnection? = null - try { - connection = - (URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also { - this.connection = it - } - connection.applyPinnedTls() - connection.connectTimeout = CONNECT_TIMEOUT_MS - // No read timeout: between events there is nothing to read for as long as the thing - // being followed is idle; the server's keep-alives and a dead socket erroring out are - // the liveness story. - connection.readTimeout = 0 - connection.setRequestProperty("Authorization", "Bearer ${settings.token}") - connection.setRequestProperty("Accept", "text/event-stream") - if (connection.responseCode != 200) { - val detail = connection.errorStream?.bufferedReader()?.readText()?.trim() - throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream") - } - - onOpen() - val reader = connection.inputStream.bufferedReader() - val data = StringBuilder() - var name: String? = null - while (true) { - val line = reader.readLine() ?: break - when { - line.isEmpty() -> { - if (name != null || data.isNotEmpty()) onFrame(name, data.toString()) - data.clear() - name = null - } - line.startsWith("data:") -> data.append(line.removePrefix("data:").trim()) - line.startsWith("event:") -> name = line.removePrefix("event:").trim() - else -> {} // id:, comments -- nothing to do - } - } - } catch (e: ApiException) { - throw e - } catch (e: IOException) { - if (!closed) { - throw ApiException( - "Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})", - cause = e, - ) - } - } finally { - connection?.disconnect() - this.connection = null - } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt deleted file mode 100644 index 35c2009..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TextCap.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.example.aiapp - -/** - * How much of a long thing the transcript draws before offering the rest behind a tap. - * - * One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept - * in one file because four copies would eventually disagree about what "too long" is -- and because - * the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the - * other half of this). The two are deliberately identical so that a benchmark comparing the apps is - * comparing renderers rather than policies. - * - * **Lines and bytes both, whichever runs out first**, because they run out on different things: a - * diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap - * counting only one of them draws the whole of the other. - * - * **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line - * saying what went wrong is nearly always the first; a message is read from the top for the obvious - * reason. (A path is identified by its other end -- none of these is a path.) - */ -object TextCap { - /** - * The bound on a verbatim block -- a tool call's input or its output. Short, because this text - * is a machine's and the reader is looking for one line of it. - */ - const val VERBATIM_LINES = 80 - const val VERBATIM_BYTES = 4096 - - /** - * The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose - * is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log, - * and cutting a reply at 80 lines would cut most long answers that nobody would call long. - */ - const val MESSAGE_LINES = 200 - const val MESSAGE_BYTES = 16 * 1024 -} - -/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */ -data class CutText( - /** What to draw. */ - val shown: String, - /** - * The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines" - * offer says, and a reader deciding whether to ask for the rest wants to know how much the rest - * is. - */ - val lines: Int, -) - -/** - * [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits. - * - * Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the - * same place. UTF-8 is what the wire carries and what `client-core` measures. - */ -fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? { - require(maxLines > 0 && maxBytes > 0) { - "a cap of nothing shows an empty block and a 'Show all' for every value there is" - } - val bytes = text.toByteArray(Charsets.UTF_8) - var byLines = -1 - var seen = 0 - for (i in text.indices) { - if (text[i] == '\n') { - seen++ - if (seen == maxLines) { - byLines = i - break - } - } - } - val byBytes = - if (bytes.size > maxBytes) { - // Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on - // one would split a character in half and `String(bytes)` would draw a replacement mark - // where an em dash was. - var end = maxBytes - while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end-- - String(bytes, 0, end, Charsets.UTF_8).length - } else { - -1 - } - val cut = - when { - byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes) - byLines >= 0 -> byLines - byBytes >= 0 -> byBytes - else -> return null - } - return CutText(text.take(cut), lineCount(text)) -} - -/** - * How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing - * newline ends the last line rather than starting an empty one. - * - * Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the - * two apps have to offer "Show all N lines" with the same N for the same message, and a count that - * is one out on every text ending in a newline (which is most tool output) would show it. - */ -fun lineCount(text: String): Int = - when { - text.isEmpty() -> 0 - text.endsWith("\n") -> text.count { it == '\n' } - else -> text.count { it == '\n' } + 1 - } - -/** What a "Show all" offer says, so the wording is one string rather than one per surface. */ -fun showAllLabel(lines: Int): String = "Show all $lines lines" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt deleted file mode 100644 index c2d1e11..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ /dev/null @@ -1,336 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.text.selection.TextSelectionColors -import androidx.compose.material3.ButtonColors -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -/** - * Catppuccin Mocha, as published in `catppuccin/palette`. - * - * Named rather than used as literals at the point of need, so the mapping below reads as the - * decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream - * palette without reading the layout that uses it. - */ -private object Mocha { - val Rosewater = Color(0xFFF5E0DC) - val Mauve = Color(0xFFCBA6F7) - val Red = Color(0xFFF38BA8) - val Peach = Color(0xFFFAB387) - val Yellow = Color(0xFFF9E2AF) - val Green = Color(0xFFA6E3A1) - val Teal = Color(0xFF94E2D5) - val Sky = Color(0xFF89DCEB) - val Blue = Color(0xFF89B4FA) - val Lavender = Color(0xFFB4BEFE) - val Pink = Color(0xFFF5C2E7) - val Text = Color(0xFFCDD6F4) - val Subtext1 = Color(0xFFBAC2DE) - val Subtext0 = Color(0xFFA6ADC8) - val Overlay0 = Color(0xFF6C7086) - val Surface2 = Color(0xFF585B70) - val Surface1 = Color(0xFF45475A) - val Surface0 = Color(0xFF313244) - val Base = Color(0xFF1E1E2E) - val Mantle = Color(0xFF181825) - val Crust = Color(0xFF11111B) -} - -/** - * The app's colour scheme: Catppuccin Mocha mapped onto Material's roles. - * - * Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link* - * -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike - * is a preference, not a contract. - * - * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, - * Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside - * it, and a project's card is Surface 0: one visible step up, which is the whole of what the - * nesting has to say. - * - * Accents on this palette are light, so anything filled with one takes Crust for its text. - */ -val AiAppColors = - darkColorScheme( - primary = Mocha.Mauve, - onPrimary = Mocha.Crust, - primaryContainer = Mocha.Surface1, - onPrimaryContainer = Mocha.Mauve, - secondary = Mocha.Lavender, - onSecondary = Mocha.Crust, - secondaryContainer = Mocha.Surface1, - onSecondaryContainer = Mocha.Lavender, - tertiary = Mocha.Rosewater, - onTertiary = Mocha.Crust, - background = Mocha.Base, - onBackground = Mocha.Text, - surface = Mocha.Base, - onSurface = Mocha.Text, - surfaceVariant = Mocha.Surface0, - onSurfaceVariant = Mocha.Subtext0, - surfaceContainerLowest = Mocha.Crust, - surfaceContainerLow = Mocha.Mantle, - surfaceContainer = Mocha.Base, - surfaceContainerHigh = Mocha.Surface0, - surfaceContainerHighest = Mocha.Surface0, - inverseSurface = Mocha.Text, - inverseOnSurface = Mocha.Base, - inversePrimary = Mocha.Mauve, - outline = Mocha.Overlay0, - outlineVariant = Mocha.Surface2, - error = Mocha.Red, - onError = Mocha.Crust, - errorContainer = Mocha.Surface1, - onErrorContainer = Mocha.Red, - scrim = Mocha.Crust, - ) - -/** - * What a session is doing, said in colour. - * - * Here rather than beside each screen that shows a status. These were separate literals in two - * other files, so the same state was a slightly different colour depending which screen you looked - * at. A colour that carries meaning is part of the scheme, not a value typed where it was needed. - */ -val runningColor: Color - @Composable get() = Mocha.Green - -/** - * "This went wrong on its own": a session that fell over. - * - * The scheme's error colour, and deliberately not "the same red as a destructive button" even - * though it is the same red. They are the same red for different reasons, and a state is not an - * action. - */ -val failedColor: Color - @Composable get() = MaterialTheme.colorScheme.error - -/** - * About the session rather than about the task: a command, and the compaction one of them starts. - * - * Its own colour because it is its own kind of work. Everything else a session does is progress - * through what was asked of it; this is the session acting on itself, and none of it appears in the - * transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not - * replying to you either" has learned what distinguishes it from a session that has hung. - */ -val commandColor: Color - @Composable get() = Mocha.Blue - -/** - * A clear: the conversation taken out of what the session is given. - * - * Red because of what it does, not because anything went wrong -- somebody asked for this, and a - * deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a - * third reason: this is neither a fault nor a button, it is the mark left where something was taken - * away. No two of the three can appear as the same kind of thing. - */ -val clearedColor: Color - @Composable get() = Mocha.Red - -/** Waiting on a person: a question, a permission, a turn that is theirs. */ -val awaitingColor: Color - @Composable get() = Mocha.Peach - -/** Approaching a limit -- still fine, worth seeing. */ -val warningColor: Color - @Composable get() = Mocha.Yellow - -/** - * The fill of a progress bar that is only reporting how far along something is. - * - * Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary - * made it the loudest thing on a screen the reader opened to do something else. A download has no - * limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is - * [quotaColor]. - */ -val progressColor: Color - @Composable get() = Mocha.Blue - -/** - * The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red. - * - * One function rather than the same `when` written beside each bar, because the point of colouring - * by consequence is that the reader learns the step once. It reads as a difference in degree, which - * is all colour can carry: the states that differ in *kind* -- a window nobody could read, a - * machine that meters nothing -- are said in words elsewhere. - * - * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent - * without one of them getting it wrong by a factor of a hundred. - */ -@Composable -fun quotaColor(percent: Double): Color = - when { - percent >= OVER_LIMIT_PERCENT -> overLimitColor - percent >= WARNING_PERCENT -> warningColor - else -> progressColor - } - -/** Close enough to the limit to be worth seeing before starting something big. */ -private const val WARNING_PERCENT = 75.0 - -/** Close enough that the next turn may be the one that is refused. */ -private const val OVER_LIMIT_PERCENT = 90.0 - -/** - * The surface verbatim text sits on: a command, a tool's output, a code block in a reply. - * - * The darkest value in the palette rather than a step up from the page, and that is the point -- - * everything else on this screen is somebody's prose, and this is what a machine was handed and - * what it said back, character for character. Crust sits *below* Base, so the same colour reads as - * one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice - * and still collides with the card it lands on. - * - * One colour for all three, so "this is verbatim" is learnable once. - */ -val rawSurface: Color - @Composable get() = Mocha.Crust - -/** - * Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette]. - * - * Here with the rest of the palette rather than beside the code that highlights: the colours a - * fence is drawn in are the same accents every other coloured thing already uses. - * - * Not a composable, because [highlight] runs off the drawing thread; these never vary with the - * theme. - */ -fun catppuccinSyntax(): SyntaxPalette = - SyntaxPalette( - keyword = Mocha.Mauve, - string = Mocha.Green, - literal = Mocha.Peach, - comment = Mocha.Overlay0, - metadata = Mocha.Yellow, - punctuation = Mocha.Subtext0, - mark = Mocha.Sky, - ) - -/** - * The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette]. - * - * Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by - * eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has - * already made for every other blue on the screen. - * - * Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which - * is upstream's choice and not an omission here. - * - * The background is [rawSurface] because that is what a tool's output is drawn on, and reverse - * video needs to know what it is reversing against. - */ -fun ansiPalette(): AnsiPalette = - AnsiPalette( - colours = - listOf( - Mocha.Surface1, - Mocha.Red, - Mocha.Green, - Mocha.Yellow, - Mocha.Blue, - Mocha.Pink, - Mocha.Teal, - Mocha.Subtext1, - Mocha.Surface2, - Mocha.Red, - Mocha.Green, - Mocha.Yellow, - Mocha.Blue, - Mocha.Pink, - Mocha.Teal, - Mocha.Subtext0, - ), - foreground = Mocha.Text, - background = Mocha.Crust, - ) - -/** - * What a selection looks like, stated rather than left to Material's default. - * - * The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app - * draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a - * code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of - * code looks like nothing happened even though it copies correctly. - * - * Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only - * as far as it takes to read on the darkest of them -- past this the fill starts competing with the - * syntax colours it sits behind. - */ -val AiAppSelectionColors = - TextSelectionColors( - handleColor = Mocha.Mauve, - backgroundColor = Mocha.Mauve.copy(alpha = 0.55f), - ) - -/** - * A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone. - */ -val linkColor: Color - @Composable get() = Mocha.Blue - -/** - * A list's markers: the bullets and numbers down its left edge. - * - * The scheme's secondary accent rather than the text colour, because a marker is structure rather - * than words: coloured, the items of a list can be counted without reading them. Lavender is not - * one of the colours that mean something here, and it is the same at every depth, since depth is - * said by the glyph and the indent -- a colour per depth would make a difference in degree look - * like one in kind. - */ -val listMarkerColor: Color - @Composable get() = Mocha.Lavender - -/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */ -val overLimitColor: Color - @Composable get() = MaterialTheme.colorScheme.error - -/** - * The composer's buttons, coloured by what pressing one does rather than by where it sits. - * - * Green makes something happen now, blue makes it happen later, orange takes back what is in - * flight, red ends the process. The near-collisions with the states above are deliberate: those are - * *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is - * a state and nothing there is pressable. - */ -val sendColor: Color - @Composable get() = Mocha.Green - -/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */ -val queueColor: Color - @Composable get() = Mocha.Blue - -/** - * Interrupting the running turn: the work stops and the session stays. - * - * Orange rather than red because of how much it takes: only what is in flight. The process is still - * there holding the conversation. Red is spent on [stopColor], which is the same button in the same - * place when what it would end is the session's process. - */ -val pauseColor: Color - @Composable get() = Mocha.Peach - -/** Ending the session's process -- the one button here that takes something away. */ -val stopColor: Color - @Composable get() = Mocha.Red - -/** - * Starting the process again, on the conversation it left. - * - * The same green as [sendColor] on purpose: both mean "this happens now", and they are never the - * same button -- the process button only offers to start when there is nothing running to stop. - */ -val startColor: Color - @Composable get() = Mocha.Green - -/** - * A filled button in one of the action colours above. - * - * The content colour is stated here beside the fill rather than inherited. A semantic colour has to - * carry its own contrast: these fills are fixed whatever the surface under them does, so the theme - * will not change to rescue a foreground that stops being readable on one of them. - */ -@Composable -fun actionButtonColors(fill: Color): ButtonColors = - ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt deleted file mode 100644 index debcf52..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import org.json.JSONObject - -/** - * A tool call's input, read rather than dumped. - * - * Every tool's input arrives as JSON, and showing it raw makes the reader parse - * `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields - * that carry the meaning are pulled out, and anything left over is still shown, because dropping a - * field would be claiming the tool has no other input when it might. - */ -data class ToolInput( - /** The thing that will actually be run or read, if this tool has one. */ - val subject: String?, - /** The language [subject] is written in, for highlighting. */ - val language: Language?, - /** The tool's own one-line summary, when it wrote one. */ - val description: String?, - /** - * How long the call may take, in the largest units it fits. Shown apart because it is a limit - * on the call rather than part of what the call does. - */ - val timeout: String?, - /** Everything else, as `name: value` lines. Never dropped. */ - val rest: List, -) { - /** The one line to show when there is only room for one: what this call is for. */ - val title: String? - get() = description ?: subject -} - -/** - * Which field of which tool is the subject. - * - * A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them - * from being the special case that gets its own code path. Unknown tools fall through to "no - * subject, everything is rest". - */ -private val SUBJECTS: Map> = - mapOf( - "Bash" to ("command" to Language.SHELL), - "Read" to ("file_path" to null), - "Write" to ("file_path" to null), - "Edit" to ("file_path" to null), - "Glob" to ("pattern" to null), - "Grep" to ("pattern" to null), - "WebFetch" to ("url" to null), - ) - -/** Fields that are the tool's own prose about itself rather than input to it. */ -private val DESCRIPTIONS = listOf("description", "prompt") - -fun parseToolInput(tool: String, input: String): ToolInput { - val json = - try { - JSONObject(input) - } catch (_: org.json.JSONException) { - // Not an object: older transcripts and some tools send a bare string. It is still the - // input, so it is still shown. - return ToolInput( - null, - null, - null, - null, - input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(), - ) - } - val (subjectKey, language) = SUBJECTS[tool] ?: (null to null) - val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() } - val description = DESCRIPTIONS.firstNotNullOfOrNull { - json.optString(it).takeIf { v -> v.isNotBlank() } - } - val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) } - val rest = - json - .keys() - .asSequence() - .filter { it != subjectKey || subject == null } - .filter { it !in DESCRIPTIONS || description == null } - .filter { it != "timeout" || timeout == null } - .sorted() - .map { key -> "$key: ${json.get(key)}" } - .toList() - return ToolInput(subject, language, description, timeout, rest) -} - -/** - * A tool call's input: its subject highlighted, then whatever else it carried. - * - * On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the - * call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a - * rendering fault. - * - * The description is *not* here. It is the tool's own prose about what it is doing, so it belongs - * with the reader's text rather than inside the machine's; [ToolCard] draws it above this. - * - * **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the - * largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] / - * [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the - * subject and the leftover fields are two halves of the same answer to "what was this call given", - * and two would make the reader ask twice. [whole] is the reader having already asked. - */ -@Composable -fun ToolInputView( - tool: String, - input: String, - modifier: Modifier = Modifier, - whole: Boolean = false, - onShowAll: () -> Unit = {}, -) { - val parsed = remember(tool, input) { parseToolInput(tool, input) } - if (parsed.subject == null && parsed.rest.isEmpty()) return - val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) } - val rest = - remember(parsed.rest, whole) { - capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole) - } - RawBlock(modifier) { - subject.shown?.let { shown -> - // Not wrapped: a wrapped command hides where its arguments end, and the long one is the - // one being read closely. - Text( - // Highlighted over what is *drawn* rather than over the whole subject, so a cut - // cannot leave a span pointing past the end of the text it styles. - // - // Not cached: a tool's subject is one command line, which lexes in microseconds -- - // the cache exists for a fence with two hundred lines in it. - remember(shown, parsed.language) { highlight(shown, parsed.language) }, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - softWrap = false, - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - ) - } - rest.shown?.let { shown -> - // Never dropped: a field left out would be claiming the tool has no other input when it - // might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text - // like - // tool results I think it should not be wrapped". - Text( - shown, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - softWrap = false, - modifier = - Modifier.padding(top = 2.dp) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - ) - } - // The count is the whole input's, both blocks together, because that is what the one - // control reveals. - if (subject.cut || rest.cut) { - ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll) - } - } -} - -/** One verbatim block as it will be drawn; see [capped]. */ -data class CappedBlock( - /** The text to draw, or `null` when there was none to begin with. */ - val shown: String?, - /** The line count of the whole of it. */ - val lines: Int, - /** Whether anything was left out. */ - val cut: Boolean, -) - -/** - * [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise. - * - * `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with - * something. - */ -private fun capped(text: String?, whole: Boolean): CappedBlock { - if (text == null) return CappedBlock(null, 0, false) - val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES) - return when (cut) { - null -> CappedBlock(text, lineCount(text), false) - else -> CappedBlock(cut.shown, cut.lines, true) - } -} - -/** - * The "Show all N lines" under a capped block. - * - * It says the count rather than "more" because the reader is deciding whether to ask for it: "Show - * all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not - * at all. - */ -@Composable -fun ShowAllRow(lines: Int, onClick: () -> Unit) { - val label = showAllLabel(lines) - Text( - label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = - Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics { - contentDescription = label - }, - ) -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt deleted file mode 100644 index 3054535..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ /dev/null @@ -1,494 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * One row as the transcript draws it: a run of consecutive tool calls, or anything else. - * - * Grouping is decided here rather than when events are folded, because it is a display decision: - * the transcript's own order is what paging and the event stream depend on, and one screen's idea - * of "these belong together" must not reach back into it. - * - * Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather - * than edited, and two rows describing the same events are equal. Compose infers stability from a - * class's fields, and a `List` field -- which several of these carry -- makes it assume the worst, - * so a page of history landing recomposed all 148 loaded rows including the markdown inside them, - * measured as 701 compositions for 148 rows in one scroll. - * - * The promise this makes is real and has to stay true: nothing here is mutated after it is built. - */ -@Immutable -sealed class TranscriptRow { - /** - * This row's identity in the list, which must survive everything that can happen to the row. - * - * The list is keyed by this so that inserting a new message at one end, or a page of history at - * the other, moves the rows and not the reader. When a key changes, the list loses its anchor - * and the transcript steps under whoever is reading it. - * - * A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number, - * and it is the *same* value whether the run is drawn as one card or as a group. Which value - * that is belongs to the item ([TranscriptItem.key]), not to a `when` here. - */ - abstract val key: Any - - /** - * Where this row starts in the transcript: the sequence number of the oldest event behind it. - * - * Separate from [key], and deliberately so. [key] is the list's identity and is a display - * decision; a seq is the server's own numbering, assigned once and meaning the same thing to - * every device. So anything that has to point at a place in the conversation and still find it - * later -- a saved scroll position -- points with this. - */ - abstract val startSeq: Long - - data class Single(val item: TranscriptItem) : TranscriptRow() { - override val key: Any - get() = item.key - - override val startSeq: Long - get() = item.seq - } - - /** Two or more calls with nothing between them; drawn as one collapsed card. */ - data class Tools(val calls: List) : TranscriptRow() { - /** The run's own name, which every call in it already carries. */ - val id: String - get() = calls.first().runId - - override val key: Any - get() = id - - override val startSeq: Long - get() = calls.first().seq - } -} - -/** - * Runs of adjacent tool calls become one row; everything else passes through. - * - * A single call is left alone: "Called 1 tool" hides a card to say the same thing in more words, - * and the run this exists for is the burst of five greps nobody wants to scroll past. - */ -fun groupToolRuns(items: List): List = - DebugStats.timed("grouped tool runs") { groupRuns(items) } - -private fun groupRuns(items: List): List { - val rows = mutableListOf() - var run = mutableListOf() - - fun flush() { - when (run.size) { - 0 -> {} - 1 -> rows += TranscriptRow.Single(run.first()) - else -> rows += TranscriptRow.Tools(run.toList()) - } - run = mutableListOf() - } - - items.forEach { item -> - // Grouped by the run each call says it belongs to, not by adjacency worked out here. - // Adjacency is the same answer most of the time and a worse one at the edges: a call - // arriving next to an existing run, or a page of history arriving in front of one, both - // change which call is *first*. - if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) { - run += item - } else { - flush() - if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item) - } - } - flush() - return rows -} - -/** - * Several calls under one heading, closed until somebody asks. - * - * What says the calls belong together is the surface behind them, which is the one cue rather than - * two half-cues -- rounded to the same corner every other card in the app has, so a group reads as - * one object rather than as a square patch behind round things. The calls sit on it inset by - * [GROUP_INSET], which is the container's own padding rather than an indent. - * - * Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so - * the run reads as one thing broken into its parts; see [connectedShape]. - * - * It closes from either end. A long group's header scrolls off while its last call is still on - * screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the - * same height as the heading at the top. - */ -@Composable -fun ToolGroup( - group: TranscriptRow.Tools, - expanded: Boolean, - /** - * Where it was pressed is the row's business rather than the control's -- a group has a control - * at each end, and only the row knows where its own ends are. - */ - onToggle: () -> Unit, - isToolExpanded: (String) -> Boolean, - onToolToggle: (String) -> Unit, - onAnswer: (List, onSettled: () -> Unit) -> Unit, - image: @Composable (String) -> Unit, - /** Which capped blocks the reader has asked to see whole; see [Capped]. */ - isWhole: (Capped) -> Boolean = { false }, - onShowAll: (Capped) -> Unit = {}, -) { - val heading = "Called ${group.calls.size} tools" - if (!expanded) { - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { - Text( - heading, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(GROUP_INSET_LARGE), - ) - } - return - } - Column( - Modifier.fillMaxWidth() - .clip(MaterialTheme.shapes.medium) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - ) { - val barHeight = groupBarHeight() - Row( - Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - heading, - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE), - ) - } - Column( - Modifier.padding(horizontal = GROUP_INSET), - verticalArrangement = Arrangement.spacedBy(GROUP_GAP), - ) { - group.calls.forEachIndexed { index, call -> - ToolCard( - tool = call, - expanded = isToolExpanded(call.id), - onToggle = { onToolToggle(call.id) }, - onAnswer = onAnswer, - image = image, - isWhole = { part -> isWhole(Capped(call.id, part)) }, - onShowAll = { part -> onShowAll(Capped(call.id, part)) }, - shape = connectedShape(index, group.calls.size), - ) - } - } - // Shutting it from here anchors the other end: the reader is at the bottom of a long group, - // and what they are looking at is what follows it. - CollapseBar(barHeight, onToggle) - } -} - -/** - * The height of a group's heading, and so of the bar at its foot. - * - * Derived from the type the heading is set in rather than written down, because the two have to - * match and a pair of numbers chosen to look equal stops being equal the moment the density - * changes. - */ -@Composable -private fun groupBarHeight(): Dp { - val line = MaterialTheme.typography.titleSmall.lineHeight - return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2 -} - -/** - * The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height - * rather than padded to something that looks close, so the surface the calls sit on is the same - * thickness at both ends. - */ -@Composable -private fun CollapseBar(height: Dp, onToggle: () -> Unit) { - val colour = MaterialTheme.colorScheme.onSurfaceVariant - Row( - Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics { - contentDescription = "Collapse these tool calls" - }, - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Chevron(Pointing.Up, colour = colour) - } -} - -/** - * The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it - * does not. - * - * Written once and given an index rather than branched at each end, because a stack has three cases - * that are one rule -- and the middle one is what a hand-written first/last pair gets wrong. - */ -@Composable -private fun connectedShape(index: Int, count: Int): CornerBasedShape { - val shape = MaterialTheme.shapes.medium - val square = CornerSize(0.dp) - return shape.copy( - topStart = if (index == 0) shape.topStart else square, - topEnd = if (index == 0) shape.topEnd else square, - bottomStart = if (index == count - 1) shape.bottomStart else square, - bottomEnd = if (index == count - 1) shape.bottomEnd else square, - ) -} - -/** The padding inside a card, and so the height a bar of one line of text comes to. */ -private val GROUP_INSET_LARGE = 12.dp - -/** How far the stack of calls is held off the edge of the surface it sits on. */ -private val GROUP_INSET = 4.dp - -/** Enough to read the join as a join rather than as one tall card. */ -private val GROUP_GAP = 2.dp - -/** - * Which half of an open card a cap and its "Show all" belong to. - * - * The two are capped and revealed independently: opening the whole of a call's input says nothing - * about wanting the whole of its output, and one control revealing both would make the card jump by - * the sum of two things when it was asked about one. - */ -enum class ToolPart { - INPUT, - OUTPUT, -} - -/** One capped thing on the screen that the reader may ask to see whole. */ -data class Capped(val call: String, val part: ToolPart) - -/** - * One tool call. - * - * Closed, it is a single line: the tool's name and what the call is for. The command itself is not - * on it, because a wrapped command turns one row into four and a run of them into a wall. - * - * Open, it shows the command, whatever else the input carried, and the output. The timeout sits at - * the top right: it is a limit on the call rather than part of what the call does. - * - * A call waiting on permission is shown open whatever the reader last chose, since the command is - * the thing being decided and a row saying only "Bash" cannot be decided on. - */ -@Composable -fun ToolCard( - tool: TranscriptItem.ToolRun, - expanded: Boolean, - onToggle: () -> Unit, - onAnswer: (List, onSettled: () -> Unit) -> Unit, - image: @Composable (String) -> Unit = {}, - /** - * Whether the reader has asked for the whole of this call's input or output; see [ToolPart]. - */ - isWhole: (ToolPart) -> Boolean = { false }, - onShowAll: (ToolPart) -> Unit = {}, - /** Square where this card faces another in a group; see [connectedShape]. */ - shape: Shape = CardDefaults.shape, -) { - val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } - val deciding = tool.asks.any { it.answers.isEmpty() } - val open = expanded || deciding - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) { - Column(Modifier.padding(GROUP_INSET_LARGE)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(tool.tool, style = MaterialTheme.typography.titleSmall) - if (open) { - Spacer(Modifier.weight(1f)) - parsed.timeout?.let { - Text( - "timeout $it", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - parsed.title?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f).padding(start = 8.dp), - ) - } ?: Spacer(Modifier.weight(1f)) - } - // A spinner says the machine is working. While this call is waiting on an answer - // the machine is doing nothing at all -- the turn is stopped on the person reading - // it -- so it says whose move it is instead. - if (deciding) { - Spacer(Modifier.width(8.dp)) - Text( - "your turn", - style = MaterialTheme.typography.labelLarge, - color = awaitingColor, - ) - } else if (!tool.done) { - Spacer(Modifier.width(8.dp)) - CircularProgressIndicator( - modifier = Modifier.width(16.dp).height(16.dp), - strokeWidth = 2.dp, - ) - } - } - if (open) { - parsed.description?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp), - ) - } - // Everything AskUserQuestion carries is the questions, and those are drawn below as - // something answerable; dumping the same JSON above them would be the decision - // stated twice, once unreadably. - if (tool.tool != ASK_USER_QUESTION) { - ToolInputView( - tool.tool, - tool.input, - Modifier.padding(top = 4.dp), - whole = isWhole(ToolPart.INPUT), - onShowAll = { onShowAll(ToolPart.INPUT) }, - ) - } - if (tool.output.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - Text("Output", style = MaterialTheme.typography.labelSmall) - // Capped like the input, and revealed separately from it: a reader who wants - // the whole of a 900-line `new_string` rarely also wants the whole of the build - // log underneath it. - val wholeOutput = isWhole(ToolPart.OUTPUT) - val cut = - remember(tool.output, wholeOutput) { - if (wholeOutput) null - else - cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES) - } - val shown = cut?.shown ?: tool.output - // What the tool printed, on the surface everything verbatim gets and in the - // face it was written for: this is column-aligned far more often than it is - // prose, and a proportional font silently destroys the alignment that carried - // the meaning. - // - // Its terminal styling applied and the rest of the escapes taken out: colour is - // often the whole of what a diff or a test run is saying. Remembered against - // the text, so a card that is open through a scroll parses once. - val palette = remember { ansiPalette() } - val styled = remember(shown, palette) { ansiStyled(shown, palette) } - RawBlock(Modifier.padding(top = 2.dp)) { - // Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw' - // text like tool results I think it should not be wrapped". Wrapping a - // column-aligned log is what destroys the alignment that carried its - // meaning, one line at a time and only on the long lines. - Text( - styled, - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - softWrap = false, - modifier = - Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - ) - if (cut != null) { - ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) } - } - } - } - } - // Shown open or closed. A call that produced a picture is one whose result *is* the - // picture, and a row that hides it says less than the one line it replaced. - tool.images.forEach { ref -> image(ref) } - if (tool.asks.isNotEmpty()) { - if (tool.tool == ASK_USER_QUESTION) { - AskUserQuestionBody(tool.asks, onAnswer) - } else { - tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) } - } - } - } - } -} - -/** - * The permission ask on the call it is about. - * - * Only the question, not the prompt's second half: the backend sends the tool's input with it so - * the ask can stand alone, and here it does not have to -- the card above is showing exactly that. - */ -@Composable -private fun PermissionAsk( - ask: TranscriptItem.QuestionCard, - onAnswer: (List, onSettled: () -> Unit) -> Unit, -) { - // What was pressed, before the answer has been round-tripped. Two bare words with no submit - // step -- unlike a question card, where the answer is worth reviewing -- so the press has to be - // its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request - // settles: by then either the answer is in `ask.answers`, or it failed and the buttons come - // back. - var pressed by remember(ask.id) { mutableStateOf(null) } - Spacer(Modifier.height(8.dp)) - Text( - ask.prompt.substringBefore('\n'), - style = MaterialTheme.typography.bodyMedium, - color = awaitingColor, - ) - // Answered or not, the options stay and the one that was taken is marked -- see - // [AskedQuestion], which is the same rule on the question card. A permission is where it - // matters most: "Answered: Deny" alone does not say that Allow was the alternative. - val settled = ask.answers.isNotEmpty() - AnswerOptions( - ask.options, - if (settled) ask.answers else listOfNotNull(pressed), - onPick = - if (settled || pressed != null) null - else - { label -> - pressed = label - onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null } - }, - ) -} - -/** - * The tool whose input is a question rather than a command; see [AskUserQuestionBody]. - * - * Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded - * inside a collapsed group. - */ -const val ASK_USER_QUESTION = "AskUserQuestion" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt deleted file mode 100644 index 1e13a99..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.aiapp - -/** - * Where one transcript lives: a session's own, or one of its subagents'. - * - * The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and - * [TranscriptCache.session] all take, rather than each growing its own branch between a session and - * a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id - * builds one with the one-argument constructor; a subagent's screen supplies both ids. - */ -data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) { - /** The URL segment naming this transcript, before `/transcript` or `/events`. */ - val urlPath: String - get() = - if (subagentId == null) "sessions/$sessionId" - else "sessions/$sessionId/subagents/$subagentId" - - /** - * Where this transcript's cache lives on the phone, relative to the cache root. - * - * A subagent's nests under its session's directory rather than sitting beside it, so deleting a - * session's cache directory takes its subagents' with it -- the same one-way door the server's - * own storage describes. - */ - val cachePath: String - get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId" -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt deleted file mode 100644 index d489f3a..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt +++ /dev/null @@ -1,596 +0,0 @@ -package com.example.aiapp - -import android.util.Log -import java.io.BufferedWriter -import java.io.File -import java.io.FileWriter -import java.io.IOException -import java.io.RandomAccessFile - -/** - * This phone's copy of the transcripts it has already been sent, so reopening a session does not - * download it again. - * - * What is stored is the server's own JSON for one event per line, in transcript order. Reading the - * cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a - * fetched one cannot draw differently, and an event type this build does not know keeps every field - * it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a - * rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`. - * - * See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it: - * 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the - * cache is a copy and is never inferred, folded or edited here; - * 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache; - * 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a - * cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched - * again. - * - * A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs - * under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line - * is the sequence number and whether the line is a streamed delta, both read with a regex. A line - * it cannot read that way is treated as damage. [warn] is where failures are said for the same - * reason. - */ -class TranscriptCache( - private val root: File, - private val warn: (String) -> Unit = { Log.w("ai-app", it) }, -) { - /** - * The cache for one transcript, whether or not anything has been stored for it yet. - * - * A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so - * deleting the session (below) takes its subagents' caches with it -- there is no separate - * purge for one. - */ - fun session(address: TranscriptAddress): SessionCache = - SessionCache(File(root, address.cachePath), warn) - - /** - * Deletes every session directory not in [ids], called after a successful list fetch. The path - * out for a session deleted on another device: nothing here would otherwise hear about it, and - * unlike a draft's few bytes what it leaves behind is megabytes. - */ - fun retainOnly(ids: Set) = - guardIo(Unit, warn) { - sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() } - } - - /** - * Deletes least-recently-touched session directories, never [keep], until the whole of this - * server's cache is under [budget]. Least-recently-touched rather than largest: what a reader - * is likely to open again is what they opened last, and evicting the big ones first would empty - * the cache for exactly the conversations it exists for. - */ - fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) = - guardIo(Unit, warn) { - val dirs = sessionDirs().sortedBy { it.lastModified() } - var total = dirs.sumOf { sizeOf(it) } - for (dir in dirs) { - if (total <= budget) break - if (dir.name == keep) continue - val was = sizeOf(dir) - if (dir.deleteRecursively()) total -= was - } - } - - fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() } - - private fun sessionDirs(): List = root.listFiles()?.filter { it.isDirectory }.orEmpty() -} - -/** - * How much of this phone's cache directory all of one server's transcripts may take. A dozen of the - * largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone. - * A number to revisit against real use rather than a measurement of anything. - */ -const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000 - -/** - * What the newest cached line says, which is what the probe checks against the server. Both halves - * are wanted together: the seq is what the request asks about, and the line is what its answer is - * compared with. - */ -data class CachedTail(val seq: Long, val line: String) - -/** - * One session's cached lines, as a directory of chunks. - * - * A chunk is a set of lines *and a claim about what they cover*, and the two are not the same - * thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the - * run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the - * 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range - * in the file's name: - * ``` - * -.rows.jsonl a coalesced page; end is the `before` it was fetched with - * -.raw.jsonl an uncoalesced page, or a closed live run -open.raw.jsonl the - * live run; end is its last line's seq + 1 - * ``` - * - * Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending - * at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because - * the gap is usually closed by paging back through it, but nothing is served across one. - * - * **The newest chunk is always raw**, which is what makes the stream cursor and the probe well - * defined. It holds by construction (the opening window and every stream frame are raw) and is - * checked on read: a `.rows` chunk at the newest end can only mean this app died between closing - * one live run and opening the next, and it discards the session. - * - * Nothing here is load-bearing. Every operation that touches the disk answers as though the cache - * were empty when it cannot, and a write failure disables writing for the rest of this instance's - * life so that a full disk costs one log line rather than one per delta. - * - * Every operation is synchronized, because two of them really do run at once: the stream appends - * live events from its own IO thread while a reader scrolling back reads pages from another. What - * it buys is that the open chunk's name, its end and its writer are never read half-rotated. - */ -class SessionCache( - private val dir: File, - private val warn: (String) -> Unit = { Log.w("ai-app", it) }, -) { - /** Set by the first write that fails: a second would fail the same way, once per delta. */ - private var disabled = false - /** - * The open chunk's writer, its file, and the seq that chunk now ends at. - * - * Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of - * times a second while a reply streams. What that costs is the unflushed tail on a crash, which - * is safe: a shorter cache is a longer catch-up, never a wrong one. - */ - private var writer: BufferedWriter? = null - private var openFile: File? = null - private var openEnd: Long = 0 - - /** - * The newest line of the suffix, or null when there is none or the newest chunk is not raw. - * - * This is the cursor the live stream would resume from, so it is also what has to be shown to - * still be the server's own line before anything is resumed from it. - */ - @Synchronized - fun tail(): CachedTail? = - guard(null) { - val newest = suffix().lastOrNull() ?: return@guard null - var found: CachedTail? = null - eachLine(newest) { line -> - found = CachedTail(seqOf(line)!!, line) - false - } - found - } - - /** The newest [limit] lines of the suffix, oldest first -- the opening window. */ - @Synchronized - fun newest(limit: Int): List = - guard(emptyList()) { - val taken = ArrayDeque() - for (chunk in suffix().asReversed()) { - if (taken.size >= limit) break - eachLine(chunk) { line -> - taken.addFirst(line) - taken.size < limit - } - } - taken.toList() - } - - /** - * The page of lines before [before], oldest first, or null when the cache cannot answer. - * - * Null is a miss -- the suffix does not cover the ground immediately below [before] -- and - * means the server has to be asked. Deliberately not an empty list: an empty page is how the - * screen is told it has reached the start of the conversation, and a cache saying that of - * history it merely does not hold would stop the transcript scrolling back for good. - * - * [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open - * leaves behind is in the middle of the live run, so a cache that could only answer at a - * boundary would send the very first backwards page to the server and, since that page would - * overlap the run, keep none of it. - * - * With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`. - * The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of - * its first delta either way. - */ - @Synchronized - fun page(before: Long, limit: Int, rows: Boolean): List? = - guard(null) { - val suffix = suffix() - val newest = suffix.lastOrNull() ?: return@guard null - // Above what is held, or at or below where it starts: either way the run the caller is - // scrolling into is not continuous with this one, and only the server has it. - if (before > newest.end || before <= suffix.first().first) return@guard null - val taken = ArrayDeque() - var counted = 0 - var inRun = false - var wanting = true - for (chunk in suffix.asReversed()) { - if (!wanting) break - if (chunk.first >= before) continue - eachLine(chunk) { line -> - // The page is what is *before* the cursor; the rows at or above it are already - // on screen. - if (seqOf(line)!! >= before) return@eachLine true - if (rows) { - val delta = isDelta(line) - // Stop only between rows: a delta continuing the run being gathered is part - // of a row already counted, and breaking on it would drop the half of that - // row already taken. - if (counted >= limit && !(delta && inRun)) wanting = false - else { - if (!delta || !inRun) counted++ - inRun = delta - } - } else if (taken.size >= limit) { - wanting = false - } - if (wanting) taken.addFirst(line) - wanting - } - } - taken.toList() - } - - /** - * The `end` of the nearest chunk at or below [before], which is the floor a fetched page is - * asked with so that it stops where this phone's copy starts. Null when there is no such chunk. - * - * Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that - * the gap is closed with exactly the bytes it is wide. - */ - @Synchronized - fun coveredUpTo(before: Long): Long? = - guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() } - - /** - * Stores a fetched page covering `[first, end)`; false when it was not stored. - * - * Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced - * event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from - * arising by bounding what it fetches, and this is the guard for a page that arrives anyway. - * Such a page is still drawn; it is only not kept. - * - * The newest chunk is never stored through here: the opening window and every live frame go - * through [append], which is what keeps the newest chunk raw and open. - */ - @Synchronized - fun storePage(lines: List, first: Long, end: Long, rows: Boolean): Boolean = - guard(false) { - if (disabled || lines.isEmpty() || end <= first) return@guard false - if (chunks().any { first < it.end && it.first < end }) return@guard false - dir.mkdirs() - val kind = if (rows) "rows" else "raw" - File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n")) - true - } - - /** - * Appends one live event, which is also how a freshly fetched opening window is stored. - * - * A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a - * `reset` looks like from here -- and closes the open chunk under the end it turned out to - * have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`. - */ - @Synchronized - fun append(line: String, seq: Long) = - guard(Unit) { - if (disabled) return@guard - val writer = writerFor(seq) ?: return@guard - // Written as it arrived. A newline inside it would split one event into two unreadable - // halves, but neither source can produce one: SSE framing forbids it, and a page's - // elements are re-serialized compactly, which escapes it. - writer.write(line) - writer.write("\n") - openEnd = seq + 1 - } - - /** - * Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a - * turn, which is the granularity a crash may as well lose -- and when the stream closes. - */ - @Synchronized fun flush() = guard(Unit) { writer?.flush() } - - /** What [purge] would discard, for the reload row in session settings. */ - @Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) } - - /** Marks this session as visited, which is what eviction ranks by. */ - @Synchronized - fun touch() = - guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) } - - @Synchronized - fun purge() = - guard(Unit) { - closeWriter() - dir.deleteRecursively() - } - - // -- chunks ------------------------------------------------------------------------------ - - private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) { - val rows: Boolean - get() = file.name.endsWith(".rows.jsonl") - } - - /** - * Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored. - * Recomputed per operation rather than kept: another operation may have changed the directory. - */ - private fun chunks(): List { - writer?.flush() - return dir.listFiles() - .orEmpty() - .mapNotNull { file -> - val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null - val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null - val open = match.groupValues[2] == "open" - val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull() - // A chunk covering nothing is one that was created and never written to -- an - // append whose very first write failed. It says nothing, so it is not a chunk. - if (end == null || end <= first) null else Chunk(file, first, end, open) - } - .sortedBy { it.first } - } - - /** - * The open chunk's end: its last line's seq plus one, or the in-memory end while this instance - * is the one writing it. - * - * An open chunk whose last line cannot be read is this app having died mid-write. That line is - * dropped and the file truncated to the last good one, which is the one place damage is - * repaired rather than discarded: the tail of an append-only file is the only place a partial - * line can be. - */ - private fun openEndOf(file: File, first: Long): Long { - if (openFile == file && openEnd > 0) return openEnd - repairTail(file) - var end = first - eachLineBackwards(file) { _, line -> - seqOf(line)?.let { end = it + 1 } - false - } - return end - } - - /** - * The contiguous run of adjacent chunks ending at the newest one, oldest first. - * - * A newest chunk that is not raw cannot happen while this code is the only writer, and means - * the directory is not to be trusted -- so the session is discarded. - */ - private fun suffix(): List { - val all = chunks() - var index = all.size - 1 - val newest = all.lastOrNull() ?: return emptyList() - if (newest.rows) throw Damaged(newest.file) - val run = ArrayDeque() - run.addFirst(newest) - while (index > 0 && all[index - 1].end == run.first().first) { - index-- - run.addFirst(all[index]) - } - return run.toList() - } - - /** - * Each line of [chunk], newest first, until [take] says stop. - * - * Backwards and lazily, because every question this cache is asked is about the newest end and - * a live run grows to the size of the conversation. Reading the file whole to answer with - * eighty lines of it is the cost the server's own reader was rewritten to stop paying. - * - * Damage anywhere but at the tail of the open chunk was not written by this code, and there is - * no honest way to say what a chunk covers with a line of it unreadable -- so it discards the - * session rather than serving what it can read. - */ - private fun eachLine(chunk: Chunk, take: (String) -> Boolean) { - eachLineBackwards(chunk.file) { _, line -> - if (seqOf(line) == null) throw Damaged(chunk.file) - take(line) - } - } - - // -- writing ----------------------------------------------------------------------------- - - /** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */ - private fun writerFor(seq: Long): BufferedWriter? { - writer?.let { held -> - if (seq == openEnd) return held - if (seq < openEnd) return null - // A gap: what this instance has written covers up to `openEnd`, and that is the name - // the chunk gets before a new one starts at the arriving seq. - closeOpenChunk(openEnd) - } - dir.mkdirs() - // An open chunk left by an earlier instance, or by an earlier screen. - chunks() - .lastOrNull { it.open } - ?.let { existing -> - if (seq < existing.end) return null - if (seq == existing.end) { - openFile = existing.file - openEnd = existing.end - return FileWriter(existing.file, true).buffered().also { writer = it } - } - rename(existing.file, existing.first, existing.end) - } - // A chunk that was created and never written to would otherwise be left behind under a name - // a second one is about to want; it covers nothing, so nothing is lost with it. - dir.listFiles().orEmpty().forEach { - if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L) - it.delete() - } - val file = File(dir, "$seq-open.raw.jsonl") - openFile = file - openEnd = seq - return FileWriter(file, false).buffered().also { writer = it } - } - - /** Renames the open chunk to the range it turned out to cover, so it stops being open. */ - private fun closeOpenChunk(end: Long) { - val file = openFile - closeWriter() - if (file == null) return - val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull() - if (first != null) rename(file, first, end) - } - - private fun rename(file: File, first: Long, end: Long) { - file.renameTo(File(dir, "$first-$end.raw.jsonl")) - } - - private fun closeWriter() { - try { - writer?.close() - } catch (_: IOException) { - // Nothing left to do about it: the file is what it is, and the read path repairs a - // half-written tail. - } - writer = null - openFile = null - openEnd = 0 - } - - // -- failure ----------------------------------------------------------------------------- - - /** A chunk that cannot be read as what its name claims. */ - private class Damaged(val file: File) : RuntimeException() - - /** - * Runs [body], answering [ifBroken] when the directory cannot give a real answer. - * - * None of this is reported on screen: none of it changes what the screen shows -- every read - * here has a network path beside it producing the same result -- and the reader has nothing to - * do about it. Damage discards this session's cache, which makes the next open an ordinary cold - * one. - */ - private fun guard(ifBroken: T, body: () -> T): T = - // A disk that refused once will refuse again, once per delta, so the first refusal is also - // the last: this instance stops writing rather than logging a line a token. - guardIo( - ifBroken, - warn, - onFailure = { - disabled = true - closeWriter() - }, - ) { - try { - body() - } catch (e: Damaged) { - warn("transcript cache damaged at ${e.file}; discarding ${dir.name}") - closeWriter() - dir.deleteRecursively() - ifBroken - } - } -} - -/** `-..jsonl`; anything else in the directory is not ours. */ -private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""") - -private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""") -private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""") - -/** - * One line's sequence number, or null when the line is not one of ours. - * - * A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM - * tests: the seq is the first field the server writes, so the first match is the top-level one. - */ -private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull() - -/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */ -private fun isDelta(line: String): Boolean = - TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText" - -/** - * How much of a file is read at a time when walking it backwards. One block covers a page of a - * transcript comfortably, and the walk stops as soon as the caller has what it asked for. - */ -private const val READ_BLOCK = 64 * 1024 - -/** - * Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset - * it starts at, until [onLine] answers false. - * - * Every question the cache is asked is about the newest end of a chunk, and a live run reaches the - * size of the conversation, so reading forwards means reading a transcript to answer with the last - * eighty lines of it. - * - * Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte - * UTF-8 sequence; each line is decoded whole. A missing file yields nothing. - */ -private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) { - if (!file.isFile) return - RandomAccessFile(file, "r").use { handle -> - // Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which - // is incomplete until a newline is found before it in an older block. - var unread = handle.length() - var pending = ByteArray(0) - while (unread > 0) { - val take = minOf(READ_BLOCK.toLong(), unread).toInt() - val start = unread - take - val block = ByteArray(take) - handle.seek(start) - handle.readFully(block) - val buffer = if (pending.isEmpty()) block else block + pending - var lineEnd = buffer.size - var at = buffer.size - 1 - while (at >= 0) { - if (buffer[at] == NEWLINE) { - val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8) - if (line.isNotBlank() && !onLine(start + at + 1, line)) return - lineEnd = at - } - at-- - } - pending = buffer.copyOfRange(0, lineEnd) - unread = start - } - // The first line of a file has no newline before it to be found. - val first = String(pending, Charsets.UTF_8) - if (first.isNotBlank()) onLine(0, first) - } -} - -private const val NEWLINE = '\n'.code.toByte() - -/** - * Drops a final line that is not one of ours, by truncating the file to where it starts. - * - * This app having died mid-write is the one kind of damage that is repaired rather than discarded: - * the tail of an append-only file is the only place a partial line can be. A second bad line is not - * this, and is left for the read path to notice. - */ -private fun repairTail(file: File) { - var truncateTo = -1L - eachLineBackwards(file) { offset, line -> - if (seqOf(line) == null) truncateTo = offset - false - } - if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) } -} - -private fun sizeOf(file: File): Long = - if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length() - -/** - * The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance. - * [onFailure] is what the caller does about it beyond answering [ifBroken]. - */ -private fun guardIo( - ifBroken: T, - warn: (String) -> Unit, - onFailure: () -> Unit = {}, - body: () -> T, -): T = - try { - body() - } catch (e: IOException) { - warn("transcript cache unusable: ${e.message}") - onFailure() - ifBroken - } catch (e: SecurityException) { - warn("transcript cache unreadable: ${e.message}") - onFailure() - ifBroken - } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt deleted file mode 100644 index 96eefa1..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ /dev/null @@ -1,547 +0,0 @@ -package com.example.aiapp - -import androidx.compose.runtime.Immutable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** - * What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). - * - * Events are the only data source, and there is deliberately no second shape for history to drift - * from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are - * all the same events through the same parser. [TranscriptCache] stores the server's lines rather - * than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever - * this file does. - */ -@Immutable -sealed class TranscriptItem { - /** - * The transcript sequence number this row started at, and its identity on screen. - * - * The list is drawn newest-first, so every new message is an insertion at index 0 and every - * page of history is an insertion at the far end. Without an identity that survives both, the - * list is addressed by position: whatever somebody had scrolled to keeps its index while the - * content underneath it slides, which reads as the view scrolling on its own. - * - * A row built from several events keeps the seq of the first, so it holds still while the rest - * of it arrives. - */ - abstract val seq: Long - - /** - * This item's identity on screen, which is its [seq] for everything that has one of its own. - * - * Here rather than in [TranscriptRow.Single] because the two items that need something else are - * the two that know why. Asking each item what it is called is also what stops the next such - * item being missed -- a `when` over concrete types would have to gain a case, silently. - */ - open val key: Any - get() = seq - - data class UserMsg( - override val seq: Long, - val text: String, - /** Refs of what was attached, drawn inside the bubble. */ - val attachments: List = emptyList(), - ) : TranscriptItem() - - data class AssistantMsg( - override val seq: Long, - val text: String, - /** - * Whether this reply is finished: the session has stopped working since its last delta. - * - * What it buys is the split. [transcriptUnits] keeps the newest reply whole because a - * streaming reply's text changes per delta and splitting a changing text is a parse per - * delta -- but "newest" outlives the turn, so a session that ends on a long reply was - * drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on - * screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping. - * - * Folded from the status event that ended the turn rather than read off the screen's - * status, because rows only change through the held-events gate: the split changes the - * newest row's list identity, and doing that from a status flip while somebody is reading - * inside that reply would step the list under them. - */ - val settled: Boolean = false, - ) : TranscriptItem() - - data class ToolRun( - override val seq: Long, - val id: String, - /** - * The run of adjacent calls this one belongs to, named once when the call is folded in and - * never recomputed. - * - * Carried rather than derived because a run can gain members at *either* end, so no - * function of its current members is stable. It is the first call's id at the moment the - * run started, which is a name rather than a description: [joinPages] hands it to older - * calls that turn out to belong to the same run. - */ - val runId: String, - val tool: String, - val input: String, - val output: String, - val done: Boolean, - /** - * The questions this call is waiting on, in the order they were asked. - * - * On the call's own row rather than beside it: an ask used to arrive as a second card - * repeating the input verbatim, so the reader saw the same command twice. The backend says - * which call a question is about, so this is a fact rather than a match on the input. - * - * A list because AskUserQuestion asks up to four at once, and a permission is the case of - * exactly one rather than a different shape. - */ - val asks: List = emptyList(), - /** - * Images this call's result carried, drawn under it. Beside it they had to be paired by - * position, and position is what a page boundary breaks. - */ - val images: List = emptyList(), - ) : TranscriptItem() { - /** A run, not a seq: see [TranscriptRow.key] for what that identity has to survive. */ - override val key: Any - get() = runId - } - - data class QuestionCard( - override val seq: Long, - val id: String, - val prompt: String, - /** A few words naming what this is about, when the asker offered one. */ - val header: String?, - val options: List, - /** Whether several options may be chosen at once. */ - val multiSelect: Boolean, - /** What was chosen, once something was; empty until then. */ - val answers: List, - ) : TranscriptItem() - - data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem() - - /** An image by server-side ref, fetched from the session's files route. */ - data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem() - - /** - * A message another agent sent this session. Its own row rather than a [UserMsg]: see - * [PeerMessageRow] for why the voice matters. - */ - data class PeerNote( - override val seq: Long, - val from: String, - val text: String, - /** - * The seq of the event this note came in on, which is what makes it itself. - * - * [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began - * at. Two messages that arrive during one turn therefore share a seq -- and sharing an - * identity as well killed the app, because the list refuses two items with one key. - */ - val arrived: Long = seq, - ) : TranscriptItem() { - override val key: Any - get() = arrived - } - - /** - * A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather - * than only shown while it waits, because it explains what follows: a conversation that - * suddenly has half the context, or a session with a new name. - */ - data class CommandRow(override val seq: Long, val text: String) : TranscriptItem() - - /** Placeholder row for events this build can't render (newer kinds). */ - data class Note(override val seq: Long, val text: String) : TranscriptItem() - - /** - * A clear that happened: everything above it left the session's context and stayed on screen. - * Carries only its position, because that is all it means. - */ - data class ClearedNote(override val seq: Long) : TranscriptItem() - - /** - * A compaction that happened, and what it recovered. - * - * In the transcript rather than only in the status line, because the status is gone the moment - * it finishes and this is the part worth keeping: the explanation for a gap in the - * conversation. - * - * The wire also says what triggered it, and this deliberately does not carry that -- the row - * says the two sizes and nothing else, so keeping the trigger would be a field nothing can - * read. - */ - data class CompactedNote( - override val seq: Long, - val preTokens: Long?, - val postTokens: Long?, - ) : TranscriptItem() - - /** - * The account ran out of quota, so the turn stopped here. - * - * A divider rather than an error: nothing failed, and what a reader scrolling back needs from - * it is the same thing a clear or a compaction gives them -- why the conversation stops at this - * line. - * - * [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the - * row has words for rather than a time it invents. - */ - data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem() -} - -/** - * The run a call joins: the one it lands next to, or a new one named after itself. - * - * Only ever consulted when the call is first folded in. That is what makes the name stable -- a run - * keeps whatever it was called when it started, however many calls arrive at either end afterwards. - * - * A question to the reader is in a run of its own, which is what puts it on the transcript as a row - * rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible, - * since a run of one is drawn as itself; and the calls around it fall into a group before it and a - * group after it, so where the reader was asked something is legible in the shape of the transcript - * without opening anything. - */ -private fun runIdFor(items: List, id: String, tool: String): String { - val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id - if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id - return previous.runId -} - -/** - * Puts a page of older items in front of the ones already loaded, healing whatever the page - * boundary cut in two. - * - * Two things straddle a boundary: a tool call separated from its result, and a message separated - * from the rest of itself. Both were one thing before the transcript was cut into pages. - * - * A boundary lands wherever it lands, and roughly half the time that is between a call and its - * result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws - * as a row of its own -- correctly, because a call that renders as nothing is indistinguishable - * from one that never happened. When the older page arrives it brings the real `ToolStart`, and - * concatenating the two lists left *both*: the same call twice. - * - * Merged by the call's own id rather than by position, because position is exactly what a page - * boundary destroys. The older row wins on what a start knows and the newer on what an end knows, - * which is the only way round that loses nothing. - * - * The third thing is the *run*, and it is the one that used to be missed. Every page ends up here, - * but [adoptRun] only ran on the path where a split call had been found -- so the boundary that - * falls cleanly between two finished calls, which is most of them, left the older page's calls - * under the run name they were folded with. On screen: one run of tool calls drawn as two groups, - * with the seam wherever the reader happened to have paged. - */ -fun joinPages(earlier: List, later: List): List { - val (older, newer) = healSplitMessage(earlier, later) - val startedEarlier = - older.filterIsInstance().mapTo(mutableSetOf()) { it.id } - val endedLater = - newer - .filterIsInstance() - .associateBy { it.id } - .filterKeys { it in startedEarlier } - val healed = older.map { row -> - val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] } - if (row is TranscriptItem.ToolRun && half != null) { - row.copy( - output = half.output, - done = half.done, - // Kept from both halves: a question or an image can be attached to either, - // depending on which side of the boundary its event fell. - asks = row.asks + half.asks, - images = row.images + half.images, - ) - } else { - row - } - } - val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater } - return adoptRun(healed, kept) + kept -} - -/** - * Rejoins a message the page boundary cut, and hands back the two pages to concatenate. - * - * [foldEvent] never leaves two assistant messages next to each other inside one page, so two - * meeting at a join are always the two halves of one reply, and leaving them apart drew a single - * answer as two with a paragraph break through the middle of a sentence. - * - * The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older - * half brings, which is safe here and nowhere else -- the join is at the oldest end of what is - * loaded, so the growth extends off the top of the screen. - */ -private fun healSplitMessage( - earlier: List, - later: List, -): Pair, List> { - val head = earlier.lastOrNull() - val tail = later.firstOrNull() - if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) { - return earlier to later - } - return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1)) -} - -/** - * Hands the older calls at the join the name of the run they are joining. - * - * The two pages were folded separately, so a run split by the boundary came back as two runs with - * two names. Naming the joined run after the *older* half would be the obvious way round and is - * wrong: the newer half is the part already on screen, and renaming it is renaming the row the - * reader is looking at, which is how a list loses its anchor. - */ -private fun adoptRun( - earlier: List, - later: List, -): List { - val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier - // A question is in a run of its own on both sides of the join, the same as it would be had the - // two pages been folded as one. Without this the heal would merge a group straight through the - // row the reader was asked something on. - if (first.tool == ASK_USER_QUESTION) return earlier - val joining = first.runId - val tail = earlier.takeLastWhile { - it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION - } - if (tail.isEmpty()) return earlier - return earlier.dropLast(tail.size) + - tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) } -} - -/** - * A peer message goes above the turn it started, not where it happened to arrive. - * - * The live Claude Code path cannot record it in place: the CLI says nothing about a peer message - * until the turn's `result`, so the event lands below the whole reply it caused. The server stamps - * it with where that turn began and the note takes that seq. - * - * Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and - * paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as - * its identity ([TranscriptItem.PeerNote.arrived]). The argument for sharing was that the turn's - * seq belongs to a status change and a status draws no row -- true, and it answered the wrong - * question: what two notes stamped with the same turn collide with is each other. - * - * Without a stamp -- a message replayed out of a session file -- it stays where it arrived. - */ -private fun placePeerNote( - items: List, - seq: Long, - event: SessionEvent.PeerMessage, -): List { - val at = event.turnStart ?: return items + TranscriptItem.PeerNote(seq, event.from, event.text) - val note = TranscriptItem.PeerNote(at, event.from, event.text, arrived = seq) - val index = items.indexOfFirst { it.seq > at } - if (index < 0) return items + note - val behind = (items.getOrNull(index - 1) as? TranscriptItem.ToolRun)?.runId - return items.subList(0, index) + note + splitRun(items.subList(index, items.size), behind) -} - -/** - * The calls the note now sits in front of, renamed if they were sharing a run with the calls behind - * it. - * - * A run is named from what a call landed next to, and nothing there knows about turns -- so a turn - * opening with a tool call, straight after one that ended with one, folds them into a single run. - * Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows - * with one key, which a keyed list cannot draw at all. - * - * The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right - * for the opposite reason: there the two halves were always one run, here they were never one - * turn's work. - */ -private fun splitRun(tail: List, behind: String?): List { - val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail - if (behind == null || first.runId != behind) return tail - val run = tail.takeWhile { it is TranscriptItem.ToolRun && it.runId == behind } - return run.map { (it as TranscriptItem.ToolRun).copy(runId = first.id) } + tail.drop(run.size) -} - -fun foldEvent(items: List, entry: SeqEvent): List = - when (val event = entry.event) { - is SessionEvent.UserMessage -> - items + TranscriptItem.UserMsg(entry.seq, event.text, event.attachments) - is SessionEvent.AssistantText -> { - // Deltas accumulate into the message they're streaming, which keeps the seq of the - // first of them: a row whose identity changed with every delta would be a new row on - // every frame, and the list would jump for the whole of a streamed answer. - val last = items.lastOrNull() - if (last is TranscriptItem.AssistantMsg) { - // A message growing again is not finished, whatever a status said in between. - items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false) - } else { - items + TranscriptItem.AssistantMsg(entry.seq, event.delta) - } - } - is SessionEvent.ToolStart -> - items + - TranscriptItem.ToolRun( - entry.seq, - event.id, - runIdFor(items, event.id, event.tool), - event.tool, - event.input, - "", - done = false, - ) - is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } - is SessionEvent.ToolEnd -> - // Created when its start is not here, rather than dropped. A fold that only ever - // *updates* loses the whole call when the start fell outside the loaded window, and a - // tool call that renders as nothing is indistinguishable from one that never happened. - // Loading the page before this one replaces the row with the real thing. - if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { - updateTool(items, event.id) { it.copy(output = event.output, done = true) } - } else { - items + - TranscriptItem.ToolRun( - entry.seq, - event.id, - // The name is not known from an end alone, so a call that was an ask cannot - // be recognised as one here; the page before this replaces the row. - runIdFor(items, event.id, "tool"), - "tool", - "", - event.output, - done = true, - ) - } - is SessionEvent.Question -> { - val card = - TranscriptItem.QuestionCard( - entry.seq, - event.id, - event.prompt, - event.header, - event.options, - event.multiSelect, - emptyList(), - ) - // A question with no tool behind it -- AskUserQuestion, or an ask whose call fell - // outside the loaded window -- is a card of its own. - if ( - event.about != null && - items.any { it is TranscriptItem.ToolRun && it.id == event.about } - ) { - updateTool(items, event.about) { it.copy(asks = it.asks + card) } - } else { - items + card - } - } - is SessionEvent.Answered -> - // Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the - // second left an Allow/Deny pair live on a question already answered from another - // device. - items.map { - when { - it is TranscriptItem.QuestionCard && it.id == event.id -> - it.copy(answers = event.answers) - it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } -> - it.copy( - asks = - it.asks.map { ask -> - if (ask.id == event.id) ask.copy(answers = event.answers) - else ask - } - ) - else -> it - } - } - is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event) - is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text) - // Screen-level state, not transcript rows -- see SessionScreen. - is SessionEvent.CommandQueued -> items - // No row of its own: a message that is still waiting is drawn as a pending bubble below the - // transcript, and becomes an ordinary one where the session read it. - is SessionEvent.MessageQueued -> items - // The bubble goes away and nothing takes its place: the message was never read, so there is - // nothing it belongs above. - is SessionEvent.MessageDropped -> items - is SessionEvent.Settings -> items - is SessionEvent.Status -> settleReply(items, event.state) - is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message) - is SessionEvent.Image -> - // Under the call that produced it when there is one, and a row of its own when there is - // not -- a person's own attachment belongs to no call, and neither does one whose call - // fell outside the loaded window. - if ( - event.about != null && - items.any { it is TranscriptItem.ToolRun && it.id == event.about } - ) { - updateTool(items, event.about) { it.copy(images = it.images + event.ref) } - } else { - items + TranscriptItem.ImageItem(entry.seq, event.ref) - } - is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt) - is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq) - is SessionEvent.Compacted -> - items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens) - is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]") - // Screen-level state, not transcript rows -- see SessionScreen. - is SessionEvent.UsageDelta -> items - } - -/** - * A status saying the session stopped working is the moment its newest reply is finished. - * - * See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of - * their own, so a replayed session settles its replies the same way a live one does. - */ -private fun settleReply(items: List, state: String): List { - if (sessionWorking(state)) return items - val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items - if (last.settled) return items - return items.dropLast(1) + last.copy(settled = true) -} - -private fun updateTool( - items: List, - id: String, - change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun, -): List = items.map { - if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it -} - -/** - * Where markdown is parsed ahead of being drawn: two threads, never all of them. - * - * The default dispatcher sizes itself to the machine, which is right for work somebody is waiting - * on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and - * taking every core for them leaves the thread that draws the frame queueing behind one -- measured - * on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile. - */ -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -private val parsingThreads = Dispatchers.Default.limitedParallelism(2) - -/** - * Parses the markdown among [rows], off whatever thread is drawing. - * - * Called where a page of transcript is folded rather than where a row is composed, which is the - * whole point: the work happens seconds before the reader reaches the rows it was done for. - * - * What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer - * message -- because a string warmed under a key no row ever looks up is a miss that nothing - * reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same - * [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages - * hand it back through here. - * - * Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer - * message was missing: this used to filter for assistant replies alone, so the one row type nobody - * had thought about paid its whole parse in the frame it appeared in. - */ -suspend fun warm(replies: ParsedReplies, rows: List) { - withContext(parsingThreads) { - val texts = rows.flatMap { row -> - when (row) { - is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text } - // A message from another agent is markdown too, and it is the longest thing in a - // transcript often enough that leaving it out was the whole of why one cost a fifth - // of a second to open. - is TranscriptItem.PeerNote -> listOf(row.text) - else -> emptyList() - } - } - if (texts.isNotEmpty()) replies.warm(texts) - // After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence - // to draw these as blocks on the composing thread. - rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) } - } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt deleted file mode 100644 index 6e271fa..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt +++ /dev/null @@ -1,128 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.text.selection.SelectionState -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * The transcript: a lazy list of [TranscriptUnit]s, laid out in reverse. - * - * Reverse layout is what makes the two insertions this list gets free rather than corrected. Item - * zero is the newest content and sits at the bottom, so a message arriving extends the end the - * viewport is pinned to and following it is not an effect -- and a page of older history lands at - * indices past everything visible, which moves nothing on screen. The keyboard is the same case - * from the other side: the viewport shrinks and the anchored item stays against its bottom edge. - * - * The lazy list is also the whole of the windowing. Only what is near the viewport is composed and - * alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the - * property a plain column here had to approximate with retained ranges and stand-in spacers, each - * of which was a way to flicker. - * - * What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a - * reply, and its parse is already made by [warm] before the fold that introduces it. - * - * The whole list sits in a [SelectionContainer], which is what makes every word selectable by the - * platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one - * body of text to a reader, and a container per row would mean a selection could never cross from a - * reply into the tool output that follows it -- and would leave whatever was drawn without one - * silently unselectable. Rows keep their tap handlers: selection is a long press. - * - * [selection] is the container's own state, held by the caller rather than made here, because the - * rows have to be able to ask whether anything is selected before they act on a tap. - */ -@Composable -fun TranscriptList( - units: List, - state: LazyListState, - moreHistory: Boolean, - selection: SelectionState, - modifier: Modifier = Modifier, - below: @Composable () -> Unit, - unit: @Composable (TranscriptUnit) -> Unit, -) { - SelectionContainer(selection) { - LazyColumn( - state = state, - reverseLayout = true, - contentPadding = TRANSCRIPT_PADDING, - modifier = - // Timed in two halves because the frame's draw phase is where Compose's measurement - // lands, and "draw is high while nothing is being recorded" does not say which - // half. Measure includes composing the items that scrolled in. - modifier - .layout { measurable, constraints -> - val started = System.nanoTime() - val placeable = measurable.measure(constraints) - DebugStats.record( - "measure: the whole transcript", - System.nanoTime() - started, - ) - layout(placeable.width, placeable.height) { - val placing = System.nanoTime() - placeable.place(0, 0) - DebugStats.record( - "place: the whole transcript", - System.nanoTime() - placing, - ) - } - } - .drawWithContent { - val started = System.nanoTime() - drawContent() - DebugStats.record("draw: the whole transcript", System.nanoTime() - started) - }, - ) { - // The bottom of the screen: what is waiting to be read sits under the newest message. - // The one item before the units, which is what [UNITS_START] counts. - item(key = "below", contentType = "below") { below() } - items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) { - val u = units[it] - DebugStats.count("unit composed") - Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) } - } - // Standing in for everything not fetched yet. Only here while there is more -- its - // appearance at the top edge is also roughly when the next page is asked for, so what - // it reports is a fetch in flight rather than an end reached. - if (moreHistory) { - item(key = "history", contentType = "history") { - Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) { - CircularProgressIndicator( - Modifier.align(Alignment.Center).size(HISTORY_SPINNER) - ) - } - } - } - } - } -} - -/** - * How many of the list's own items come before the first unit -- the waiting-messages slot. - * - * Named once because two things count on it: the list building itself, and [visibleUnits] turning a - * list index back into the unit that was drawn there. Read off by hand at the second of those, it - * is an off-by-one that misnames every row in a report and looks like a plausible answer. - */ -const val UNITS_START = 1 - -/** The gap between rows, and the room around the whole conversation. */ -val TRANSCRIPT_SPACING: Dp = 8.dp - -val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp) - -/** Smaller than the whole-screen loading spinner: it stands in for a page, not for everything. */ -private val HISTORY_SPINNER = 24.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt deleted file mode 100644 index 10c7c32..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt +++ /dev/null @@ -1,177 +0,0 @@ -package com.example.aiapp - -import android.content.Context -import java.io.File -import java.util.concurrent.atomic.AtomicReference - -/** - * Where the session screen gets a transcript from: this phone's copy first, the server for the - * rest. - * - * One seam rather than a cache the screen has to remember to consult. Everything it fetched before - * is asked of this, and everything the server sends is written into the cache on the way past, so - * the screen never learns which side answered. What it does learn, through [DebugStats], is how - * often each one did. - * - * See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing. - * Every read has a network path beside it producing the same result. - */ -class TranscriptSource( - private val settings: ServerSettings, - private val address: TranscriptAddress, - val cache: SessionCache, -) { - private val stream = AtomicReference(null) - - /** - * The cached opening window, or null when there is nothing usable to draw. - * - * Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on - * screen while the check that they are still the server's rows is in flight, and a failed check - * replaces them exactly as a `reset` does. - */ - fun cachedOpening(limit: Int = OPENING_WINDOW): List? { - if (cache.tail() == null) return null - val lines = cache.newest(limit) - if (lines.isEmpty()) return null - return try { - lines.map { parseSeqEvent(it) } - } catch (e: org.json.JSONException) { - // Lines this build cannot read at all, which the cache's own checks cannot see: it - // reads a seq off a line, not an event. Nothing to serve, so a cold open. - cache.purge() - null - } - } - - /** - * Whether the server's event at the cached cursor is still the cached one. - * - * The screen must not resume a stream from a cached seq unless it is the same conversation. A - * transcript is append-only in ordinary use, but the file can be replaced or truncated -- a - * sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the - * server's catch-up on such a file would hand this phone a continuation of a *different* - * conversation, spliced onto the cached one with no seam. Caught with one request of a few - * hundred bytes, in the slot the opening page's request used to be in. - * - * False purges the cache and means "open cold". A throw is the server not being askable, which - * is neither: the cached rows stay on screen and the caller tries again on the reconnect - * schedule. - * - * What this cannot see is a line changed in the middle of the file with the tail intact. That - * is what the Reload button in session settings is for. - */ - suspend fun probe(): Boolean { - val tail = cache.tail() ?: return false - // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* - // the cursor when the server still has one there. - val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1) - val matches = - answer.size == 1 && - try { - answer[0].second == parseSeqEvent(tail.line) - } catch (e: org.json.JSONException) { - false - } - if (!matches) cache.purge() - return matches - } - - /** - * Today's opening fetch, kept as the start of the live run. Only called when the cache has - * nothing to open with, or when [probe] said what it had was not the server's. - */ - suspend fun fetchOpening(): List { - DebugStats.count("transcript page from server") - val page = fetchTranscript(settings, address, limit = OPENING_WINDOW) - page.forEach { (line, entry) -> cache.append(line, entry.seq) } - cache.flush() - return page.map { it.second } - } - - /** - * The page before [before]: from the cache when it holds it, otherwise from the server bounded - * by what the cache already has. - * - * The bound is what keeps the cache worth having. A coalesced page reaches back as far as its - * row count takes it -- a single reply is hundreds of lines -- so a page fetched after the - * reader has been away would run straight past the cached run and overlap it, and an - * overlapping page cannot be stored. Told where this phone's copy starts, the server stops - * there instead. - */ - suspend fun page(before: Long, limit: Int, coalesce: Boolean): List { - cache.page(before, limit, rows = coalesce)?.let { lines -> - DebugStats.count("transcript page from cache") - return lines.map { parseSeqEvent(it) } - } - DebugStats.count("transcript page from server") - val page = - fetchTranscript( - settings, - address, - before = before, - limit = limit, - coalesce = coalesce, - after = cache.coveredUpTo(before)?.minus(1), - ) - if (page.isNotEmpty()) { - // `before` rather than the newest line's seq: a coalesced page covers everything up to - // the cursor it was asked with, and nothing in its lines says so. - cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce) - } - return page.map { it.second } - } - - /** - * [EventStream.run], with every frame written to the cache before [onEvent] sees it. - * - * Before, so that an event held back for a reader who is scrolled away is already on disk -- - * what the cache holds is what the server sent, not what the screen has got round to drawing. - * Flushed on each status change, which is a turn's boundary and the granularity a crash may as - * well lose. - */ - fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { - val opened = EventStream(settings, address) - stream.getAndSet(opened)?.close() - try { - opened.run(after, onOpen, onReset) { raw, entry -> - cache.append(raw, entry.seq) - if (entry.event is SessionEvent.Status) cache.flush() - onEvent(entry) - } - } finally { - cache.flush() - } - } - - /** Ends the stream, from any thread, and leaves the cache with everything it was given. */ - fun close() { - stream.getAndSet(null)?.close() - cache.flush() - } -} - -/** - * How many events the screen opens with, cached or fetched. - * - * The server's own default for a page, named here because the cached opening has to be the same - * size as the fetched one -- a reader must not get a shorter first screen for having been here - * before. - */ -private const val OPENING_WINDOW = 80 - -/** - * Where this server's cached transcripts live. - * - * Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the - * server, which Android may delete under storage pressure without asking. Keyed by host and port - * because two servers can hold a session with the same id, and a line from one shown against the - * other is the whole invariant broken. `v1` is the layout's version. - */ -fun cacheRoot(context: Context, settings: ServerSettings): File { - val transcripts = File(context.cacheDir, "transcripts") - transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() } - return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}") -} - -private const val CACHE_VERSION = "v1" diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt deleted file mode 100644 index 8374d19..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt +++ /dev/null @@ -1,478 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.runtime.Immutable -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -/** - * One item of the transcript list: a whole row, or one block of a settled reply. - * - * The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item - * at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be - * twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when - * the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst - * frame is bounded. This is the piece that was missing when a lazy list was last tried here. - * - * Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit - * points back at its row. The list draws units; anchors and paging still speak seq. - */ -@Immutable -sealed class TranscriptUnit { - /** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */ - abstract val key: Any - - /** Where this unit's row starts in the transcript -- the anchor identity, never the key. */ - abstract val seq: Long - - /** - * This unit's position within its row, counted from the row's oldest end. What a saved scroll - * position carries besides the seq: a reply split into forty blocks needs more than "somewhere - * in this row" to put a reader back where they stopped. - */ - abstract val ordinal: Int - - /** The gap drawn above this unit -- between rows, or between blocks of one reply. */ - abstract val gap: Dp - - /** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */ - data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() { - override val key: Any - get() = row.key - - override val seq: Long - get() = row.startSeq - - override val ordinal: Int - get() = 0 - } - - /** One [Piece] of a settled reply; [text] is the prose it is a piece of. */ - data class Block( - override val seq: Long, - override val ordinal: Int, - val text: String, - val piece: Piece, - override val gap: Dp, - ) : TranscriptUnit() { - override val key: Any - get() = "b$seq:$ordinal" - } - - /** - * The heading of a message from another agent: who sent it, and the control that opens it. - * - * A peer message is the one row whose *opened* size is unbounded -- these are the longest - * things a transcript holds -- so it is flattened the same way a settled reply is, and for the - * same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the - * draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from - * 0.39ms to 3.15ms. - * - * The card is drawn in pieces rather than given up: a filled Material card is elevation zero, - * so it has no shadow to break, and each piece paints the same fill with only the corners it - * owns. - */ - data class PeerHead( - override val seq: Long, - val item: TranscriptItem.PeerNote, - val open: Boolean, - override val gap: Dp, - ) : TranscriptUnit() { - /** - * The note's own key, so opening and shutting does not change what the list is anchored on - * -- and so two notes stamped with one turn's seq are still two items. - */ - override val key: Any - get() = item.key - - override val ordinal: Int - get() = 0 - } - - /** - * One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is - * always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn - * inside the piece where the card's fill covers it. - */ - data class PeerBlock( - override val seq: Long, - override val ordinal: Int, - val text: String, - val piece: Piece, - val last: Boolean, - val spacing: Dp, - override val gap: Dp, - /** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */ - val note: Any, - ) : TranscriptUnit() { - override val key: Any - get() = "p$note:$ordinal" - } - - /** - * One slice of a long user message; see [userChunks]. - * - * A user message is plain text, so cutting it costs a scan rather than a parse -- but the - * reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand - * pixels of `Text` whose layout lands in the frame the row scrolls into. - */ - data class UserChunk( - override val seq: Long, - override val ordinal: Int, - val text: String, - val first: Boolean, - val last: Boolean, - /** The message's attachments, drawn under the words -- so only the last slice has any. */ - val attachments: List, - override val gap: Dp, - ) : TranscriptUnit() { - override val key: Any - get() = "u$seq:$ordinal" - } - - /** - * The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES]. - * - * Its own unit rather than something inside the row above it, because the row above it is a - * *bounded* item now and this is what says so -- and because a control that lives inside the - * thing it reveals moves the moment it is pressed. - */ - data class ShowAll( - override val seq: Long, - override val ordinal: Int, - /** The row this belongs to; what goes into the set of rows shown whole. */ - val row: Any, - /** The line count of the whole message, which is what the offer says. */ - val lines: Int, - override val gap: Dp, - ) : TranscriptUnit() { - override val key: Any - get() = "s$row" - } - - /** One memory note of a settled reply; see [MemoryNote]. */ - data class Memory( - override val seq: Long, - override val ordinal: Int, - val part: MessagePart.Remembered, - override val gap: Dp, - ) : TranscriptUnit() { - override val key: Any - get() = "m$seq:$ordinal" - } -} - -/** - * A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it. - * - * The cut happens **before** the flatten below decides how to draw the row, so everything after it - * -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The - * shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are - * untouched by a reader opening or closing one. - * - * A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two - * hundred lines while the model was plainly still writing would read as the stream having died. - * `iris`'s `row::build_row` states the same rule for the same reason. - */ -private fun capRow(row: TranscriptRow, shownWhole: Set): Pair { - val item = (row as? TranscriptRow.Single)?.item ?: return row to null - if (row.key in shownWhole) return row to null - val cut = - when { - item is TranscriptItem.UserMsg -> - cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let { - it to TranscriptRow.Single(item.copy(text = it.shown)) - } - item is TranscriptItem.AssistantMsg && item.settled -> - cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let { - it to TranscriptRow.Single(item.copy(text = it.shown)) - } - else -> null - } ?: return row to null - return cut.second to cut.first.lines -} - -/** - * The rows flattened into list units, newest first -- index zero is the item at the bottom of the - * screen, which is what a reversed lazy list calls the start. - * - * Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever - * cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one - * is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text - * changes with every delta, and splitting it here would parse the whole message per delta on - * whichever thread is composing. Once settled it splits like every other reply, which is what - * bounds the newest row's cost after a session ends on a long one. - * - * Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm - * path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has - * seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once. - */ -fun transcriptUnits( - rows: List, - replies: ParsedReplies, - openNotes: Set, - shownWhole: Set = emptySet(), -): List { - val started = System.nanoTime() - val units = ArrayList(rows.size) - rows.forEachIndexed { index, whole -> - val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING - val (row, hidden) = capRow(whole, shownWhole) - val rowStart = units.size - val item = (row as? TranscriptRow.Single)?.item - if (item is TranscriptItem.PeerNote) { - val open = item.seq in openNotes - units += TranscriptUnit.PeerHead(row.startSeq, item, open, rowGap) - // No gap between the pieces: they are one card, and a card with a stripe through it is - // what any spacing here would draw. - if (open) { - val pieces = replies.piecesOf(item.text) - var previous: Piece? = null - pieces.forEachIndexed { at, piece -> - units += - TranscriptUnit.PeerBlock( - row.startSeq, - at + 1, - item.text, - piece, - last = at == pieces.lastIndex, - spacing = gapBefore(previous, piece), - gap = 0.dp, - note = item.key, - ) - previous = piece - } - } - } else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) { - // A scan, not a parse, so it is cheap enough for the fold path -- and cached like the - // markdown splits so the scan happens once per message rather than once per fold. - val chunks = replies.chunksOf(item.text) - chunks.forEachIndexed { at, chunk -> - units += - TranscriptUnit.UserChunk( - row.startSeq, - at, - chunk, - first = at == 0, - last = at == chunks.lastIndex, - attachments = if (at == chunks.lastIndex) item.attachments else emptyList(), - gap = if (at == 0) rowGap else 0.dp, - ) - } - } else if ( - item is TranscriptItem.AssistantMsg && - splitWanted(item, index, rows.lastIndex) && - replies.splitReady(item.text) - ) { - var ordinal = 0 - fun gap(within: Dp) = if (ordinal == 0) rowGap else within - replies.partsOf(item.text).forEach { part -> - when (part) { - is MessagePart.Prose -> { - var previous: Piece? = null - replies.piecesOf(part.text).forEach { piece -> - units += - TranscriptUnit.Block( - row.startSeq, - ordinal, - part.text, - piece, - gap(gapBefore(previous, piece)), - ) - ordinal++ - previous = piece - } - } - is MessagePart.Remembered -> { - units += - TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING)) - ordinal++ - } - } - } - } else { - units += TranscriptUnit.Whole(row, rowGap) - } - if (hidden != null) { - units += - TranscriptUnit.ShowAll( - row.startSeq, - units.size - rowStart, - row.key, - hidden, - BLOCK_SPACING, - ) - } - } - units.reverse() - reportDuplicateKeys(units) - // Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is - // this number growing, and it was invisible until it was written down. - DebugStats.record("units flattened", System.nanoTime() - started) - return units -} - -/** - * Whether this reply should be drawn as blocks: settled, or anywhere but the newest row. - * - * Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are - * answered by different things: this one by the fold, the other by whether [warm] has run. - */ -private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) = - item.settled || index != lastIndex - -/** - * The replies among [rows] that should draw as blocks but whose parses are not made yet. - * - * Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold - * is the reply that just finished streaming -- nothing warms live deltas. The session screen warms - * what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against - * ready parses. - */ -fun unwarmedReplies( - rows: List, - replies: ParsedReplies, - shownWhole: Set = emptySet(), -): List = rows.mapIndexedNotNull { index, whole -> - // The *capped* row's text, since that is what the flatten will draw and so what has to be - // ready: a capped row draws its head, which is a different string from the message and so a - // different cache entry. - val row = capRow(whole, shownWhole).first - val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg - item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) } -} - -/** - * Above this many characters, a user message is drawn in slices rather than as one bubble. - * - * Not zero, because a bubble's width wraps its content: slices have to fill the row to look like - * one bubble, and forcing that on a short message would visibly widen it. A message past this - * length has lines that wrap, so its bubble is at the full width already and the slices match it - * exactly. Below it, one item of at most a few screens is nothing the list minds composing. - */ -const val USER_SPLIT_CHARS = 4000 - -/** - * Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point. - * - * About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so - * its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the - * phone. Smaller buys nothing -- the seams are free -- but the units multiply. - */ -private const val USER_CHUNK_CHARS = 1000 - -/** - * A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS]. - * - * At newlines only, never mid-line: text layout runs per line, so slices that own whole lines stack - * back into exactly the lines the single `Text` drew, and a cut inside one would reflow it. The - * newline at each cut is dropped -- the boundary between two stacked slices *is* that line break. A - * single line longer than a slice (minified JSON, a base64 blob) stays whole in its slice, so a - * slice is bounded by the longest line rather than absolutely. - */ -fun userChunks(text: String): List { - val chunks = ArrayList() - var start = 0 - while (start < text.length) { - if (text.length - start <= USER_CHUNK_CHARS) { - chunks += text.substring(start) - break - } - var cut = text.lastIndexOf('\n', start + USER_CHUNK_CHARS) - if (cut <= start) cut = text.indexOf('\n', start + USER_CHUNK_CHARS) - if (cut < 0) { - chunks += text.substring(start) - break - } - chunks += text.substring(start, cut) - start = cut + 1 - } - return chunks -} - -/** - * Says which two units share a key, before the list dies of it. - * - * A duplicate key is fatal -- `LazyColumn` throws, and the app goes down in the middle of somebody - * reading a conversation -- and all the framework's message carries is the key. When that key is a - * seq it names neither row, and there is no way back from it to how the two came to share one: it - * took an afternoon and a fixture that could reproduce it. Two lines here answered it immediately, - * naming both rows and the field they had in common ([TranscriptItem.PeerNote.arrived]). - * - * Always on, for the same reason [DebugStats] is: an instrument that is only in the build nobody is - * holding when it breaks is not an instrument. It costs one map over the units that were just - * built, beside a loop that already allocates one entry per unit. - */ -private fun reportDuplicateKeys(units: List) { - val seen = HashMap() - units.forEach { unit -> - val had = seen.put(unit.key, unit) - if (had != null) { - android.util.Log.w("ai-app", "duplicate unit key ${unit.key}: $had AND $unit") - } - } -} - -/** - * What is on screen right now, a unit at a time: what each one is and how tall it is. - * - * For the render report, and it is the line every "it is slow here" report has needed. The - * framework's own per-frame cost grows with how many nodes are *alive* rather than how many are on - * screen, so a screen holding one enormous item is slow in a way that no counter of ours - * distinguishes from a screen holding twenty ordinary ones -- and "2 units visible" says one of - * them is enormous without saying which. This says which. - * - * [first] is the index the list gave the first *unit*: the list also holds the waiting-messages - * slot at index zero and the history spinner past the end, and both are named here rather than - * silently reported as whichever unit is nearest. - */ -fun visibleUnits(units: List, visible: List, first: Int): String = - if (visible.isEmpty()) " nothing on screen" - else - " on screen: " + - visible.joinToString(", ") { info -> - "${units.getOrNull(info.index - first).kind} ${info.size}px" - } - -/** What a unit is, in a word, for [visibleUnits]. Null is one of the list's own non-unit items. */ -private val TranscriptUnit?.kind: String - get() = - when (this) { - null -> "the list's own" - is TranscriptUnit.Block -> - if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item" - is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading" - is TranscriptUnit.PeerBlock -> "peer block" - is TranscriptUnit.UserChunk -> "user slice" - is TranscriptUnit.Memory -> "memory note" - is TranscriptUnit.ShowAll -> "show all" - is TranscriptUnit.Whole -> - when (val row = row) { - is TranscriptRow.Tools -> "tool group" - // The class name rather than a word per kind: this is a diagnostic, and a - // `when` here would be one more place that has to gain a case whenever the - // transcript does -- silently naming a new row after an old one until somebody - // noticed. - is TranscriptRow.Single -> row.item::class.simpleName.orEmpty() - } - } - -/** - * Where the unit named by a saved position sits in [units], or null if its row is not loaded. - * - * The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older - * unit when the exact one is gone -- a reply regrouped by a page boundary can split into a - * different number of blocks than it had when the position was saved, and "a little above where - * they stopped" loses less than the newest end does. - */ -fun unitIndexFor(units: List, seq: Long, ordinal: Int): Int? { - var best: Int? = null - var bestOrdinal = -1 - units.forEachIndexed { index, unit -> - if (unit.seq == seq && unit.ordinal <= ordinal && unit.ordinal > bestOrdinal) { - best = index - bestOrdinal = unit.ordinal - } - } - return best ?: units.indexOfFirst { it.seq == seq }.takeIf { it >= 0 } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt deleted file mode 100644 index 5794ff6..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UniqueItems.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.lazy.LazyItemScope -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable - -/** - * Keyed [items], with anything repeating a key already used left out. - * - * A lazy list throws when two of its items claim the same key, and the throw happens during measure - * on the main thread -- so it is not an error the screen can show, it closes the app. That is a - * disproportionate answer to a list with a repeat in it, and it lands on the reader rather than on - * whoever produced the repeat: on 2026-08-31 the import list crashed on a Claude Code session id - * recorded under two project directories, which is an ordinary state of a machine. - * - * Every list in this app keyed on an id keyed it on an id *the server chose*, so all of them shared - * the hazard and none could rule it out locally. Hence one function they all go through. - * - * Dropping the repeat is right here because the key is the whole identity: two rows with one id are - * two rows every action would treat as the same thing. Where the duplicate means something, the fix - * belongs at the source, and this is only what stops a data problem from being a crash. It is - * counted so the render report says it happened rather than leaving a silently shorter list. - * - * The transcript's own list is deliberately not on this: its keys are made here rather than - * received, and it is the one list where an extra pass over the items is measurable. - */ -inline fun LazyListScope.uniqueItems( - items: List, - crossinline key: (T) -> Any, - noinline contentType: (T) -> Any? = { null }, - crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, -) { - val seen = HashSet(items.size) - val unique = items.filter { seen.add(key(it)) } - if (unique.size != items.size) { - DebugStats.count("list items dropped for a repeated key") - } - items(unique, key = { key(it) }, contentType = contentType) { itemContent(it) } -} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt deleted file mode 100644 index a95e071..0000000 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.example.aiapp - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import java.time.OffsetDateTime - -/** - * Window bars for the account's rate limits, with reset times. - * - * A dialog rather than a screen. Usage is something you check *against* what you were reading -- - * "can I start this" is asked with the transcript still on screen -- and pushing a whole screen for - * it took the session away to answer a question about the session. It also has no navigation of its - * own, so the only thing its Back could ever have meant was "put this away". - */ -@Composable -fun UsageDialog(feed: UsageFeed, onDismiss: () -> Unit) { - // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the gaps - // between its title, content and buttons at sizes meant for a sentence of prose and a decision; - // this is a dense read-out, and those gaps left a band of empty dialog above Close that was - // taller than a bar. - Dialog(onDismissRequest = onDismiss) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Deliberately not subtitled with the provider this was opened from. These - // numbers belong to an account on a particular machine -- naming the session's - // provider here made an echo session's screen read "echo" above a line reading - // "claude". Each machine names itself and the service it came from. - Text( - "Usage", - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f), - ) - // A spinner in the button's place while the answer is on its way, since the - // numbers under it stay put during a refresh -- without it, pressing refresh - // over an unchanged read-out looks like a button that does nothing. - if (feed.refreshing) { - GlyphSpinner("Refreshing usage") - } else { - GlyphButton(REFRESH_GLYPH, "Refresh usage", feed.refresh) - } - } - Spacer(Modifier.height(8.dp)) - // Scrolls rather than being trimmed: a machine can report any number of windows and - // there can be any number of machines, and a dialog is the one place where running - // out of room is silent. `fill = false` so a short read-out keeps a short dialog. - Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { - UsageBody(feed.snapshots) - } - TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { - Text("Close") - } - } - } - } -} - -/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ -@Composable -private fun UsageBody(state: LoadState>) { - Column { - when (val current = state) { - is LoadState.Loading -> CircularProgressIndicator() - is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error) - is LoadState.Loaded -> - if (current.value.isEmpty()) { - // Not an error and not a blank screen: no machine offers a paid service, so - // there is genuinely nothing to report and saying so is the answer. - Text( - "No machine here runs anything with usage limits.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - // No card around each machine. A card is a step up the surface ladder, and - // inside a dialog -- itself a raised surface -- the step barely renders while - // costing 16dp on every side. What separates one machine from the next is the - // line naming it. - current.value.forEachIndexed { index, snapshot -> - if (index > 0) { - Spacer(Modifier.height(20.dp)) - } - // Machine and service on one line: which account these numbers belong to is - // decided by both together, and stacked as a heading over a subtitle they - // read as a section of their own. Small and quiet, because the numbers - // below are what somebody opened this to see. - Text( - "${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - SnapshotState(snapshot) - snapshot.windows.forEachIndexed { windowIndex, window -> - // Between the bars, not after the last one: a trailing gap here is what - // put a band of empty dialog above the Close button. - if (windowIndex > 0) { - Spacer(Modifier.height(12.dp)) - } - WindowBar(window) - } - } - } - } - } -} - -/** - * Anything other than numbers: why this machine has none. - * - * The distinction the old single message could not draw. A machine nobody has logged in on is - * working exactly as somebody set it up, so it reads as a plain statement -- marking it would be - * the interface nagging about a decision already made. Only the two faults are coloured as faults. - */ -@Composable -private fun SnapshotState(snapshot: UsageSnapshot) { - when (snapshot.state) { - "ok" -> {} - "notLoggedIn" -> - Text( - "No Claude account on this machine.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - // Reached but refused, versus never reached at all: different things to go and do, so they - // say different things rather than sharing one "unavailable". - "failed" -> - Text( - snapshot.detail ?: "Couldn't read the limits from this machine.", - style = MaterialTheme.typography.bodyMedium, - color = failedColor, - ) - else -> - Text( - snapshot.detail ?: "Couldn't reach this machine.", - style = MaterialTheme.typography.bodyMedium, - color = failedColor, - ) - } -} - -@Composable -private fun WindowBar(window: UsageWindow) { - Column { - Row(modifier = Modifier.fillMaxWidth()) { - Text( - window.label + if (window.active) " (active)" else "", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Text("${window.percent.toInt()}%", style = MaterialTheme.typography.bodyMedium) - } - Spacer(Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) }, - color = quotaColor(window.percent), - modifier = Modifier.fillMaxWidth(), - ) - resetLine(window)?.let { - Spacer(Modifier.height(2.dp)) - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -/** - * "resets in 3h 12m" -- close enough for deciding whether to start a big task -- or nothing. - * - * Null for a window that is not running: there is no end to report. What this used to get wrong is - * the other missing case, a timestamp that arrived and could not be read -- printed raw, so a parse - * failure appeared as an ISO string in a sentence written for a person. Both are named in - * [WindowEnd], and the session bar words them the same way. - */ -private fun resetLine(window: UsageWindow): String? = - when (val end = windowEnd(window.resetsAt, OffsetDateTime.now())) { - WindowEnd.NotRunning -> null - WindowEnd.Unreadable -> "reset time unreadable" - is WindowEnd.Ends -> - if (end.until.isNegative) "resets soon" else "resets in ${formatSpan(end.until)}" - } diff --git a/app/androidApp/src/main/res/font/nerd_icons.ttf b/app/androidApp/src/main/res/font/nerd_icons.ttf deleted file mode 100644 index 2be19a12dcbadda9a8dde59cb73fca1259934f6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2860 zcmb7Gdu)@}6+hSC!%hNm9LF{=YCh-jB?U&o@q?y8O#(#Fq#>l>0DF+cvB^6ou+0_G z9xMqEDlLQ!7)z^ZnkIzU1S_L$05yMvXo{)?b!@PeZm28KHYh`kP6@F;?{`0kl1ck# z*E;v!b9~ zg(zhR|BHL-_eBy3NK#rZaMPZ)*Tdgi^sC3%Lx+5@DOBH>y?XM?M8;E~`IkQT}oLvB2#t6sT>!T66m39btHE^Y)zCE<~t->|1|5NzW z5b4~vKOxc{*cbW#WFXR}Oe9T_m7WKOCCgsua@(`y99OpLwRn7PdkT}YUtpM2kDvM6 z%NQnm79(|uT;i#$^vsO(T&ZlXkaua6*OE7-oz82iP}xXctNJPXx!PJGg`d2?M6GD9 z819uyA)xIAW8PhDNUdziWACf#UG|H-SK444zJyIAhsgxQ=3>Sa=3pk8d1M&96eE!? z#fuG7iMaQY2;?_$FT5Yr!p06r*l8J&WOI4_7LVWO_j-JVZu@i0?y%b|EXye8Waq11 zW5-PHSl^+WT{SgbxB3oiOV$?E)D*4F2!%!ro9|w{SfW&QbyX>)Z(qC{e@8n|^I}oa zi#7B8N&}LgqWhJ?{BUg|hTnwX)+fOYL^?Sc17R|TWkOgJn*_qodoD78%;ba?m&L{4 zQU=^~*fV72%9d@(-1~iAixik05F^^zfrf_Bfq{m`F?ROJxw^i-I@wdVp-i~U#%9KY z4Q%OnD6CD58NbxGduDuh-}x#gB&hiw{_-+Th|pwYsAUfHF8^Ag94(&YE;M&p^E4E30)pF$0fMq zr)r|n8o7h^GyR%Jv=9!u1N6u?8 zdHyMs4ql%ppQ+hqmclG9B>j$}X#ZB~l2Qvkn@9TPg4ONhh<=E?B; zq2{YOicvPo84lEzOUcW%x`6TW!fB%NaG;_Cs^uHU-W$HIJ$Ufhk^cVv`}bKEYhW2H zW0y9)ZEWoEhv(0S!{N|{^B)c|Ypd2M9F&TVR&VV;dhcHR5BEOnKXUUXtN!y32Ew6V zUtmAG0On|`78DL9W0=Ny+#rd_g4`@b9Zr+(Fh-X*Pr<=~lWRq)v)inOTXQ+f-+!7PLCmZ95Ynh?A*;kbdhK zkHtob+JNe`vNtoGYC{DjP^d|+cuOlQXl-d})Be=f!o-&=Up|wbX|}Ooy2+Rl+NiLF z)Ip%kMjU3=4J&P+d}vsuhGZ3t|sjq6?7BhJRsmlrjrf{iuW%odauzmNBk z!#lK)R#8bZKVIHA(Cmz|pg4?bPPT}PgL{J(GS@)RLcl|QAMYqey&)ovUS!iQ(!pIj z3JP8kK8cS%zqxW7Tk!DK8>jxF9g1|6ly+t-%JSvTqg$%0Dp<~y5%IUxTaP#cMb7rp zvZC(8=XUJqso7LsU?_>L+_GYo2!`RMB9kI$U=(Z1(atX%4J9=&{da$@8P%c-b} z(eJKaJ>o11IQO1BapvUl8=udsP{hTnI(eV?UD6{xfu8C^e_uiP4lHI1nT7YoLbuGc>;&^Gj-WRy=USe~qnuZZ}Ra#b7+7;>PjYLXH zj%(f5x(C@y_zI!JP-a?yda&wU02EFu!Pz_=<+Pgy>23NuoL|k#Squ9XJI4OV?i&sm zJ~2ELO2Now08LB(xp=p^_;X4_{1`Wa*T2ozsb60FPNG-KHyto80BXV=(FeFT=o@@h zF~$J{fZH6wSd?5aL$@5(!dx`bo9Htg&@F!Px6UD&hz;fnWAbb(b;x!!z zUYW&@bRfzgvn8kl+F|mo1^c^rRz#lbGC*?AXeF@Z-T-|ab9;f-Quo|moW~=f0x!!6d<9_)duTTwm>Y-mjHiNVoyZyRUh+f0m zP7To0M*Hx86@{R!5xa8GI3JhcyH02c!OAzlSr4B7M^d3e+#N4q4Fz-y``|Ixs=!O` q=PG?&O)vcq54c~?e`)%+-}n`C{UAXZQ~UGlB8| diff --git a/app/androidApp/src/main/res/values/strings.xml b/app/androidApp/src/main/res/values/strings.xml deleted file mode 100644 index ca0d2bb..0000000 --- a/app/androidApp/src/main/res/values/strings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - AI Sessions - diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/AnsiTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/AnsiTest.kt deleted file mode 100644 index 35657aa..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/AnsiTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.example.aiapp - -import androidx.compose.ui.graphics.Color -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -/** - * What survives a terminal's escape sequences, and what the styling reads as. - * - * Asserted as the plain text and as the style over a named substring, rather than as span offsets, - * so a failure prints the output that was got wrong instead of a pair of numbers. - */ -class AnsiTest { - private val palette = - AnsiPalette( - colours = (0..15).map { Color(it, 0, 0) }, - foreground = Color(1f, 1f, 1f), - background = Color(0f, 0f, 0f), - ) - - private fun styled(text: String) = ansiStyled(text, palette) - - /** The style covering the first character of [word], or null where nothing styles it. */ - private fun styleOver(text: String, word: String) = - styled(text).let { annotated -> - val at = annotated.text.indexOf(word) - assertTrue(at >= 0, "no \"$word\" in ${annotated.text}") - annotated.spanStyles.firstOrNull { at >= it.start && at < it.end }?.item - } - - private val esc = '\u001B' - - @Test - fun `a colour becomes a span and the sequence itself disappears`() { - val text = "plain ${esc}[31mred${esc}[0m plain" - assertEquals("plain red plain", styled(text).text) - assertEquals(palette.colours[1], styleOver(text, "red")?.color) - assertNull(styleOver(text, "plain")) - } - - @Test - fun `bright, background and 256-colour forms all reach the same table`() { - assertEquals(palette.colours[9], styleOver("${esc}[91mx", "x")?.color) - assertEquals(palette.colours[4], styleOver("${esc}[44mx", "x")?.background) - // The first sixteen of the 256-colour table are the palette's own, so a program that - // spells a colour either way gets the same one. - assertEquals(palette.colours[1], styleOver("${esc}[38;5;1mx", "x")?.color) - // And past them, xterm's cube: 16 is its black corner, 231 its white one. - assertEquals(Color(0, 0, 0), styleOver("${esc}[38;5;16mx", "x")?.color) - assertEquals(Color(255, 255, 255), styleOver("${esc}[38;5;231mx", "x")?.color) - assertEquals(Color(10, 20, 30), styleOver("${esc}[38;2;10;20;30mx", "x")?.color) - } - - @Test - fun `everything that is not styling is dropped rather than printed`() { - // A cursor move, an erase, an OSC window title with its bell, and a bare two-character - // escape. None of them mean anything in a scrolling document, and all of them would be - // line noise if the escape alone were stripped and the body left behind. - val text = "a${esc}[2Jb${esc}[Kc${esc}]0;a titled${esc}=e" - assertEquals("abcde", styled(text).text) - } - - @Test - fun `a carriage return rewrites its line, as it does on a terminal`() { - // What a progress bar looks like: every state it passed through, ending on the last. - assertEquals("done\n", styled("10%\r50%\rdone\n").text) - // The line before it is untouched. A Windows line ending rewrites nothing and is not - // kept either: it is one line break, and passing the carriage return through would draw - // a stray control character in the middle of the output. - assertEquals("kept\nlast", styled("kept\r\nfirst\rlast").text) - } - - @Test - fun `a sequence cut off mid-stream takes no text with it`() { - // Output still arriving ends anywhere, including inside an escape. The fragment goes and - // the whole sequence arrives with the next delta. - assertEquals("text ", styled("text ${esc}[3").text) - } - - @Test - fun `unstyled text costs no spans at all`() { - assertEquals(0, styled("nothing to do here").spanStyles.size) - assertEquals(0, styled("a${esc}[2Jb").spanStyles.size) - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/DurationsTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/DurationsTest.kt deleted file mode 100644 index 6ca17b8..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/DurationsTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.aiapp - -import java.time.Duration -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * The two ways a span of time is written here, and the rule each of them follows. - * - * Both are read off a screen to make a decision -- how long a tool call may take, how long a quota - * has left -- so what matters is that the shortest form that answers the question is what appears. - */ -class DurationsTest { - @Test - fun `under a minute is the largest unit alone`() { - assertEquals("30ms", formatMillis(30)) - assertEquals("999ms", formatMillis(999)) - assertEquals("1s", formatMillis(1000)) - assertEquals("2.5s", formatMillis(2500)) - // One decimal, rounded rather than cut: 2.46s is nearer two and a half than two and four. - assertEquals("2.5s", formatMillis(2460)) - assertEquals("59.9s", formatMillis(59_900)) - } - - @Test - fun `a minute or more is every unit that has something in it`() { - // The figure this rule was written for: a tool timeout, which arrives as milliseconds and - // is unreadable as 480000. - assertEquals("8m", formatMillis(480_000)) - assertEquals("1m", formatMillis(60_000)) - assertEquals("1m 30s", formatMillis(90_000)) - assertEquals("5d 12h 4m", formatMillis(475_440_000)) - // Empty units are left out rather than written as zero: the labels say which is which, - // and "5d 0h 4m" is only longer. - assertEquals("5d 4m", formatMillis(432_240_000)) - } - - @Test - fun `only a whole number of milliseconds is rewritten`() { - assertEquals("8m", formatMillisText(" 480000 ")) - // A timeout a tool expressed some other way is its own words, passed through rather than - // guessed at. - assertEquals("2 minutes", formatMillisText("2 minutes")) - assertEquals("", formatMillisText("")) - } - - @Test - fun `a countdown rounds up, so it never reports a minute already spent`() { - assertEquals("3h 13m", formatSpan(Duration.ofMinutes(192).plusSeconds(50))) - // Exactly on a minute is already the answer and is not pushed past it. - assertEquals("3h 12m", formatSpan(Duration.ofMinutes(192))) - assertEquals("12m", formatSpan(Duration.ofMinutes(12))) - // Rounding up carries, so a day's worth of minutes reads as a day. - assertEquals("1d 0h", formatSpan(Duration.ofHours(23).plusMinutes(59).plusSeconds(30))) - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt deleted file mode 100644 index 0b054a5..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.example.aiapp - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The line arithmetic behind the file viewer. - * - * Worth a test rather than an eye: a line number that is one out is invisible in a short file and - * obvious in a long one, and a colour that stops at a line break is invisible until the file has a - * block comment in it. - */ -class FileLinesTest { - @Test - fun `a file that ends with a newline has the number of lines its author would count`() { - assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines) - assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines) - // Only one is dropped: a blank line at the end of a file is a line somebody typed. - assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines) - } - - @Test - fun `an empty file is one empty line`() { - val lines = FileLines.of("", null) - assertEquals(1, lines.size) - assertEquals("", lines.line(0).text) - } - - @Test - fun `a comment that spans lines is coloured on every line it covers`() { - val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n" - val lines = FileLines.of(text, Language.RUST) - assertEquals(4, lines.size) - val comment = catppuccinSyntax().of(Kind.COMMENT) - // The whole of the middle line, and the part of the third up to the closer. - assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 }) - val third = lines.line(2) - assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length }) - // And the code around it is not commented. - assertTrue(lines.line(0).spanStyles.none { it.item.color == comment }) - assertTrue(lines.line(3).spanStyles.none { it.item.color == comment }) - } - - @Test - fun `a span never runs past the line it was cut into`() { - val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN) - for (index in 0 until lines.size) { - val line = lines.line(index) - assertTrue( - line.spanStyles.all { it.start >= 0 && it.end <= line.length }, - "line $index", - ) - } - } - - /** - * The number every row in the viewer is sized to. It has to be the widest line, because rows of - * their natural widths scroll sideways by different amounts -- see [FileViewer]. - */ - @Test - fun `the column count is the widest line, counting a tab as eight`() { - assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns) - // A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space - // past the longest line, under-estimating puts its end out of reach. - assertEquals(9, FileLines.of("\tx\nshort\n", null).columns) - // An empty file is one empty line, which is no columns at all rather than an error. - assertEquals(0, FileLines.of("", null).columns) - } - - @Test - fun `a file with no language is plain`() { - val lines = FileLines.of("fn main() {}\n", null) - assertTrue(lines.line(0).spanStyles.isEmpty()) - } - - @Test - fun `a language comes from the extension, and only from a real one`() { - assertEquals(Language.KOTLIN, fileLanguage("Main.kt")) - assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts")) - assertEquals(Language.RUST, fileLanguage("files.rs")) - assertEquals(Language.TOML, fileLanguage("Cargo.toml")) - assertEquals(null, fileLanguage("Makefile")) - assertEquals(null, fileLanguage(".bashrc")) - assertEquals(null, fileLanguage("notes.txt")) - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt deleted file mode 100644 index ab4f529..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt +++ /dev/null @@ -1,405 +0,0 @@ -package com.example.aiapp - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * What the scanner colours, asserted as the text under each span rather than as offsets, so a - * failure prints the code that was got wrong instead of a pair of numbers. - * - * Most of these are the mistakes dev.snipme:highlights 1.1.0 made -- the library this scanner - * replaced -- measured against it directly before it was removed. They are here rather than in the - * `highlights-repro.sh` script they came from because a case that only a script can ask about is a - * case nobody asks about. - */ -class HighlighterTest { - /** Every span of [kind] in [code], as the text it covers. */ - private fun spans(code: String, language: Language, kind: Kind): List = - spansOf(code, language).filter { it.kind == kind }.map { code.substring(it.start, it.end) } - - private fun assertSpans( - code: String, - language: Language, - kind: Kind, - vararg expected: String, - ) { - assertEquals(expected.toList(), spans(code, language, kind), "$kind in: $code") - } - - // The four inputs the repro script asked the library about, and what it answered. - - @Test - fun `a quoted glob is one string, not a comment`() { - // The library answered a span whose end preceded its start here, which crashed the app. - assertSpans("x '*/a/*'", Language.SHELL, Kind.STRING, "'*/a/*'") - assertSpans("x '*/a/*'", Language.SHELL, Kind.COMMENT) - } - - @Test - fun `a find with globs has no comment in it`() { - val code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print" - assertSpans(code, Language.SHELL, Kind.STRING, "'*/.git/*'", "'*.kt'") - assertSpans(code, Language.SHELL, Kind.COMMENT) - } - - @Test - fun `a URL does not comment out the rest of a shell line`() { - val code = "curl https://example.com/x && echo done" - assertSpans(code, Language.SHELL, Kind.COMMENT) - assertSpans(code, Language.SHELL, Kind.KEYWORD, "echo") - } - - @Test - fun `a URL inside a Kotlin string stays a string`() { - val code = "val url = \"https://example.com\"\nfun f() = 1" - assertSpans(code, Language.KOTLIN, Kind.COMMENT) - assertSpans(code, Language.KOTLIN, Kind.STRING, "\"https://example.com\"") - assertSpans(code, Language.KOTLIN, Kind.KEYWORD, "val", "fun") - } - - // Attributes, which the library greyed out as comments. - - @Test - fun `a Rust attribute is metadata and the struct after it still colours`() { - val code = "#[derive(Debug)]\nstruct A { b: u8 }" - assertSpans(code, Language.RUST, Kind.METADATA, "#[derive(Debug)]") - assertSpans(code, Language.RUST, Kind.COMMENT) - assertSpans(code, Language.RUST, Kind.KEYWORD, "struct") - } - - @Test - fun `an inner Rust attribute closes at its own bracket`() { - val code = "#![allow(dead_code)]\nfn f() {}" - assertSpans(code, Language.RUST, Kind.METADATA, "#![allow(dead_code)]") - assertSpans(code, Language.RUST, Kind.KEYWORD, "fn") - } - - @Test - fun `a C preprocessor line is metadata rather than a comment`() { - val code = "#include \nint main() { return 0; }" - assertSpans(code, Language.C, Kind.METADATA, "#include ") - assertSpans(code, Language.C, Kind.COMMENT) - assertSpans(code, Language.C, Kind.KEYWORD, "int", "return") - } - - @Test - fun `a Kotlin annotation is metadata`() { - assertSpans("@Composable fun f() {}", Language.KOTLIN, Kind.METADATA, "@Composable") - } - - // Strings whose contents the library read as code. - - @Test - fun `a hash inside a Kotlin string is not a comment`() { - val code = "val c = \"#FF0000\"\nval d = 1" - assertSpans(code, Language.KOTLIN, Kind.COMMENT) - assertSpans(code, Language.KOTLIN, Kind.STRING, "\"#FF0000\"") - } - - @Test - fun `an apostrophe inside a Kotlin string does not open one`() { - val code = "val a = \"don't\"\nval b = \"x\"" - assertSpans(code, Language.KOTLIN, Kind.STRING, "\"don't\"", "\"x\"") - } - - @Test - fun `a Rust lifetime does not open a string but a character literal does`() { - val code = "fn f<'a>(x: &'a str) { let c = 'x'; }" - assertSpans(code, Language.RUST, Kind.STRING, "'x'") - } - - @Test - fun `an escaped quote is inside the Rust character literal`() { - assertSpans("let c = '\\'';", Language.RUST, Kind.STRING, "'\\''") - } - - @Test - fun `a Rust raw string keeps its inner quotes`() { - val code = "let s = r#\"a \"quoted\" b\"#;" - assertSpans(code, Language.RUST, Kind.STRING, "r#\"a \"quoted\" b\"#") - } - - @Test - fun `a Kotlin triple quoted string is one string`() { - assertSpans( - "val s = \"\"\"a \"b\" c\"\"\"", - Language.KOTLIN, - Kind.STRING, - "\"\"\"a \"b\" c\"\"\"", - ) - } - - @Test - fun `a shell single quoted string takes no escapes`() { - // `\` is literal inside shell single quotes, so the string ends at the next apostrophe. - assertSpans("echo 'a\\' b", Language.SHELL, Kind.STRING, "'a\\'") - } - - // Comments. - - @Test - fun `Rust and Kotlin nest block comments`() { - val code = "/* a /* b */ c */ x" - assertSpans(code, Language.RUST, Kind.COMMENT, "/* a /* b */ c */") - assertSpans(code, Language.KOTLIN, Kind.COMMENT, "/* a /* b */ c */") - } - - @Test - fun `C ends a block comment at the first close`() { - assertSpans("/* a /* b */ c */ x", Language.C, Kind.COMMENT, "/* a /* b */") - } - - @Test - fun `a shell comment starts only at a word boundary`() { - val code = "\${#x} \$# a#b # real" - assertSpans(code, Language.SHELL, Kind.COMMENT, "# real") - } - - @Test - fun `a hash anywhere is a Python comment`() { - assertSpans("x = 1 # note", Language.PYTHON, Kind.COMMENT, "# note") - } - - // TOML, which the library has no rules for at all. - - @Test - fun `a TOML table header is metadata and a hash in a value is not a comment`() { - val code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one" - assertSpans(code, Language.TOML, Kind.METADATA, "[server]") - assertSpans(code, Language.TOML, Kind.STRING, "\"#FF0000\"") - assertSpans(code, Language.TOML, Kind.COMMENT, "# the real one") - assertSpans(code, Language.TOML, Kind.LITERAL, "8080") - } - - @Test - fun `a RON attribute and its values colour`() { - val code = "#![enable(implicit_some)]\n(count: 3, on: true)" - assertSpans(code, Language.RON, Kind.METADATA, "#![enable(implicit_some)]") - assertSpans(code, Language.RON, Kind.KEYWORD, "true") - assertSpans(code, Language.RON, Kind.LITERAL, "3") - } - - // Markdown, which has a scanner of its own: what a character means there is decided by where - // it sits rather than by what it is, so most of these are about the cases where it means - // nothing at all. - - @Test - fun `a heading is coloured whole and a hash inside a word is not one`() { - val code = "## Layout\nissue #12 is fixed\n#hashtag" - assertSpans(code, Language.MARKDOWN, Kind.KEYWORD, "## Layout") - } - - @Test - fun `seven hashes are not a heading`() { - assertSpans("####### deep", Language.MARKDOWN, Kind.KEYWORD) - } - - @Test - fun `a fence carries its language as metadata and its body as one string`() { - val code = "text\n```kotlin\nval x = 1\n```\nmore" - assertSpans(code, Language.MARKDOWN, Kind.METADATA, "kotlin") - assertSpans(code, Language.MARKDOWN, Kind.STRING, "```", "val x = 1", "```") - } - - /** The state that crosses a line, so the one worth asking about at both ends. */ - @Test - fun `a longer fence is not closed by a shorter one, and a heading inside it is not a heading`() { - val code = "````\n```\n# not a heading\n````\nafter" - assertSpans(code, Language.MARKDOWN, Kind.KEYWORD) - assertSpans(code, Language.MARKDOWN, Kind.STRING, "````", "```", "# not a heading", "````") - } - - @Test - fun `an unclosed fence runs to the end rather than throwing`() { - assertSpans("```\nstill going", Language.MARKDOWN, Kind.STRING, "```", "still going") - } - - @Test - fun `list markers and quote markers colour without their text`() { - val code = "- one\n2. two\n> quoted" - assertSpans(code, Language.MARKDOWN, Kind.MARK, "-", "2.", ">") - } - - @Test - fun `a rule and a setext underline are the same mark`() { - assertSpans("Title\n=====\n\n---", Language.MARKDOWN, Kind.MARK, "=====", "---") - } - - @Test - fun `emphasis needs something on both sides of it`() { - assertSpans("**bold** and *thin*", Language.MARKDOWN, Kind.LITERAL, "**bold**", "*thin*") - // The case the guards exist for: a C fragment written in a paragraph. - assertSpans("a * b * c and *p = *q", Language.MARKDOWN, Kind.LITERAL) - } - - @Test - fun `an underscore inside a word emphasises nothing`() { - assertSpans("snake_case_name and _real_", Language.MARKDOWN, Kind.LITERAL, "_real_") - } - - @Test - fun `a code span holds a backtick when opened with two`() { - assertSpans("``a ` b`` and `c`", Language.MARKDOWN, Kind.STRING, "``a ` b``", "`c`") - } - - @Test - fun `an unclosed code span is ordinary text`() { - assertSpans("a ` b", Language.MARKDOWN, Kind.STRING) - } - - @Test - fun `a link marks its brackets and colours its destination`() { - val code = "see [the plan](PLAN.md) now" - assertSpans(code, Language.MARKDOWN, Kind.MARK, "[", "]") - assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)") - } - - @Test - fun `a table is found by its delimiter row, and pipes elsewhere are plain`() { - val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph" - assertSpans( - code, - Language.MARKDOWN, - Kind.MARK, - "|", - "|", - "|", - "|---|---|", - "|", - "|", - "|", - ) - } - - @Test - fun `a table without outer pipes still colours, and the table ends with the rows`() { - val code = "a | b\n--- | ---\nnot a row" - assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---") - } - - /** - * The tag in the last case is not an autolink and is left plain, but the address inside it is - * still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is - * not something this scanner knows, and a URL is a URL wherever it was written. - */ - @Test - fun `an autolink colours and an HTML tag does not`() { - val code = " and and
and " - assertSpans( - code, - Language.MARKDOWN, - Kind.METADATA, - "", - "", - "http://x", - ) - } - - @Test - fun `a bare URL gives back the sentence's punctuation`() { - assertSpans( - "see https://example.com/a., and ssh://host/x)", - Language.MARKDOWN, - Kind.METADATA, - "https://example.com/a", - "ssh://host/x", - ) - } - - @Test - fun `a bracket a URL opened itself stays in it`() { - assertSpans( - "https://en.wikipedia.org/wiki/A_(b) here", - Language.MARKDOWN, - Kind.METADATA, - "https://en.wikipedia.org/wiki/A_(b)", - ) - } - - @Test - fun `a URL inside a link destination is not coloured twice`() { - assertSpans( - "[x](https://example.com)", - Language.MARKDOWN, - Kind.METADATA, - "(https://example.com)", - ) - } - - @Test - fun `a bracket with no destination after it is left plain`() { - assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK) - } - - @Test - fun `an unknown fence language is drawn plain`() { - assertEquals(null, fenceLanguage("brainfuck")) - assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text) - } - - @Test - fun `every language the fence table knows has a scanner`() { - Language.entries.forEach { spansOf("x", it) } - } - - /** - * The scanner must never throw and must never answer a span the code does not contain: the - * library's reversed range is exactly the shape that crashed a card, and a fence still being - * written is an unterminated string or comment on every keystroke. - */ - @Test - fun `spans stay inside the code for every language and every nasty input`() { - val nasty = - listOf( - "", - "'", - "\"", - "\"unterminated", - "/* unterminated", - "###", - "#", - "#![", - "[", - "r#\"", - "\\", - "'''", - "\"\"\"", - "0x", - "1.2.3", - "a#b//c/*d*/'e\"f", - "```", - "*", - "**", - "~~", - "> ", - "- ", - "1.", - "[x](", - "#######", - "|", - "|---|", - "<", - "<>", - "http://", - "a://", - "\n\n \n", - ) - for (language in Language.entries) { - for (code in nasty) { - val spans = spansOf(code, language) - spans.forEach { - assertTrue( - it.start in 0..it.end && it.end <= code.length, - "$language answered $it for ${code.replace("\n", "\\n")}", - ) - } - assertEquals( - spans.sortedBy { it.start }, - spans, - "$language answered spans out of order for ${code.replace("\n", "\\n")}", - ) - } - } - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/LimitRowTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/LimitRowTest.kt deleted file mode 100644 index e4c030f..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/LimitRowTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.aiapp - -import java.time.ZoneId -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * What the transcript says where a session ran out of quota. - * - * The pair worth a test is the one that reads the same when it goes wrong: a reset time that - * arrived and one that never did. The second must not turn into a plausible-looking time, because a - * reader has no way of telling an invented one from a reported one. - */ -class LimitRowTest { - private val utc = ZoneId.of("UTC") - - @Test - fun `a reported reset time is shown as a time`() { - // 2026-09-05T12:00:00Z. Asserted as a prefix and the clock reading rather than as the - // whole string: the platform's own short-time format is what this asks for, and it - // differs by JDK and locale down to which space character separates the meridiem. - val summary = limitSummary(1_788_609_600.0, utc) - assertTrue(summary.startsWith("Usage limit reached • resets "), summary) - assertTrue(summary.contains("12:00"), summary) - } - - @Test - fun `a limit with no reset time says only what is known`() { - assertEquals("Usage limit reached", limitSummary(null, utc)) - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt deleted file mode 100644 index c4c1094..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/TextCapTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.example.aiapp - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** - * The cap rule, which this app and the Rust one have to answer identically -- these mirror - * `client-core/src/text_cap.rs`'s own tests case for case, because "Show all 4,000 lines" appearing - * in one app and "Show all 4,001" in the other is exactly the kind of difference a benchmark - * comparing the two would report as a rendering difference. - */ -class TextCapTest { - @Test - fun textUnderBothBoundsIsNotCut() { - assertNull(cutText("one\ntwo\nthree", 80, 4096)) - } - - @Test - fun theLineBoundCutsAtALineBoundary() { - val cut = cutText("a\nb\nc\nd\n", 2, 4096)!! - assertEquals("a\nb", cut.shown) - assertEquals(4, cut.lines, "the count is the whole text's, not the shown part's") - } - - /** - * The half the line bound cannot catch: one enormous line, which is what a minified file is. - */ - @Test - fun theByteBoundCutsOneLongLine() { - val cut = cutText("x".repeat(5000), 80, 4096)!! - assertEquals(4096, cut.shown.length) - assertEquals(1, cut.lines) - } - - @Test - fun theTighterOfTheTwoBoundsWins() { - val text = "aaaa\n".repeat(100) - assertEquals(100, cutText(text, 80, 100)!!.shown.length) - assertEquals("aaaa\naaaa\naaaa\naaaa", cutText(text, 4, 4096)!!.shown) - } - - /** - * A cut landing inside a multi-byte character has to back up to the boundary. The Rust half - * measures in UTF-8 bytes, so this one does too -- counting UTF-16 characters instead would cut - * the same text at a different place in every message with an em dash in it. - */ - @Test - fun aCutInsideAMultibyteCharacterBacksUpToTheBoundary() { - val cut = cutText("é".repeat(100), 80, 11)!! - assertEquals("é".repeat(5), cut.shown, "11 bytes lands mid-character; 10 is the cut") - } - - /** A trailing newline ends the last line rather than starting an empty one; see [lineCount]. */ - @Test - fun lineCountMatchesRustsStrLines() { - assertEquals(0, lineCount("")) - assertEquals(1, lineCount("a")) - assertEquals(1, lineCount("a\n")) - assertEquals(2, lineCount("a\nb")) - assertEquals(2, lineCount("a\nb\n")) - } -} diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt deleted file mode 100644 index e25341f..0000000 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt +++ /dev/null @@ -1,310 +0,0 @@ -package com.example.aiapp - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.junit.jupiter.api.io.TempDir - -/** - * The cache's file logic, which is the half of the transcript cache that can be wrong without - * anything on screen saying so: a page served short, a chunk served across a gap, or a run of lines - * whose recorded coverage does not match what is in it. - * - * Lines here are the shape the server writes -- `{"seq":N,"ts":T,"type":...}` -- because that is - * what the cache reads its two facts off. Nothing parses JSON on either side. - */ -class TranscriptCacheTest { - @field:TempDir lateinit var temp: File - - private val said = mutableListOf() - - private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } - - private fun session(id: String = "s") = cache().session(TranscriptAddress(id)) - - private fun line(seq: Long, type: String = "toolStart") = - """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" - - private fun delta(seq: Long) = line(seq, "assistantText") - - private fun dirOf(id: String = "s") = File(temp, "v1/host_8443/$id") - - private fun names(id: String = "s") = dirOf(id).list().orEmpty().sorted() - - private fun write(name: String, lines: List, id: String = "s") { - dirOf(id).mkdirs() - File(dirOf(id), name).writeText(lines.joinToString("\n", postfix = "\n")) - } - - private fun seqs(lines: List?) = lines?.map { - Regex("\"seq\":(\\d+)").find(it)!!.groupValues[1].toLong() - } - - @Test - fun an_appended_run_is_one_open_chunk_and_its_newest_line_is_the_tail() { - val cache = session() - (1L..3L).forEach { cache.append(line(it), it) } - cache.flush() - - assertEquals(listOf("1-open.raw.jsonl"), names()) - assertEquals(CachedTail(3, line(3)), cache.tail()) - assertEquals(listOf(line(2), line(3)), cache.newest(2)) - // More than there is is what there is, which is a short opening window and not a failure. - assertEquals(3, cache.newest(80).size) - } - - @Test - fun a_gap_in_the_stream_closes_the_open_chunk_under_the_end_it_turned_out_to_have() { - val cache = session() - (1L..3L).forEach { cache.append(line(it), it) } - // What a `reset` looks like from here: the next event is not the one after the last. - cache.append(line(90), 90) - cache.flush() - - assertEquals(listOf("1-4.raw.jsonl", "90-open.raw.jsonl"), names()) - // Nothing is served across the gap: the suffix is the newest chunk alone. - assertEquals(listOf(line(90)), cache.newest(80)) - assertEquals(CachedTail(90, line(90)), cache.tail()) - } - - @Test - fun an_event_already_covered_is_not_written_again() { - val cache = session() - (1L..3L).forEach { cache.append(line(it), it) } - cache.append(line(2), 2) - cache.flush() - - assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80))) - } - - @Test - fun an_adjacent_page_extends_the_suffix_and_a_gap_stops_it() { - val cache = session() - (100L..102L).forEach { cache.append(line(it), it) } - cache.flush() - - // Adjacent: its end is the open chunk's first. - assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true)) - assertEquals(listOf(98L, 99L), seqs(cache.page(before = 100, limit = 2, rows = false))) - assertEquals(60L, seqs(cache.newest(80))?.first()) - - // Behind a gap: kept on disk, because paging usually closes the gap, but never served - // across it. - assertTrue(cache.storePage((1L..9L).map { line(it) }, 1, 10, rows = true)) - assertNull(cache.page(before = 10, limit = 5, rows = false)) - assertEquals(60L, seqs(cache.newest(200))?.first()) - } - - @Test - fun a_page_that_overlaps_what_is_here_is_not_stored() { - val cache = session() - cache.append(line(100), 100) - cache.flush() - assertTrue(cache.storePage((60L..99L).map { line(it) }, 60, 100, rows = true)) - - assertFalse(cache.storePage((50L..79L).map { line(it) }, 50, 80, rows = true)) - assertFalse(cache.storePage(emptyList(), 40, 60, rows = true)) - assertEquals(listOf("100-open.raw.jsonl", "60-100.rows.jsonl"), names()) - } - - @Test - fun a_miss_is_null_and_never_an_empty_page() { - val cache = session() - (100L..102L).forEach { cache.append(line(it), it) } - cache.flush() - - // At or below where the run starts, so what the reader is scrolling into is the server's. - // An empty list here would be read as the start of the conversation and would stop the - // transcript scrolling back at all. - assertNull(cache.page(before = 100, limit = 40, rows = true)) - assertNull(cache.page(before = 40, limit = 40, rows = true)) - assertNull(session("never-visited").page(before = 100, limit = 40, rows = true)) - } - - @Test - fun a_page_starts_from_anywhere_inside_the_run_not_only_at_a_boundary() { - val cache = session() - (1L..10L).forEach { cache.append(line(it), it) } - cache.flush() - - // Where a warm open leaves the cursor: in the middle of the live run, because the screen - // drew the newest lines of it. A cache that could only answer at a chunk boundary would - // send this to the server -- and the page that came back would overlap the run and be - // thrown away, so the whole of the scroll back would be fetched again on every visit. - assertEquals(listOf(5L, 6L, 7L), seqs(cache.page(before = 8, limit = 3, rows = false))) - assertEquals((1L..7L).toList(), seqs(cache.page(before = 8, limit = 99, rows = false))) - } - - @Test - fun a_page_counted_in_rows_folds_each_delta_run_into_one_and_cuts_only_between_rows() { - val cache = session() - // Two replies of three deltas each, split by a tool call: the same fixture as the - // server's `coalescing_counts_rows_and_joins_delta_runs`. - val lines = - listOf(delta(1), delta(2), delta(3), line(4), delta(5), delta(6), delta(7), line(8)) - write("1-9.raw.jsonl", lines) - cache.append(line(9), 9) - cache.flush() - - // Three rows: the tool call at 8, the run 5..7, and the tool call at 4. The cut lands - // between rows, so the older run is not started. - assertEquals( - listOf(4L, 5L, 6L, 7L, 8L), - seqs(cache.page(before = 9, limit = 3, rows = true)), - ) - // One row is one whole run, however many deltas it is made of. - assertEquals(listOf(8L), seqs(cache.page(before = 9, limit = 1, rows = true))) - // A page of lines counts lines, which is what the anchor restore asks for. - assertEquals(listOf(7L, 8L), seqs(cache.page(before = 9, limit = 2, rows = false))) - } - - @Test - fun a_row_page_crosses_a_chunk_boundary_and_stops_short_at_the_oldest_chunk() { - val cache = session() - write("5-9.raw.jsonl", listOf(delta(5), delta(6), line(7), delta(8))) - cache.append(delta(9), 9) - cache.append(line(10), 10) - cache.flush() - - // A run straddling the boundary is one row, as it will be once folded. - assertEquals(listOf(8L, 9L, 10L), seqs(cache.page(before = 11, limit = 2, rows = true))) - // Asking for more rows than the suffix holds is a short page, not a failure and not a - // claim that the conversation starts here. - assertEquals((5L..10L).toList(), seqs(cache.page(before = 11, limit = 40, rows = true))) - } - - @Test - fun the_floor_for_a_fetch_is_the_nearest_chunk_at_or_below_it() { - val cache = session() - write("1-10.rows.jsonl", (1L..9L).map { line(it) }) - write("10-40.rows.jsonl", (10L..39L).map { line(it) }) - cache.append(line(90), 90) - cache.flush() - - // The run behind the gap, which is what makes the fetched page adjacent to it: a page - // fetched before 90 with a floor of 39 stops at 40 and closes the gap exactly. - assertEquals(40L, cache.coveredUpTo(90)) - assertEquals(40L, cache.coveredUpTo(41)) - assertEquals(10L, cache.coveredUpTo(10)) - // Nothing at or below the oldest chunk's start, so the page is bounded only by its limit. - assertNull(cache.coveredUpTo(9)) - } - - @Test - fun a_newest_chunk_that_is_not_raw_discards_the_session() { - val cache = session() - write("1-10.rows.jsonl", (1L..9L).map { line(it) }) - - // Only reachable by dying between closing one live run and opening the next, and there is - // no cursor to be read off a coalesced line -- so the open is a cold one. - assertNull(cache.tail()) - assertFalse(dirOf().exists()) - } - - @Test - fun a_half_written_last_line_is_dropped_and_the_file_repaired() { - val cache = session() - dirOf().mkdirs() - File(dirOf(), "1-open.raw.jsonl").writeText(line(1) + "\n" + line(2) + "\n" + """{"se""") - - assertEquals(CachedTail(2, line(2)), cache.tail()) - assertEquals(line(1) + "\n" + line(2) + "\n", File(dirOf(), "1-open.raw.jsonl").readText()) - // And the run continues from where the good tail left off. - cache.append(line(3), 3) - cache.flush() - assertEquals(listOf(1L, 2L, 3L), seqs(cache.newest(80))) - } - - @Test - fun damage_anywhere_else_discards_the_session_when_a_read_reaches_it() { - val cache = session() - write("1-open.raw.jsonl", listOf(line(1), "not ours", line(3))) - - // Not seen by the tail, which reads the newest line and stops -- reading a chunk from its - // end is exactly not reading the rest of it, and that is what keeps a warm open cheap on - // a conversation of tens of megabytes. - assertEquals(CachedTail(3, line(3)), cache.tail()) - // Reached by a read that walks past it, and there is no honest way to say what a chunk - // covers with a line of it unreadable -- so what is served is nothing, and the session - // opens cold from here on. - assertEquals(emptyList(), cache.newest(80)) - assertFalse(dirOf().exists()) - assertTrue(said.any { it.contains("damaged") }) - } - - @Test - fun a_name_this_does_not_recognise_is_ignored() { - val cache = session() - write("notes.txt", listOf("hello")) - write("1-open.raw.jsonl", listOf(line(1))) - - assertEquals(CachedTail(1, line(1)), cache.tail()) - } - - @Test - fun a_chunk_larger_than_one_read_block_is_walked_across_the_boundaries() { - val cache = session() - // Well past the 64 kB block the backwards reader takes at a time, so a page has to be - // stitched across several of them -- including a line that straddles a boundary, which - // is the case nothing else here would notice going wrong. - val padding = "x".repeat(300) - val lines = (1L..500L).map { """{"seq":$it,"ts":1.5,"type":"toolStart","id":"$padding"}""" } - write("1-open.raw.jsonl", lines) - - assertEquals(500L, cache.tail()!!.seq) - assertEquals(lines.takeLast(80), cache.newest(80)) - assertEquals(lines.subList(0, 400), cache.page(before = 401, limit = 999, rows = false)) - // And a non-ASCII line, whose bytes a naive split could cut through a character. - val accented = """{"seq":501,"ts":1.5,"type":"assistantText","delta":"héllo — ok"}""" - cache.append(accented, 501) - cache.flush() - assertEquals(accented, cache.tail()!!.line) - } - - @Test - fun eviction_takes_the_least_recently_touched_and_never_the_one_on_screen() { - val cache = cache() - listOf("old", "middle", "open").forEachIndexed { at, id -> - write("1-open.raw.jsonl", List(50) { line(it + 1L) }, id = id) - dirOf(id).setLastModified(1_000_000L + at * 1000L) - } - val each = dirOf("old").walkTopDown().filter { it.isFile }.sumOf { it.length() } - - // Room for two of the three, so the oldest goes -- and the session being read never does, - // however long ago it was last touched. - cache.evictToBudget(keep = "open", budget = each * 2) - assertEquals(listOf("middle", "open"), File(temp, "v1/host_8443").list()!!.sorted()) - - cache.evictToBudget(keep = "open", budget = 0) - assertEquals(listOf("open"), File(temp, "v1/host_8443").list()!!.sorted()) - } - - @Test - fun retaining_deletes_exactly_the_sessions_the_server_no_longer_lists() { - val cache = cache() - listOf("a", "b", "c").forEach { write("1-open.raw.jsonl", listOf(line(1)), id = it) } - - cache.retainOnly(setOf("a", "c")) - assertEquals(listOf("a", "c"), File(temp, "v1/host_8443").list()!!.sorted()) - } - - @Test - fun size_and_purge_are_the_two_halves_of_the_reload_button() { - val cache = session() - assertEquals(0L, cache.bytes()) - (1L..5L).forEach { cache.append(line(it), it) } - cache.flush() - - assertTrue(cache.bytes() > 0) - cache.purge() - assertEquals(0L, cache.bytes()) - assertNull(cache.tail()) - // And the session is usable again straight afterwards, which is what a reload does next. - cache.append(line(9), 9) - cache.flush() - assertEquals(listOf(9L), seqs(cache.newest(80))) - } -} diff --git a/app/bench-fixture/README.md b/app/bench-fixture/README.md index fe82ac8..547bec6 100644 --- a/app/bench-fixture/README.md +++ b/app/bench-fixture/README.md @@ -1,16 +1,13 @@ # The P0 benchmark fixture -`transcript.jsonl` is a synthetic transcript in the app's own event model (the JSON lines -`GET /sessions/{id}/transcript` returns; see `Events.kt`'s `parseSeqEvent` and -`server/src/session/driver.rs`) -- never a real one. It is what both the Compose `bench` build -and iris's bench build open with no server, so the two apps draw exactly the same content and a -frame-time comparison is measuring the renderer rather than the data. +`transcript.jsonl` is a synthetic transcript in the app's event model (the JSON lines +`GET /sessions/{id}/transcript` returns; see `event-model` and +`server/src/session/driver.rs`) -- never a real one. The `bench` build opens it without a +server so renderer measurements use deterministic data. Generated by `./generate.py` (Python stdlib only, seeded -- `SEED = 20260905` -- so re-running it -reproduces the same file byte for byte). It writes into `assets/` -- a separate directory from this -script and README, because the Compose `bench` build type points its own asset source set straight -at `assets/` (`app/androidApp/build.gradle.kts`'s `sourceSets { getByName("bench") }`), and a Python -script and a markdown file have no business inside an APK: +reproduces the same file byte for byte). It writes into `assets/`; only those generated assets are +embedded in the benchmark APK. - `transcript.jsonl` -- 3,603 events. The first 3,202 (`BACKLOG_COUNT`) are the scrolled-back history the benchmark opens with: user turns, tool calls with kilobyte-scale input/output, @@ -31,7 +28,7 @@ script and a markdown file have no business inside an APK: **The run-on message is kept**, as the first two events of the backlog: 14,824 characters in a single block, just under `text_cap`'s 16 KiB `MESSAGE_BYTES` so it draws in full rather than behind a "Show all". It is deliberately *not* streamed -- the repeated-reshape pathology needs a - growing block, and that lives in `app-rust/tests/frame_profile.rs` where it can be iterated on + growing block, and that lives in the UI profiling rig where it can be iterated on in a second rather than in a two-minute phone run. It is emitted with the random state saved and restored around it, so adding it left every other backlog event byte-identical; that is what keeps `phone_screen.rs`'s recorded gestures landing on the content they were recorded against. @@ -39,10 +36,9 @@ script and a markdown file have no business inside an APK: same way a real attachment is (`GET /sessions/{id}/files/{name}`), referenced by the two `"type":"image"` events in the transcript. -`BACKLOG_COUNT` lives in three places and moves in all of them or none: here, `app-rust/src/ui/ -fixture.rs` and `BenchFixture.kt`. The split is by line index, so a stale copy makes that app open -a different half of the file. +`BACKLOG_COUNT` lives here, in `generate.py`, and in `app/src/ui/fixture.rs`; change all three +together. The split is by line index. Regenerate after changing the shape (a new event type, a different backlog/stream split) with `./generate.py`, and commit the result -- it is checked in rather than generated at build time so -both apps' bench builds embed the identical bytes without needing this script at build time. +the benchmark embeds identical bytes without needing this script at build time. diff --git a/app/bench-fixture/generate.py b/app/bench-fixture/generate.py index 5f070c3..8f1dedc 100755 --- a/app/bench-fixture/generate.py +++ b/app/bench-fixture/generate.py @@ -1,13 +1,12 @@ #!/usr/bin/env python3 -"""Generates transcript.jsonl -- the synthetic fixture P0's benchmark opens in both apps. +"""Generates transcript.jsonl -- the synthetic fixture the benchmark opens. -Deterministic (fixed seed), so a Compose bench APK and an iris bench APK draw byte-identical -content: the point of the fixture is a like-for-like comparison, not a realistic one. +Deterministic (fixed seed), so runs draw byte-identical content. Never a real transcript -- see AGENTS.md's ui-sandbox.sh, which this borrows its vocabulary style from (headings, code fences, a table, a link) rather than reusing its Claude-Code JSONL shape. This file's shape is the *app's own event model* instead: one JSON object per line, -matching what GET /sessions/{id}/transcript returns and what Events.kt's parseSeqEvent reads +matching what GET /sessions/{id}/transcript returns (server/src/session/driver.rs is the source of truth for the field names). ./generate.py writes transcript.jsonl and bench1.png/bench2.png here @@ -54,7 +53,7 @@ DELTAS_PER_BLOCK = (4, 12) # stress for one-shot shaping and puts a row right on the cap boundary, where nothing else is. # # It is *not* streamed: the repeated-reshape pathology needs a growing block, and that lives in -# `app-rust/tests/frame_profile.rs`'s `what_reshaping_a_growing_message_costs`, where it can be +# the UI profiling rig's `what_reshaping_a_growing_message_costs`, where it can be # iterated on in a second rather than in a two-minute phone run. Note that the cap would not save # a real one anyway -- `row::build_row`'s `cap` is deliberately `false` for the live tail, because # a row that grew while capped would appear to stop growing, so a streamed block's shaping cost diff --git a/app/bench-lib.sh b/app/bench-lib.sh deleted file mode 100644 index 4b8acdd..0000000 --- a/app/bench-lib.sh +++ /dev/null @@ -1,57 +0,0 @@ -# Shared by transcript-bench.sh and stream-bench.sh: the parts of driving -# this app that both need and that neither should describe twice. -# -# Sourced, not run. It assumes android-env.sh has already been sourced, so -# `ui-trace` and `adb` are this checkout's. -# -# **Nothing here presses a coordinate.** Every control is found by the name -# it already carries for assistive technology, resolved from the screen at -# the moment of the gesture (`ui-trace record --do "tap 'Save'"`). A -# coordinate is a position measured once by hand, and anything that moves -# the control -- a button added to the row, a font size, another device -- -# makes the tap land on whatever now sits there; the bench then reports a -# number that was never measured, which reads exactly like a result. That -# is not hypothetical: both scripts pressed the render-report button at -# `tap 723 205` until 2026-09-03, when it moved into the session settings -# dialog. The check that none has crept back: -# -# grep -n "tap [0-9]" app/*.sh - -# The title of the session these benches open by default: whichever one the -# server lists first, which is the most recently active. -# -# Asked of the server rather than taken from the screen, because a row has -# no stable name of its own and "the first one" is a fact about the list -# rather than about a position on it. Passing -s overrides it, and that is -# the honest way to measure a particular session: the list is ordered by -# last activity, so "the first row" is a different session at different -# times of day. -first_session_title() { - ./ui-sandbox.sh api /sessions | - python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[0]["title"] if d else "")' -} - -# Restarts the app and opens the session called $1. -open_session() { - adb shell am force-stop com.example.aiapp - adb shell am start -n com.example.aiapp/.MainActivity >/dev/null - sleep 5 - ui-trace record -d 6000 --do "tap '$1'" --do 'wait 3000' \ - -o /tmp/bench-open.txt >/dev/null - sleep 2 -} - -# Copies the app's render report to the clipboard and the log, through the -# session settings dialog it lives in. -# -# Pressed twice per run: once to empty the counters, and once at the end, -# so what comes back covers exactly the work between the two. Closing the -# dialog is part of it -- it covers the transcript, which is the thing the -# gestures in between are meant to reach. -copy_render_report() { - ui-trace record -d 7000 \ - --do "tap 'Session settings'" --do 'wait 1500' \ - --do "tap 'Copy'" --do 'wait 800' \ - --do "tap 'Close'" --do 'wait 800' \ - -o "${1:-/tmp/bench-report.txt}" >/dev/null -} diff --git a/app/build-apk.sh b/app/build-apk.sh index 66e1ce0..1fb7355 100755 --- a/app/build-apk.sh +++ b/app/build-apk.sh @@ -1,159 +1,108 @@ #!/bin/sh -# Builds the app's APK, ready to install on a phone through Dev Updater. +# Builds the Rust cdylib with cargo-ndk, packages it with Gradle, and +# verifies the resulting APK. # -# ./build-apk.sh the release build, signed (what the phone runs) -# ./build-apk.sh debug the debug build, for reproducing something the -# emulator scripts would build anyway -# ./build-apk.sh bench P0's benchmark build (own app id, "AI Sessions -# bench" label, opens straight onto the fixture -# session -- see docs/RUST.md's P0 box and -# app/bench-fixture/README.md). Signed the same -# as release; never touches the CA it pins. -# -# Dev Updater's `.dev-updater.ron` at the checkout root spells these out as -# build modes, one command line each; it passes nothing else, so the word -# here is the whole interface. -# -# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/ai-app/certs/ca.pem, -# or AI_APP_CA), so build it on the machine that runs the backend: an app -# built somewhere else trusts a CA that backend can't present, and simply -# won't connect. Start ai-server once first if there are no certificates -# yet -- it generates them; the build stops with that instruction if it -# can't find one. -# -# Unlike ./run-android.sh, this touches no emulator: it only produces the -# file. Installing on a real phone goes through Dev Updater, which serves -# whatever is under this project's build directory. +# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"] +# debug/release default to debug (matches this-machine-android's "the +# emulator stays on debug" rule -- pass `release` explicitly for a phone +# build). --abi defaults to arm64-v8a (a phone/real device); pass +# x86_64 for this checkout's own AVD. --features defaults to +# "transcript-screen". Pass "transcript-screen bench" for the retained +# benchmark app. Never force GLES for a phone or emulator build; Iris's +# runtime selects the available hardware backend. set -eu +cd "$(dirname "$0")" -VARIANT=${1:-release} -case "$VARIANT" in -release) TASK=assembleRelease ;; -debug) TASK=assembleDebug ;; -bench) TASK=assembleBench ;; -*) - echo "build-apk.sh: unknown variant '$VARIANT' (release, debug, bench)" >&2 - exit 2 - ;; +BUILD_TYPE="debug" +ABI="arm64-v8a" +FEATURES="transcript-screen" +case "${1:-}" in + debug|release) BUILD_TYPE="$1"; shift ;; +esac +while [ $# -gt 0 ]; do + case "$1" in + --abi) ABI="$2"; shift 2 ;; + --features) FEATURES="$2"; shift 2 ;; + *) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;; + esac +done + +SDK_ROOT="$HOME/Android/Sdk" +export ANDROID_HOME="$SDK_ROOT" +export ANDROID_SDK_ROOT="$SDK_ROOT" +NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1) +if [ -z "$NDK_DIR" ]; then + echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2 + exit 1 +fi +export ANDROID_NDK_HOME="$NDK_DIR" + +# Only the ABI asked for goes into the APK. cargo ndk adds its output beside +# whatever earlier builds left here, and Gradle packages every directory it +# finds -- a debug x86_64 emulator build left behind made an arm64 "release" +# 339 MB on 2026-09-06. +rm -rf android-project/app/src/main/jniLibs +# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach. +# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a +# build that switches ABI packages the previous ABI: an `--abi x86_64` +# release APK containing `lib/arm64-v8a/libmain.so` installed fine and +# aborted at startup with `Could not get adapter!: NotFound { +# active_backends: VULKAN }` under libndk_translation -- which reads +# exactly like the phone's own Vulkan problem and is nothing of the kind. +# Scoped to the merge task's directory rather than all of `app/build`, so +# an ABI change costs the native merge and not the whole Gradle build. +rm -rf android-project/app/build/intermediates/merged_native_libs \ + android-project/app/build/intermediates/stripped_native_libs \ + android-project/app/build/intermediates/merged_jni_libs +echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\"" +if [ "$BUILD_TYPE" = "release" ]; then + cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \ + --profile android-release --no-default-features --features "$FEATURES" +else + cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \ + --profile android-dev --no-default-features --features "$FEATURES" +fi + +GRADLE_TASK="assembleDebug" +APK_DIR="android-project/app/build/outputs/apk/debug" +APK_NAME="app-debug.apk" +if [ "$BUILD_TYPE" = "release" ]; then + GRADLE_TASK="assembleRelease" + APK_DIR="android-project/app/build/outputs/apk/release" + APK_NAME="app-release.apk" + export AI_APP_KEYSTORE="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks" + if [ ! -f "$AI_APP_KEYSTORE" ]; then + KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}" + KEYTOOL="${KEYTOOL:-keytool}" + if ! command -v "$KEYTOOL" >/dev/null 2>&1; then + echo "build-apk.sh: no release key and no keytool to create one" >&2 + exit 1 + fi + mkdir -p "$(dirname "$AI_APP_KEYSTORE")" + AI_APP_KEYSTORE_PASSWORD=$(head -c 24 /dev/urandom | base64 | tr -d '/+=') + (umask 077 && printf '%s\n' "$AI_APP_KEYSTORE_PASSWORD" > "$AI_APP_KEYSTORE.password") + (umask 077 && "$KEYTOOL" -genkeypair -keystore "$AI_APP_KEYSTORE" \ + -alias ai-app -keyalg RSA -keysize 2048 -validity 10000 \ + -storepass "$AI_APP_KEYSTORE_PASSWORD" -keypass "$AI_APP_KEYSTORE_PASSWORD" \ + -dname "CN=ai-app" >/dev/null 2>&1) + fi + export AI_APP_KEYSTORE_PASSWORD + AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password") +fi + +unset AI_APP_BENCH +case " $FEATURES " in + *" bench "*) export AI_APP_BENCH=1 ;; esac -SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -cd "$SCRIPT_DIR" +(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain) -# Prefer an SDK this machine has already configured -- the host and the dev -# VM don't keep it in the same place, and android-env.sh is written for the -# VM's layout (it also installs missing packages, which isn't wanted here). -if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then - echo "==> Using ANDROID_HOME=$ANDROID_HOME" -elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then - ANDROID_HOME="$ANDROID_SDK_ROOT" - export ANDROID_HOME - echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" -elif [ -d "$HOME/Android/Sdk" ]; then - ANDROID_HOME="$HOME/Android/Sdk" - ANDROID_SDK_ROOT="$ANDROID_HOME" - export ANDROID_HOME ANDROID_SDK_ROOT - echo "==> Using $ANDROID_HOME" -else - echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2 - echo "(Android Studio's default location is ~/Android/Sdk)." >&2 - exit 1 +APK_PATH="$(pwd)/$APK_DIR/$APK_NAME" +BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1) +echo "--- aapt2 dump badging ---" +"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5 +if [ "$BUILD_TYPE" = "release" ]; then + echo "--- apksigner verify ---" + "${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH" fi - -CA="${AI_APP_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs/ca.pem}" -if [ -f "$CA" ]; then - # Printed so a wrong or stale certificate is visible here rather than - # as a handshake failure on the phone -- compare it against the CA the - # backend is actually presenting. - FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \ - | openssl pkey -pubin -outform der 2>/dev/null \ - | openssl dgst -sha256 -binary 2>/dev/null \ - | openssl base64 2>/dev/null || echo "(openssl unavailable)") - echo "==> Pinning the CA at $CA" - echo " fingerprint: $FINGERPRINT" -else - echo "No CA certificate at $CA -- start ai-server once on this machine" >&2 - echo "(it generates them), or set AI_APP_CA. The APK embeds it at build time." >&2 - exit 1 -fi - -# The phone runs the release build. A debuggable build runs Compose at a -# fraction of the speed -- ART keeps the process debugger-friendly and the -# compiler leaves its inspection hooks in -- so a frame time measured on one -# says little about the app; that cost a day of tuning against the wrong -# number. A release build must be signed, and the key is what the phone -# recognises the app by, so it lives beside the CA, outside any checkout, -# and is generated once here. Switching from an installed debug build means -# uninstalling it first: the signatures differ, and Android refuses to -# update across them. -KEYSTORE="${AI_APP_KEYSTORE:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/release.jks}" -if { [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; } && [ ! -f "$KEYSTORE" ]; then - KEYTOOL="${JAVA_HOME:+$JAVA_HOME/bin/keytool}" - KEYTOOL="${KEYTOOL:-keytool}" - if ! command -v "$KEYTOOL" >/dev/null 2>&1; then - echo "No signing key at $KEYSTORE and no keytool to make one -- set" >&2 - echo "JAVA_HOME to the JDK Gradle uses, or AI_APP_KEYSTORE to an existing key." >&2 - exit 1 - fi - echo "==> No signing key at $KEYSTORE -- generating one" - mkdir -p "$(dirname "$KEYSTORE")" - PASSWORD=$(head -c 24 /dev/urandom | base64 | tr -d '/+=') - (umask 077 && printf '%s\n' "$PASSWORD" > "$KEYSTORE.password") - (umask 077 && "$KEYTOOL" -genkeypair -keystore "$KEYSTORE" -alias ai-app \ - -keyalg RSA -keysize 2048 -validity 10000 \ - -storepass "$PASSWORD" -keypass "$PASSWORD" -dname "CN=ai-app" >/dev/null 2>&1) -fi -if [ "$VARIANT" = release ] || [ "$VARIANT" = bench ]; then - AI_APP_KEYSTORE="$KEYSTORE" - AI_APP_KEYSTORE_PASSWORD=$(cat "$KEYSTORE.password") - export AI_APP_KEYSTORE AI_APP_KEYSTORE_PASSWORD - echo "==> Signing with $KEYSTORE" -fi - -# Dev Updater draws a real progress bar from "@@progress done/total" lines, -# and ignores anything that isn't exactly that shape. Gradle can't be asked -# for this directly: an init script using taskGraph.afterTask is rejected -# outright by the configuration cache, and whenReady never fires on a cache -# hit. --dry-run costs about a second, is cache-friendly, and prints one -# ":task SKIPPED" line per task the real build will run, which is exactly -# the total. The build then prints one "> Task :x" line per task as it -# goes, so counting those against it is the whole mechanism. -# -# Task count is not time -- the Kotlin compile and dexBuilder are most of -# the wall clock -- so the bar moves unevenly. It is still counted work -# rather than a guess at how long last time took. -TASKS=$(./gradlew :androidApp:$TASK --dry-run --console=plain 2>/dev/null \ - | grep -c '^:[A-Za-z:]* SKIPPED' || true) - -echo "==> Building" -if [ "${TASKS:-0}" -gt 0 ]; then - echo "@@progress 0/$TASKS" - DONE=0 - ./gradlew :androidApp:$TASK --console=plain 2>&1 | while IFS= read -r line; do - echo "$line" - case "$line" in - "> Task "*) - DONE=$((DONE + 1)) - echo "@@progress $DONE/$TASKS" - ;; - esac - done - # The pipeline's exit status is the shell's, not gradle's, so ask - # gradle again rather than reporting a failed build as a success. It is - # up to date by now, so this is a second or two. - ./gradlew :androidApp:$TASK --console=plain >/dev/null -else - ./gradlew :androidApp:$TASK -fi - -APK="$SCRIPT_DIR/androidApp/build/outputs/apk/$VARIANT/androidApp-$VARIANT.apk" -echo -echo "==> Built $APK" -[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}' -echo -echo "To get it onto the phone: add this project to Dev Updater (or hit" -echo "Update on it if it's already there) and install from there." -echo "Debug and release are signed with different keys, so switching from" -echo "one to the other means uninstalling the installed one first." -echo "Then start the backend and scan the enrollment QR it prints:" -echo " ./server/target/release/ai-server --rotate-token" +echo "$APK_PATH" diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh deleted file mode 100755 index 7e9cd55..0000000 --- a/app/build-icon-font.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Rebuilds androidApp/src/main/res/font/nerd_icons.ttf. -# -# The app draws a handful of icons -- a cog, a refresh arrow, send, stop -- -# as text in a Nerd Fonts glyph rather than as vector assets or as ordinary -# Unicode. Unicode has no character for most of these, and the ones it does -# have are not reliably in an Android system font, so they land as tofu -# boxes on somebody's phone. Shipping the subset removes the hope: the -# glyph is in the APK. -# -# The whole symbols font is 3 MB for the handful below, so what is -# committed is a subset. Add a codepoint to GLYPHS below and to NerdIcons.kt -# (the two lists have to agree -- a codepoint in the Kotlin but not here is -# a glyph that silently doesn't exist), then run this and commit the result. -# -# Needs python3 and network access; fontTools is fetched into a temporary -# venv, so nothing has to be installed on the machine. -# -# Copied from dev-updater's script of the same name rather than shared -# through wg-app-link, for the reason Theme.kt gives about the palette: the -# link is the tunnel, the pinned CA and enrollment, and an icon set is a -# preference rather than part of that contract. -set -euo pipefail - -# Codepoint, then the Nerd Fonts glyph name it came from. Material Design -# Icons bar one, so they read as one family -- and the first two are -# deliberately the same two dev-updater uses, since a cog and a refresh -# arrow mean the same thing in both apps. The exception is noted on its -# own line, as dev-updater's script does with its two. -GLYPHS=( - U+F0493 # md-cog - U+F0450 # md-refresh - U+F048A # md-send - U+F04DB # md-stop - U+F03E4 # md-pause - U+F040A # md-play - U+F1163 # md-send_clock - U+F0156 # md-close - U+F004D # md-arrow_left - U+F009A # md-bell - U+F04C5 # md-speedometer - U+F024B # md-folder -- dev-updater's too; a folder means the same in both - U+F0415 # md-plus -- likewise - U+F03EB # md-pencil - U+F0193 # md-content_save - U+F0224 # md-file_outline - U+F201 # fa-line_chart -- Font Awesome's, asked for by name -) - -url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip -out="$(cd "$(dirname "$0")" && pwd)/androidApp/src/main/res/font/nerd_icons.ttf" -work="$(mktemp -d)" -trap 'rm -rf "$work"' EXIT - -echo "Fetching $url" -curl -fsSL -o "$work/nf.zip" "$url" -python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work" - -python3 -m venv "$work/venv" -"$work/venv/bin/pip" -q install fonttools - -unicodes="$(IFS=,; echo "${GLYPHS[*]}")" -mkdir -p "$(dirname "$out")" -# The Mono face rather than the proportional one, which this used until -# 2026-08-30. Every glyph in it is one em wide and one em tall, so two -# icons drawn at the same size are the same size -- which is what makes two -# icon buttons beside each other match without either of them being told a -# width. In the proportional face the advances run from 0.46 em (play) to -# 0.92 em (line chart), so the composer's Send button came out visibly wider -# than the Stop button next to it, and any fix at the call site would have -# been one measurement hardcoded per pair. -# -# The trade is the one the old comment named: an icon inline beside text is -# padded out to a cell. That is worth it, and it is also why GLYPH_SIZE in -# NerdIcons.kt came down when this changed -- a glyph that fills its em -# draws bigger at the same point size than one that does not. -"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \ - --unicodes="$unicodes" \ - --layout-features= \ - --drop-tables+=DSIG \ - --output-file="$out" - -echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs" diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index a1a0efc..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,6 +0,0 @@ -plugins { - alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.androidLibrary) apply false - alias(libs.plugins.composeMultiplatform) apply false - alias(libs.plugins.composeCompiler) apply false -} diff --git a/app/debug-transcript.sh b/app/debug-transcript.sh deleted file mode 100755 index 40f86c9..0000000 --- a/app/debug-transcript.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/sh -# Puts a real Claude Code conversation on the emulator, for looking at the -# transcript screen under content it was not written against. -# -# The echo driver's fixtures (`/mixed`, `/stream`) are the right rig for most -# things and the wrong one for anything whose cost scales with what was -# actually written: a real reply is longer, is real markdown, and carries tool -# calls whose input and output are kilobytes rather than a word. Two faults -# were invisible until a real transcript was loaded -- a page of history -# landing mid-fling threw the reader back to the newest end, and parsing one -# real reply took 51ms against 4.6ms for a synthetic one. -# -# ./debug-transcript.sh # newest transcript in ~/.claude/projects -# ./debug-transcript.sh dev-updater # newest one whose project path matches -# ./debug-transcript.sh -b dev-updater # the biggest one instead of the newest -# ./debug-transcript.sh -d 350 # hold every response back 350ms -# -# **The transcript never enters the repository.** These files are private -- -# they hold whatever was said, read and written in that session -- so this -# copies one into /tmp and points an isolated server at it. Nothing it makes -# is committed, and ~/repos is shared with the host besides. -# -# What it builds, all of it disposable: -# /tmp/ai-app-debug/home a HOME holding only the copied transcript, so -# the import cannot see or resume a live session -# /tmp/ai-app-debug/sessions that server's own data directory -# a server on PORT, with its own config and the real CA (so the installed -# APK, which pins the CA of the machine that built it, still trusts it) -set -eu - -PORT="${PORT:-8455}" -DELAY=0 -MATCH="" -BIGGEST="" -STOP="" -while [ $# -gt 0 ]; do - case "$1" in - -d|--delay) DELAY="$2"; shift 2 ;; - -b|--biggest) BIGGEST=yes; shift ;; - --stop) STOP=yes; shift ;; - -h|--help) sed -n '2,29p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; - *) MATCH="$1"; shift ;; - esac -done - -SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -cd "$SCRIPT_DIR" -REPO=$(dirname "$SCRIPT_DIR") -WORK=/tmp/ai-app-debug -PROJECTS="$HOME/.claude/projects" - -# Whatever the last run left, before this one takes the port again. -# -# Importing spawns `claude --resume` so the conversation can be continued, and -# those outlive the server that started them: twelve accumulated over one -# afternoon of re-running this. They are found by the scratch HOME and nothing -# else, because every other `claude` on this machine is somebody's live session -# -- including the one that may be running this script. -stop_previous() { - for pid in $(pgrep -x claude 2>/dev/null); do - home=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^HOME=//p') - if [ "$home" = "$WORK/home" ]; then kill "$pid" 2>/dev/null || true; fi - done - pkill -f "[a]i-server --bind 127.0.0.1 --port $PORT" 2>/dev/null || true - # Gone, not merely signalled: the next start binds the same port. - while pgrep -f "[a]i-server --bind 127.0.0.1 --port $PORT" >/dev/null 2>&1; do sleep 1; done -} - -stop_previous -if [ -n "$STOP" ]; then - echo "Stopped the debug server on port $PORT and anything it spawned." - exit 0 -fi - -# Newest first, so with no argument you get the conversation you were just in. -# `--biggest` is the other question worth asking of this directory, and the one -# a scrolling test wants: the longest conversation on the machine is the one -# with enough rows to page backwards through, and the newest is routinely a -# session five minutes old with nothing in it. -if [ -n "$BIGGEST" ]; then - SRC=$(ls -S "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1) -else - SRC=$(ls -t "$PROJECTS"/*"$MATCH"*/*.jsonl 2>/dev/null | head -1) -fi -if [ -z "$SRC" ]; then - echo "No Claude Code transcript under $PROJECTS matching '${MATCH:-anything}'." >&2 - echo "Sessions are written there as /.jsonl." >&2 - exit 1 -fi -ID=$(basename "$SRC" .jsonl) -PROJECT=$(basename "$(dirname "$SRC")") -echo "==> Using $PROJECT/$ID ($(wc -l < "$SRC") lines, $(du -h "$SRC" | cut -f1))" - -# A HOME of its own is the isolation: `import::list` enumerates -# "$HOME"/.claude/projects/*/*.jsonl through the transport, so a server started -# with this one can only ever see the copy. That matters for more than tidiness -# -- importing spawns `claude --resume `, and against the real file that -# would be a second CLI writing to a conversation somebody may still be in. -rm -rf "$WORK" -mkdir -p "$WORK/home/.claude/projects/$PROJECT" -cp "$SRC" "$WORK/home/.claude/projects/$PROJECT/$ID.jsonl" - -CERTS="${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs" -if [ ! -f "$CERTS/ca.pem" ]; then - echo "No CA at $CERTS/ca.pem -- start ai-server once normally first." >&2 - exit 1 -fi - -SERVER="$REPO/server/target/debug/ai-server" -[ -x "$SERVER" ] || (cd "$REPO/server" && cargo build) - -echo "==> Starting server on port $PORT (delay ${DELAY}ms)" -HOME="$WORK/home" setsid nohup "$SERVER" \ - --bind 127.0.0.1 --port "$PORT" \ - --config "$WORK/config.ron" --data-dir "$WORK/sessions" --certs "$CERTS" \ - --delay "$DELAY" >"$WORK/server.log" 2>&1 /dev/null; do sleep 1; done -TOKEN=$(grep -o 'token=[A-Za-z0-9_-]*' "$WORK/server.log" | head -1 | cut -d= -f2) - -api() { curl -s --cacert "$CERTS/ca.pem" -H "Authorization: Bearer $TOKEN" "$@"; } - -echo "==> Importing" -SETUP=$(api "https://127.0.0.1:$PORT/setups" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) -SESSION=$(api -H 'Content-Type: application/json' -X POST \ - "https://127.0.0.1:$PORT/sessions" \ - -d "{\"setup\":\"$SETUP\",\"provider\":\"claude-cli\",\"title\":\"$PROJECT\",\"import\":\"$ID\"}" \ - | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1) -echo " session $SESSION, $(wc -l < "$WORK/sessions/$SESSION/transcript.jsonl") events" - -# 10.0.2.2 is the emulator's route to this VM's loopback. The `&` are quoted -# on the *device* side: adb runs its argument through a shell there, which -# would otherwise cut the URI at the first one and enrol with no token. -if command -v adb >/dev/null 2>&1 && [ -n "$(adb devices | awk '$2=="device"{print $1}')" ]; then - echo "==> Enrolling the app" - adb shell "am start -a android.intent.action.VIEW \ - -d 'aiapp://enroll?host=10.0.2.2&port=$PORT&token=$TOKEN'" >/dev/null -fi - -cat <bb3QVg*ju^jKS3o#9H#w4Yxz1*n>pYH+2nC3dC@ic(OUh@sI7>n^@O3t+EN zk19TR2&69-M23X8{h9JYx|8)kwwf7ttr>teD4+VN#nkbK$s(IG`^odY38j0~G5LYI zE8yj!-3t3WJpbc*GP8f1Ijv!GZFxNt(Cq4DxZU@%dVh&ur@bEGnl2N^*QNXDgt%%uIzL8-|f9*0a_P$gWq1W= zyYFqsd}OSk24kb~1dN}B%z?^{HGmwKoogz&O?>^nlNT;9zKwXe(^*#}CA6|3JU~$) z84gW6*^!J(I2cJ6Fex`F@*8Z;s#mTo^y2AJ6hSf>Ei3lYhvt>4{wrqH*`8wlt+NqV zs$Y#ZDUzSWF!beISEBi0Dvx#amw4A=5p>tM)l(wMg*GU=hp|-Z=aZM_pw^OegdgFE z#1Jtd_&p~_;Lb@JjM5MZdJErBWf7~hq^IxX89(}?*<2v)uDSTyr#g{7fM4R;@KjPU zef+&aPhcAskT5|z_09<(`3G^SKwJ0e=Q(TjU}<2E7jh(ZoiwT{!}jl%GU(rNo2?a! zx2+TFX}Pt%EZ7ohNMI$bpk|IVcQ3Z2tWLJSZtq)*Im<#WBDYEfci;r(!~8KiUAI2I z+)9QJ;|lF-J*nrrVRIf{Rt}tBY`7YBW#R+!Qox8y99}8lf<@)9znd`>8Q;dY znEDDc?e6`E=jWxW%O= z*dn!&=MGIsRTcKy#$f?nc7NBdssxcHDt6p!g8h@bt@_P63RGK`SeA81RG5nyyn|pn zh5?evjH@qk|v=A$Ff0_lB8|p@3G{6%UYG`sujf7mV;X1<41iQ?RY8pV7 z+JTVijVDHlCoGIu&$AT+dB^5QPcKo%0%E$4uIC6MXfn^S5s%Or=V!~H;WD2>O)~K>|X>YMP=}Y_0pejZk-6r@uWi|+^N62^lykrsvI-LZ#)+m^*{7pxuE$-YJPa6ES98M;^-kOi6XZp$Mun zVh_^?Hp<}gH$rrm9(0RoI9SWRQ6R)wVQt0P3)HH@+_$;Wu?Pdh#`*-jv&l=#aB#ZB z__a1vF1``N!=i=c>_*5tSi+du{D=L>p#AE6M9%CROw=u892xWbhBJ2&ZWOPUu9e_t z`J0llKMY7mQOc(WraFZmW=wk^KbcDk)u1}fF!vO9a$)!UcLP)4H1`%4xgRqS0K?Ri z5wDR#A&14*d%ZEfJ%yaM!xA9$SjkFRTM(E=VBF=fl`Xebo{4H-4hj0}f`xQV%Siw~ zm)X(4E#M~0rjvozMFh8$OtrMtNIwdWI#K9mA^S9Y`%(O7+DH&z2BPw}+FP|N{8`yc ztMq*2M?9lMzlQKSXTlP7_S}q6O5>aSLKTkPfx$(5-5iMGcgSoF6$&wzunij_p=r=9 zULJ3>$)nnNCaOIhR<^3ydE|tmD2_eJi2rKJ>=4lfdXl%T^<`2cL8Qnr#g}u6)mqEfkdy^j(pd_;1LfQq)~T z)#*RRvAV3a;5g%FsE=#2$4c)4WyUl~Bx{f3L=Y&s6_!#gFQs!SM z%Ptu1IMS7C?+LldgwXHRxHrmZ7c|W9txqXT!D^j9u-AN|Y|OWq2SC{L<-cTTicAmi z_r#W74+DHIHg*akRkcJKQULezAc{~%>2%5wLQ>VNv3usWH7RnZ2Gz-YT0A%><>0c`H5JO8&DXi*zR64@Cim$sxd2bU<1bGfQN zYN$wwe1Suk{w@!&Grd0uH@kI*wheyqH}Pu37`unlXJ3eVY_&RLtw?MtwCC}kX& z2r=ymZ+8nA9_W&_-!Uk78%AX;0kBIr^@FWC=Iq?}st>4E&+p_%duBh3kVNp=krEPD z)GOWz8oLGhf-icgv}Z?)m7f&8FU^%9YU6rK!9w3vM<_rm+D;$*BFzlm^yg?%23uAQ z%KeUiUgps!x2o$8_73aGGei+l?ijb$qk0&_pcxE$L&m{m1E)z5{%6fgW`S-VGaRav z!S?Iw_l z8bcI?Ymm-FZKn!Np;!~O96H)0IY%e2ReRm7kG<82Fn{mNbwWMVA4 z>uZy@Ye%>7g;Xba{0<$EH@{`|xhnAW31=;CMC_|9j?M+?>Ej*_aqKS9>ogRu%(R<^ z8J;b1?=_I671Vk@wUgy9Y-KNgni)d}*j0y<^urrM2Uk2lFt7uFt`+!g{6?nxn8HDA z-|mcYugdaGsE%N=JvnV*xpYv3#ROT8=BsCVx@0{J239XjS;u0Ma+!u+Fwr5ij=6m0 zLSvIxxB1C7^gHZ#DcL&5(^lO35rh)6% zTsaD~2LM9BOvklgrH#EyzGJ%@S_@lewSL>+u5R+Tiq+s>wC&&!bZ{TdFdO)hkb5-6 z$JW2#Z|Z!%lkE+Ji(AJ*TFz!!5aIfBcEyHaG53g88ae_isos&=hRdKu{(IgmZ3Gds z7k(3>R}TbXV~wbz&J~3lCtMn+1npudNl-F=qB2KmbKczrin|qq(zUiV=mz!5jQt(W z4osJngz2I~I*eB?O3AP2V$NNllivTjjiDCk>V%*qVl&IrYG0a8ch#hengcSQ0H~+K zBrZ5)DU<3ZAI!Gpd$pCpi>TAd%xh;}9a74VXzmbM7C9K#VsIv!z}_@E{+d_U`?Nq% zi@u}DiWhyB4y$-r=+xk@;E9jM)7*`fPg)%mEu3MTd`DT51r_)uS|F(! zH_LOl6m)}??`_oHJ&>D)Os*^s<836X+kL$G%25M*fJ%&Z1B&T zx8I#PIpI+RmNaLK`Fp&CnIwK8BSBAd1%72kS{KygNw=~bG)!%CA<;1+2uK$d2#E5( z^@|w)w_j8cQIwICP*Z1Ako+&t$S^qx7s8AJvE@f{8IQdTeE6YPrV5cF8dS5|VoNd< zANp`#(YR!CkO|d!PGixcChz$md$u3@%N8#7%MI==THk`Dlx!B6XY2sEDGUZrd)9ZV z`Neo82#w}>2PYcZeDI8fty)z?U1X_n8XA^i5hNk;Ku&STgIxXgtP~=n*gS)-O^6j7 z-R8FH?Zu`x8u%&h7qGwPXFENvoAPODTR+FYpC8NT{G42^n5yv;0}-EEv48O`iX+}!?a@(PLya{a<6*$#GkW&+gS*DX|y z3T|bC+7j^vr8&A+T^EY8jqRDWGEpR0uQE9h$nPLQ$=sk4R$IL5iqjzJwhACddroj?Br~lT+S2>vo<5ZJwlL{#iW=$)A*+<(iFSGBZie!dF^3DNh z?&VkWO=0E?+KTHCe{4{tuuxRaV9(2n@)ICSnZ2(;4v}b^r=)pTAhI4=3C5^0CHG3> z5h}3Rg{iTfU#*m;NN8>F%TAm=@&ZkrpGX$TSo?}+I$VpJo~E7Htc`3-$LXM;rG9lE z72K^9V-I@?9QApE5W?Uzl-x0%^3DO@2O@e?1gXf|5|#& zaPJKC&qP7%bNu_I|MIs>uk=5yw}q;n61oV+J0O+OAx&;v;wres&^q5jLs*uj3x$aS zGMW-4nrUu5pKw|3SGz<^!a(je)0GZ68as>N3*RexSA-RI0-B-a6wht;CExAj>+9`3 z-&b6E=8n}>KTY4lg_b&U0yR3jp;S#E!jhxxcj#Giw&7vWgRckGs5^*u)}A0>LkQ!I{?^dBG~R6ZVjsS)_$H=G&-0-z@Z^T z;gk9U-en=IA!lcEox4>qLD#kR4LdF1skHspDSpcDCtJ+ybScF*(D?T!p(2YlA*vwq zAJ4-kR{A+sw33EEiS2Z`n_qo}aC3;lJcfq<;{niS?5-}rPRGD7m$_b3hl5hZ5!aN! zPLy%q0TY}4dDKRy07;H;-8fl_tf4Q;8~MGZk_=Na8%JZtZH-%UH;0oEvTv6|jv9#5 zX8r@RdYCzRycx0WuBIz*2gC5y z)@!vL!f>yhNfq-Oz{~es!{ua*4Ca!K4t`s7c z?iQ~9gtptia7l`q!C%-Gm`i0ekj*E1s%i;tDz+ew*Y5eDj+TqZT=3(@w4{BmzLsxw z!coPP;`-z1E39lmq)-pBMaMZ-6|l&KD!tY3aLsLct@ZYHshJprsG#TS`shgFPp8V^ zVpn`qovk)vp|y6`lB+%uZx_8!7sE(RD4jQnwOblArJa`ci^wW`^a7L@xC*=OWa6+M zWodt%>31m$zsTBhf2>XGc19kgP`HQ`g8jk$!D5TuerlYMuKnf|${e0*W9mQUHk_Ev z1}3`IX4Nk_!^H+3Mcz{yB=h>ksBn$HPmbW(DR831#0o6v_VR)8<~YCJMRB4}c!C+% zE(&$bqy;^T&;?C?Oe2OLHskKJzIx*E&hoNHm$F2u!;$|m{&DwYVi1pqDLHKXV@l)k z36#r#G4nvPjNrHa_$71n%MJ0`6v*1wky|(>O$xHJ3V1>n3+s8hR;IV+8_`;T4HS#? zDo_Bx04bO z85- z>;Zba zyPAjT{}#u8!EU35gBrRPMj#^z{$d`Qa77h|qZ+tOsx>Oi09Oxo`F3$}U#JV9^|yXv z%A}*E7r7^|M~P73<_q|I9kZF`ywlWE;ry@m88DGWrtFD}gPfNvw_Lvqx2b@~_kB8$ zv}^c&Bc+_zj2AH)7YDT;78bHIoXODzI*o0P&RWg#jg~2pza30qE?_b$U8NSvMOWSN zIHb~7wgBX;vYiEs-UbVuI7t>P33PF2i&FtNo7Ol`xJ0n4`DNxKG0`#6u{1%FJve(7 z6()8&O^z@Cm+@+II!-2hvI<;Z&&BeE79GZ;678KP^0R^Hc#^~cNY23 zXU4@&2L&gWb_*TPRyqoIHurXobs2qgWjGU)o6xR;%r?K6?I~q$f1y6EG_UQOemWI# z;9LlKge2*183ODujxR$J&WdACrinw@RlLxSPDp0TS-stiKl>_9aEFlWBz z4o))x#Y{SoFc;HF37qwrWdsFTPqL2&u$$VQ%699A5}Gdrv*zqU&NrNW!e4V($Q{Eb z@C3EVde^8R$2|_zL1pX@TNlTcLk>Go%~+9F$r95adgKnGo+d#?3nbB8W3H@vIViDl zNdLBOVr-}K8UauAi=yA$+Vt`1QsscTU*%lr74*hlOAW)u+*ewJHiY{qRFpgv-n!X6 z_;zwk`r8TBo5iM0`)nEPT<5(udKJd|fU~VoZ#uv+S%6UAl6zHFp(6!iA&>i7nBdwR zVizF<+MXpZIf(@zQ?6-PqZUpc89rh>{hU0^U+qm=&FW4eMb^^A)OYC1N8i_gZ2}ej=NJx2ZmfOOT*an@)`UG zxzAZ?M}$(t(9tj7<>m>lztI~ccK3!lUGWUVI7hb(jhyn=Db4=)<98-}DD~I5R(Hl! zu=tcAAoSmzYk~jdT+2B+c{%=5ivB51YVIcP7XNavQ#5tFFc$FEsg9Lp)X1_y&>(5_ zm}QV7ql95XLL;J%DXim{al&MmWJ;wyH1ssGQJ^snbuK-`JJ)2kkxp(l7LMsH7%l^D zdcEGjpSO^m8N$PcF4Z-{ISCPcj;hrT{jGA}&YigL|4irl!)-zNk2v29MDpKk&YYc)@pPt9^quC8sB_uIJ0dnBf_RA0`WT0W5c1_q%Q*_If_syb&1MJXv$6n=m^YAi88`5kqcgxE zHT!&IrQGr=Hag$yPP;YB)|O^{4_bY7`(em%E`uHVCOB7!?WmkF4aKx2aAp$zOmB!p zZ{sfS^NgnElY1m^zXJ#n#{EG6N0;{ZkMZ|66Jeu}P2N)0nD{nw+2kXv4NQtveLTJP zq8xB*CfeD&C5mN)kXl^4Z4P?bvd6J>2%aY;7Z;Y^%^s1COcy6RISiB8O=1X*RSx0i z?4}wxXqs&73|pz8<9*uS7g#mPX20_4GZqpdnl>m(;*1X-$>OpyqLNDt!RgaVs*FjF zpEdmoBj7RsbXIlQ0&Fe$z?xT6@xUxtvMKb%c*>wkiL%Dj^2jlvA92ce&*EpInxGm; zhH7{Ec1Y!xC@645q356GIhKr&|SNuCtFCPPwT=qG!zQi zf8Kc;@8L|h@U0+?F9Q@wk3E%0Zb+P*6XKSd7*&vP`D)qZRWD5=J|3oAVIByluY?Qn zaTzl&XEuTzt=Ce4lfd5&wESsarOEY)V?`&_KC2l(j%u31)GCOuH1;Ey!5W?7Vj_Xt zS4OE7xrP9fv%yJ3Omk^Q3YCS6uXsHEbPwgAMwF8pEvx8E&(v)9uug;#Cz* ztJ87q7^qM(UJtO5ooD%#E%^OpbQdPeZPr+K$FX7iabuC5c347c59={zQ8Pe>{&Yu9TjzTlu^n?A=u%gzT@W990w?gVu|$m zb(Z2_!!&KO#AqU)nWBhA?z58zl?8E1*)_fFj&LVdsmEoS{}-HEK0Sk_U8K$ROo zS&f4BHBE!>^LlF4XqTaHz5IW!xN}gFbBh|v4AZXIDft4ciGxcpM@8SE_G z55YtvyO15!XNCpN<_;C{#Iq8G4&@}mG`yT6VdunGQVCu)$`v)bsaLu+KjYuUhhBT!VVve9>y&;aOKnTM6I zQAh0so87UGBP+SH2bI&$iytVb?!=+KN91was;F!4qK=`f&F&b|Hhs3ne?s#TT5~1M*dJ28dCgdFYel( zZrY0cdX2WeD8+cJyDiA^@2KQf7isPl5`DoEApxq~m(&%E7-A{hS&M$ot5)&h%5sxZH zFG{L0Hlk06rNVQwz+^BQ1Q&~W)Hss4(u%aMV(LR}xhAKSFIId1iF`r3W8z!MhgYDF zWX+MoA7WuOqlsBFj1^Ume5Y=-W5z#SmX)!~?wh{-G5-k5Pvg3GcD5w=P(j&|5Cxt3 zQ=7lm{+K-Rt*>7Q5%U_Gm9~E}Fd-}C6b|$H@jw~YN^vqUg?=a3^py$hBeAEZ3uR&5 z`iBIz?dc4?iGG0`(ytbziHhj+!#bJ1$lG`d{#)>B{y1jDz&^?6H038ESfDq=J0KJYh!xV`Y3P4+H&(E5bF*=@`lpJ1hDHCAgk~pQD$NPw z40kv8^2$=JVqga4V>Y}DMt_xO#v^^U4R(PdzjQozP+vKn^`sb*-uc*tmvR5nb%lHt z$12E>4JsB4l)I>2ntpuI|GX0~T@nj{(&vp`**INl?1pR{9K^<_c2#B)c9v)6to|Y- zTFI$w&7rhj$By0lmKV3mUzWbww+8#{n8)PRf*w)6ak{9#QSm!rSWJ$dqteIpZAf|Z zm=B5J3{HqdOV@gWVQP};g!q>+g6;U}ONqB5U-0&~L$6bVT)o(`%vb}XTm3Y-3LClW z#FuYZR))(W#^V=~Oe@=J-K%gu)ELps>PquO2$5v{@z^P{hJQ$FQ zA$l?64|d3fqh1D<`F~*mByhB0BB0Oo`EEMEe{eYQfkE!AnGki+tav2&1Uy+^%* zG}A7CItGc{VK9gMhRBrXA1140{mLRP3w$R_@CuHf%Y4HzsSAKR7WxaR_N#TtT%Rs3 z_-|d@e{|dX-w^dOakcpOx4kg6V?}fojCaP>hGOlpFA?yuc?|2y!eeAbXzX7aQLHKM zkz2D{8Nk`*4yG_jCDAsAiEXvf6#PMm$Gl4*E#!EU*7mb5{jEB?KVF|8jp5`Fa*>gj z=7<-_mOR6%D%{F7Rd>rZ>&gM628E_nl~Ih+jA1k_u z=u5{EPfMh2N)mLdwXvG-D^0$0FcOkVX;m0nrz7j<$Z+akz(G17T$c=`(evW)Hh!Q$ zarlMhAuTZhC)nIuRsn3hF5u4@e8`=~%YgQgE8baxjkSO~0~ApA=b2bNgeufaB5^Me zxIU4mtw;7wgmo+-YPd1AwisWQJE?j;|F}|l$22wkYWA}m|2u&YG#%H1Nb`yCRdX-Q z0^g-5rq~HhtKuB8_-9sa?US06o*q6n^q?4!PxO zu}BX6CH;PRi=sVfoqm_Y5asKUxNsbcqfXGgEf&o9)8~}2N-VF?167OQ2ok&=^k}2F zGuwwF+n-g1m*lilI2*MHnW9%|;~mv)d{k=h zp)ERiuitw}7QgW$4I}CqDS~O_p_wBO5h68aVzYH4HCx#Mh^N z22nRj9@@2gd;n|78D!eTtXQr_@BcQ;F3i$A_gksSU;rpphqy>tbuX{`@sHI1&hY0> z_vewJgZw*k=l)L&(*M^RDJ#fQ*2MWE-69f3CgR(HCNthHdih1BKh!BMRJL|>HxDzag_13XftbrL zFx*$+GE+!4g=`B;L9`s=Ze`uGbex#B2S+_z#g?Bx5e~Of>WecNxqnz}X^|UFSUxb& z$d(`Ti^wnj2sS&TK_J|B3mhxuZmi}$6;bG^o=(Z>)ck?G2D-)uKLog#lr;S z$`;Ds*e5gAmtAHxs_t;uSO@AmP0cLyOrJA$h)6X~P4FzVAt; z*c%;$MBvgjG~`{2tGzq*S1{zontJgj_F9pC`Q(f{pU@~1g!EDUMT|yoH}+ni#RZiH*5Cb7gc>ThK55;4FizECc1M}Y7 zIO2uDEXmcvPInW3nGZ`8pi{@7XKkDifh1TTv?--O{*mwEMQP9a3Yh_Yw{rQQTfOOP6oi8_C?I((#u)D zzCZp>l3~Qhx>fTjB40m!GE<@)dcH|jK-n4fH-c(Q5lKuKq<&99@We75bCH;nes8V% z&nYvMxdwK|4~YQZd^!qg6jxlRn#$VgTK;g|`^I2Q{qZf@YPRI}7$G9aG^pwL??VsvTkc{0 zqwO=^zs}{&g2mFZ`FOrrD=FijMlju^$+Zlqc^dGb>eJD}p4fl=OLGcPN^B1=PTY3)_ zim|pf{}oa7CeFgkAd#VjOi=Sg&F~KY7Xp{eK~r%)(WmpbH38QDglGOnk5v?uz&;tK z+#iPQ$>Xm6`YY5jI2vj+bWBbJ9t%;2hgWzb&_TwFltmIf2#pH;AN55S&lot^NCnDR)(w}I+N)Fq5zwHj< zRhHgfZr*OJd>^S-4BQ&hnIeW2@Ku31yy=g{A7NIMa=HpCQ^~`|$H`y%@^HYh3wuP| z`UxZFhcFtc3~7vAnnxU!8x(Q|k2dJ`sN9WfWjM2YqGvVpK&~+~^M5>{RZ>>o8%2tu z&E-%$v!m9-*FHi1wbRKjDWl&$xhCpwxkl(e*=Y?&yZ6z(==~h-wAFprs_&sJ5tp0rb{sw;v7C%E(eK7;od&0)DlN_~Xdm`-|Jy(7)Wqmk3 zXCr0Tv=_<%t)rK~{_BNeLdTbavc<{7{!>c2J#F>@(ZL^ux;i}lm+bbLV9=t^1G3*_ zeY*I$Y68!}&7>W?5r2L^Ol82q60or?*#j`JuQxSlOuMw$sWWJG?95`jK3BD0`Vz0- z`<7j?H=$k$Q=nRT&tqp%qg1GA!-ZqQLE^;b11&}l>?j~Bg>evf8%g8S-Tt-;5Q!e zq?RVbekTKnK%DO^+4+egr{1o@!TpdSjUyBDPjQ6rH>NhN+MW;f@3(6b21q6p@!^xl zO$B$zdn+astC3}b&xkB(; zeuI6w|9XMzOJVUKc=Rg$k`y@%Md!n^l$^fxCim(6yFDIX(i6yN%%-LcoPgg0&iRqy z#EQzkO9M|ct(EMUNby3__P=2;;2}vLkpBX=0fxG%)hDo9{?=jqeWm`N{Pi!|8K9x( zg|30|jwF-L4v|lT9U?Ic^QE&$1+J-KO_W;ICP@~aLpi#Xt#lMPD*q!LsL2TT4DF9j z6tF$m*zuKSO!w#)li(ltS3=##boOGcHchI-vp-YKkM9qHe($fB&1oR9+jIcv$4jG& zZuHE9lS<~sWnua3NRMIl3jG!OiDB&!~G>u-3t;Kx8_1xTR_8HrW!30g~OR0M@Ht4>i|X~psU;366z&XCQ64|PT^JwiqOMb#j;qn4m&QcY{aY)&hYvf zD|U3I8KT>p9I;^AqYlZuWD7gUa9d2Y-Lxij<}%pa?%5Dll|D1$9Lp<8-fBQCe0zv8 zun$=OO-#fN#Se%k3d5H<6B>Z`h(ZatK5~!;m5*F=P6x<`L?8$6v#QkOLM$TFCT(zCtaEF z=)g!t{K*P}r#+1ejMSAQN;oPq=~qi!dFW9!?6iC)mjJe=D!;sH=Ho80M)|bU5;qxo z<}(9A^-glAVl^neEeF~sr4T>_59 z#VlnluCJCE8>M61cRdZ0a#M5}$RG&QjV< z`cWhti_T3O>E*qw4KZ`L;ifnuw2Gap%keWP1eF9bw@YkVRjO@fd?^dAK^W~5+LpSp zLvZ?-HDa}B+35^dq3}CZr_z|oXe4nN2MX6BU4L!srpV1{M=)x!kHyGFsyV^wX%*5& zj%l1whJO)b&x!UE3iDP1;c26;UTDejL&INB+KhA2c_u_ABi|LOpJ`R^s|a$BJz+9H zdDPgMs*=8il|hd?aR=5yE@2g9{K&VQhmkHU@JJ%kWWHa~N!4RqD2++I^CfXu_5W7= zWTk3tCGMpkrLQ||A!_pX76azn6v}?&m zYQ-f_=UbI0KX#;!e4*5&u6Y4`Ry>`fcNUITi@8o*gJa5aeV6c`HvMty;0pyA@p^8!VNIkd?eT4z8 zrD%+4j?eWy&|Bxv6a&-d;v%xldoCgfYRqjCk}?BvH?AH8b!P@j57Qd_R#(Bdr(VhW zm};qlY*799f_mR`Nj|{5-5~v7R{8DiT7CkW-;i_3@L~m|c4&A{x14}s7ra02XJ)?C zzpdZjBwyb3HeFshSS|Gyg1=iW6JIY~ZJ0$w2>CYv-iNub*?5bu%@L2Ktlw9LbLijh z!O~yh5%x>T-bJ7KCH&RJJX!g_4{SK)86cCLHl8<* zbAOBC0Z4HJWh(d(g0{3%`xHHwt_Js#ii6sMiUyjtK?SBo|6nUbGvqJHrA0X-SUVYs z@-@*|t2%>gi_-aT0C*cn9>b+Qz3upmK1k{Ul=|MbXwGhz13tk2;#plrA_n+RONs#d zcZTJE;MsqWtNH)clJ+k=o1$T$g>QipXo#i_^DVVO*;-<@;U)pubXTFAK}q;>bQD>?zpq^&Ue|EEJ0M5QW-U|Vogtf zO!qm+e!IUU4uoKiE=4dB@Z%JEplBeo;%uo79TH1lP-ahNarMzia##SG@rZ4kbF~gx ze4mT6tH&I#yq*APjOgTFYv}y)W>20Ta&;9fi6YR#478BPB{OoXBP2E+n z3(*@bSTGA4mBAV_5E}6`Uv787C%(r+3VmTdF1&t@&mTSi z;O^Ba&{KmCbHkq{yCc=WL?r6AC3F2K5)y%d=IIMAEbX-Q_;TH2CW2DS=me;2R8_K#I;Xzzq@cu=| zh0fety|9RjuDp6b2*aRGT{cAr4Z-Tg0ZTop6hT1ZbTv*v#et}ST-Q*Fn{EV)yo4G( zV+g3hbC`0g0B-`H%Tf5%K^I2t&NNRPp0m@>`nLc|h#b5=h#aOX-I_bSbs=ctk;gt| z%p!@6gXs*CNlf4iqMpeVKMsV5*`e4wz%7j61` zci7{#@grsvJeVFnKUzsTcy)y1VP);2Ge|QtcOl%o2 z`;5@w*}C%tJN!cJb&HH*`H{-@JMbM#z^#Q;aNK0=LFveZeaa}Pf{@xWg+!__ClNLG zf4!uz_f%ieC>cz?La+)-i~kh={c$GpMkXp5+OrIU zDD@HaU132fuzGTT&236x&96I1unQ#1GYeAuK9Qo)CS z$Ao}+Qtk_mhWvN6a)O|-*VZvn3$I~y>cxhnNc7o(?bGPnuh~8#dVa-^TtZW!z=2?y zVl|HKM&2sV;XsKuVYv6YhCc$9DD!in30aDM8rNFbJE|o|NculXXE(U8R=+Z&tz%y*@vxc;%=?( zYT{|(>SkguW^G|+XW{xUn-!z6?)K?0KGw089ooX`{pG?asYBTv#Ho{S@==5fZA8H4 zjT_e-9g~VP*Dbu}R8cXyuadOF1+RxHcI0V2CH>v!u{Ztaao^#r*mK%#tjGAOO};}R-9`trXm}wK zb+kLrNB7I)NF%jg98;g>lgynW3wQwI5>v69Akzw&15f@Hp<}6(Op4%S|BX(r9Ezir zIZ~fiZFK${8u6h`CSUR0&jh(X1k6g3dHX&;qtc)*)Pj3< zyq;oDt&a&%KK;eVrInUIjUXB4v`)m-o?^Nos`Lm^WaNz*r=gFvzpWVUOAQh~LuXU` zQaoG;5nj5Eqpqg9NLD@r3els_(KG88TuXNT5G%b}LcSxKt}A;-RfR<=)^tkSJkoCl ztdfZ)gQVkiedHerQ$C0^?t?JRnet*>AHFkyspjqago(grubp*JS8jp5zvTjm zIm}`i&UQxljc1l)765=_10+O%uia8}i%FTnT22Pf%t@&v5?S~l3^=Pi59LZbNMR)?l z+zWl7Sk%pZPe|u-vLQ`3eaOP`-R1BxwTL$Hl0>L!;WjL-DHn4d43wU>ivV6gynYS+ z%wMrjscs64!w&|wXFR$FzK(UVuHD}r^{$8nNd|zEt>}Hzz`($Uc`RKfnZ~%Qx}si% zG2cGds0;DD9km@-02b#tylF7c;&kUPe{sfr4W4mS@P)C6D{@$d^?l_dt5B1qH7|F& z0ycnVqQBx!jr5BAM;eu#wm_KBg~_CYwM>8kVrI#ep6aH4|02z6@_e&I8Z_H-PJ4KE zSpT`0S1s%BoQtyjUmuK`UJa#}TbA_!)M)Nls&mpywWaZyB1-w)w}RGUO1mQg1ZE>M zhjIwbbu<#qTDXA&?=Reg%3^_6j&COAfM2(q?T7L!aBt6l@Zi+AN#-g)(q}j0bMH3` z$Mw{fJ)7U{ZccIa=_ibffGm~RrKGmCwk_;2EMuK$G=RkZHhh~`5rJeYbL9Y(ltXyl zaA5tr6NLtac6Rw@WnRpMJD7)kWEUoZmUZP}H_J;Sf&wDL752lz&#C$@)nBOeqCIJM z&0%LYWY#8JGdFfaxUL-%lh2TJSMlz&Lo74aXbAPB5S0s5-Tn7|PqhH0yXi8qP18v( z;rWdN>c@j1(7!AOyq@tX!l%XH{QT30e_s<3`G2+X|7Bf!Co{XxG6>V>a~FFLHyh8- zu3vi#5i>IjH#Y?nM-!|6#=#b!0X2pQO2A|w0zDriU4dc81S}`Lo3J~inP^0ga3K!= z{cuNH1~mqC7LvnV82yg;tGGdC>c_3-}fd5xjqE+cR@3 z0@+W?15d68AKVT2&6CgvpLMa~37Bb+;AG5---?ictL zTRtwRS=$f|3IMD4kgqkMJ+biB#|e@9lzG z#r@ba+vb8^yumT6UEp7R{)h9_1}pW+{S;!lzm2c|l&7i}-xsNYy&%QH{YvGn{Oy zPq=&X+myyA+I zAv=N2kQwUZt4_Uopz<7#*hGUQdSPnff=_|Dov$dHy(4Z^4!0vs7+7-~L(V>+O2rb^ z5wL~3-;oH!G-IC;as^a0h3+E|troGdgwDC0fUeF)&vYWtNhTMRAYrzqu)BWgzX{05 z{|$|kQTTllTTVBYrKNyj7_6)xPKcrsp$9$}nFs>>N=wrOkkZ5v7_llvpJcqciy)?i z*+u3ul|zSHUM99<>`~WTyyz1QZ-KVRDviZ-6eXKg9pmY>To&pv1bhK^FdQ)#>G|BwJ!eTd{hJT2-=pKbnbv-;k#;R z7^r3)5}6rCDM2Kh!g**LU15ynMg2NO@YPt;p|X?$i70BJu9QKRRmvJ|g(eLD~UeHUOQ?CUCU1e`G4CE@Dk4rdOZ?r3fXIq91eUdt^e6;A5DU&_+sM781%z1TKeEq3d#`tAgwtW;Ya`{b)Vz~!WyS2Smz~}9MA3v zR~!WPkSc>L7=7%t5Pv6v9B-+dS0kyIf!{&jm3Vx>y4*8Eiuh0H(o|a*%p%&`L_Mee z+(mwJNTSL_q+Rd&%*o=>zeJEI+*{TXC$e}Qa`GY`;X%=+HoPdSdyC>*Mb!k+mHjAU zB4X*fYD{aBYHwU{dWP758w6({F&Q7JK`@X%N9!o7F}iA#@OD#(1j2GS!@qLXMX5Z2 zEKm^X@IQ56Ju%?(X0FX!5oq5)RmajNiB*TM2p2uk9((z{U+XZfDndv>kVCrfke`~z z`&)MGF|h26q%dYmaq~Ec;6k-X=QKy4`db)VM$HvQuHILAq2cD@BxL%D@ zdsGU8a$%Pk_c}o8lDnXCMqR#*|S% z!ngc|i-5-$=Sq_Aj8sRg0XNGjjFVLhh(nnEYYicDz?Sp0z0T~#j2f-Y5N;P?#puBc z?$~bt(?8Pnaaz`KJ80cy!o?~D5pN@3Y(>%zpt$XKZSVTqRK-Zh}rK2;J$;5~P*VP4G6sd>hdfOz(NvIg^jyz=U`Y z4ekZ`{@|oP)`DN8^<&MHi8rC5t!pmr{ylHMVW?INQtbIA#aUFIH{Ld*f~Mnh-A@JO zkc$+xpK1}kDKpLF4Y+7DVC%>tD0`1>0ghhAeG`#8xqI-7x%-+HVmwR2J_iVCEhPPu zZ}Fi()c_KiK!@)Ti@)NYsF=iF<-@cu-|a>OeJbyII@cc5=0_ADx(SnZ|#Le@Wmu$BP8{IY4WV=p^mVEy}%M2GSB{D&!Rf-@q-#@)w6@|CzBF9 zSiQKDKvcKSlsl394^`g_A|P|rcJjx85)=<*^;=!OX8pXszN7scIiq9OQvtc$NPvVE zlGqFbuf9sJ(yTEuV=Xr|xEDdXg0Q8a{4bZ4@>Q%2Is-s60I9CsUCQ!@4uNxSQ)w6+ zwRrVzsz=DtrQZ4Ko?z7F&sW;;*wlL_Ph8HLuUB3>;KM+M1OI6#XB2sl@cTg;Apa3* z{2KuM??o2=X-2WOGcYC=HZe7Dv3CCNxxAyDnd1-sl(ukkHnIJG0BE$5j@^PPx-V?_ z{)p3h603X}ex;a30xL{a6AX+sKZc^DdMPN1NCt4}Q~3UJJ(FLP_(ELCrb9iIZQClX zTXa)OghbyG>1FN(-bKVE52z>`1G5zG^{B_z%;?wUaAN7NX@m6LOt0r-oI|zxEwudr>`mae)!t`LISBexT=#i^sNJJXqZwsS0RQYC+o?$*J{}5 z`&}fcR?FB1fotb4vKJi(E2skE8e9s+H74LPu>M*1ouJF8PBD79far!}&!r2wKNlJ(do0baXaXkWGtz zRJRU3>!<*O!uK>F8Dl2d27Lt*Hn<8zXxpG4z1i{=g`fP^T)x;}(Fn{%Xbg3rk52u! zStmvziNkKp%9>{4C{pK~A#n?NhQg!28Z^k!m-2Uc$`4&m(392_Lb@I+G^zF!uxZ^a z;06rkBdDl~A18}h6G9D6rZQ}A$}0DOlh1BD5!cLXA$*A z2AD)X_5CLxGVIy{A|?we@&lNZi`qV57sU`+M(H!~v3u9lKObr+-tIcp7-sB0WKJ|M zL}cZ#+w_dl`Bv7aY|-JbdzYr3rs|{Ha^}2HU)hB1 zlt3Gy6OT+*V|C@OpcAx7N*9Y0jZ0v~2a#klF>mf>K0n z&t;4p@ZM9C?BQ*g#L;^=KNPGOnxNo%V6YC)uVUd>Pv30Ot6_(QPz`z*qk?ES?SJUY z`yvD#$(!~PFcomP7jMHL{OSQDK;p?43YKqz&GyfR>Rl2x-y<$q~x>h6)0f7x)k)~|ABym(65 zt_S@XE)r4}ufCWksFaUiLlE-h+EkFZt1kfDxbdvUW3d!m%84P15q`?o{)ri{3NdH)wI z8?jYqaB#Z?;u4t@;sD-oJ$?B;v}U(xzeGq(oxwa5_kt7plPwn4295C%kc947KgC1E ztV2eAsRoxqJ&x4ud5($!zHBX8!%#E2;)tvoSGV1&QXKJ{Alj#JzcWYCasZt%v_**e zj!ghd78DbMeGb7EKNbj(FjPhGnmXR|*1VAcD2zj^Fea!&d7!9WnCEg-%DR!To!HPx>;TW-g7U1c1I1VI_f5@N5<5Si7UsSP0uP02gm*qy+I**O}9grWeucd^DpTstbRXRG>bZ<7<%*_*z`=?=(}WV z?kch13Hn3FEvTG75{&0d09ztoOhBkv$?*1%KSsnkLm>E1$VvXkkR$ldA@^?ps8}89 zx3Zbb7wqIkHy4)y-XIYWF$U`p^?Q^6AO?6miNR_eb`d#v(=l5(cGgmV_0V|JbqgO-DZETztMUT#I4z~ID7DpV3Vh6KD2%g4YMMvp?XA$&hs{-L&uf{7r7^Q z(&T1DQ15_{!exd^DiagYjwfsm%TXv$OW;a0_GK=oY2?MC1|p)CL992_mGxsnH;RfN&$K%VgeV>M9G4N$L<=r0YVe}eI~xhpgy8Cc zOwAes{p*g;q&Y1vTSJhUEmJ~nscIlvC{R_18mxM#r7rF5hMI`X^yDdljo(;6>d~3N z(M(*fny(6X*80)<4ig=p0@Q<30L(_!_{?5f{hSF6FkRC6Yv}<_j(sKi(T!I`5opc& zI=@;Ak~gL_8E2;(#{w?ZgQ;cey_Z+F7;}I*=Tt&rH%P&IiwdAOc(urCfY76`n5W1r zcVI1(>*wrp$=ecep$(C)ss=@cL3*Mhbu|sj#y!TSQ8$X;Tc+p1lUts2RRaCh=3AHt zY2S_EH#^+0T9jr5m-j7bHybKJ9q3$v_47i#JiL38Dc1xZJb|x>5;-2TWN7X+GGaC2 zu6Zw1rJl}7tOCLFYEXSQ@Py(2o?7FFk!)F$hlw(uUi{X-|0d#x9s*|5o#^F3^P>M9 z$c`+`gZZ$m28J-8nB5)H&!kCyz=_dLCJTFLGdhHDW+jDw_| zzT8z!+aJ0E90wG*`bTTe=w4T8HW zeVF!Q@(43&hmO`W&>cs&mMS(`t!ls=-tNJ*ckF)YJ+ei}%&#%@gF^r( zo(tQGSVkehy)x7<6aO#c_N^bL^!1GEWaB8;!=0Pr)DkBKdpdOkGz^ z7C~LDI9AbR#bd5n+jX7#9b&v7YeHjI7Y+dq(lt_(u2xmQ0-2X~qe*)HGuVwTx4;s~ zxa`sLZw=IkJmVIr$P3eIfPTv;f3k`8Eyps$T0}PGdC5a{c)Wp7pcfJEAPfqg6mia1 zeBKZ)lt+jDHGp-4;8=Fo{1apHztjEw-9dNl)*%DKtB}G(q`U~i2%~_|BYFrgP^pAySYkZ7C3y9Vp^NL~>jq;!-cE$St{(I({?Gpm)oH6q3}FBQ$( z;5$t287%sy&2PpYe;Wb3F?%_k-nPMXWB-!U| zsD}%yD0hhcr7^F0bxtak@K#NJ)}j`6phBT*P-+z$W^#Y0O+LSEl3koj z0Ap7-#_zRc7sGys6;yfRDvE4LBJAZwK-yPoL0pDpU+3#A4?|cx)2>)w( zA89QE{E1e9;UtyuciL)kC1s0@wJJ$;6{xO{?Hixw>t3c=G3{@C7P^PkbcCtT?|je? zz%Qk`lYUzuzP!O3^yV`=k2)==5x;#uz@L7&ModOVYb&#FBhni8xw)pp{G%r!Tvw;K z>>6Ntl1O*_^lK8R7^O*+3t@ThjKPwj>(DQYU*;c@v^eDI!G^x?E{h2SocpqIF)Qw#oP^5N3Yryx8Pa#q_9hZij1Dkm_EJxL5hQ;V>c+zG4*H8dYXq* zpEytZ?0u@8r!9(g=WE&6z%y@-Fq|2ksJTqt9yLyhAgo$->O!j7-%}Dp>ac31X7GXa zYI^d$W5`4s!?RUGvekTT^jNu6Cv`gOW;og18O^?}Be#aGczMeMF_g8x&_;cse{0wG1=-~tSRKgO=ct76udVWRm#_!U2&xljR94c8MlMpA52#~sSL#RZs1w~NiHE`fGJck1TU zE9!uCRj+G|H$!StbgaX6VC8c0xT*kPzVS7=(d8!u>xwogc>>K7JzLQBRyFoWvbEBq z2)&*9ngDFF>OCnAnhfbu?!CKS@i8R{wF6Kc5V-5pySN47SAQ*txc zD9&_)1!2p2$c%K$8T>keaJ1jr@MNJT^sK)b9vlBGV505^ENOc2W%ve8G&#uZY}N>Z z!f;HI&b__j%RBtaECh4x9ov}3a%Q!GlbST@Nn?-wO6Vrm6UFh?d}P33b+De!XM4P* zX_#x%(bH>TvdHHDhd9GX;rplBkblXoOB=QvWHlX_eiV4Jyt)|>xq=!g5`C}#kXpxN znKMe;Z?Y)4$R7Asw>!{b81!ktWSvxZ+?QT{Jn*%=RL$T!*s~n7+_LO&!4v9pOz#f+ zKC|mDfgRNxy{~d?|AJjr;toNS>Y%f-opf-4ct*xBGPwUoI`0O$U6=TJ=NaF!#2zRL zGjMpJ5A<2D(LcH`cGg563qfg7;j#;63;P8iKxswN0IC!73VuQ+a@>v#2&msZ+{*a2Zin%!j0lASc8Bzk+1)r zsDouA38LvlZBSXNbn%lmv=}+rq{7C34mX7?6f0a7e%P`s^amO!&mf8GNQHE~_OXhP%m$R3>J-gnfUT z!Jk)605keLTu(FSd}t}RV?54EfAbtQdCA!wjtx3c36(PB2{#W7nWF8kpd)rLjcR1{ zhHxRkSlZAJIWS-RyEcM9&?=a4cV!mV?3P6ArjWH#lW;I5U?l;GomOaSIf%QcJFF`7 zByCW8miaAP=7l~F^a1~Ms1}}pyNyf~Q&Ydy2c_+*R!bQWTo~RV3UaDhlw30>9Jw(P zV9#drjd|ss*5Vbw`U>7E+c%_U(RgjISNJ8X)Ph+zPt!tYna?gf7<{neajHG!*kOV$BWdc9pJcS|6^D3Tm^jYp+&rB(0ErnDqv7I-#1 z6^P|h^VwO(<1(-jd6RO9PCbrze_c`2^-G4AB6}@MtWkb;cK*6-(3VQRIr-9zAy6T7 z;^;>ZSAtkiqnk7J#$q4);DxG>SL5c&^#wCqE9RqYr;44c>$C1M#(4C0m}*7fAHQ*Z z+cw6qfCmhrng;Ja`g^yzd+NBHSx`yneXPMX#F{+yKHwV01EvJk%Xn6#zhCl8oHB|C zm}!ROZ-hR@C{u|!jMS>MR2n_ll)Id^$n?<|mfY&12MjCU!Jp;nuRI%g_;^YBgt?>WM;M}dvzhZ(qx!RahC{OL`6dOw2{)k*Xbgg= z^{=2vVfhRjw7-H@oi=EJ01KNf`AdTx`!?s zifxjbfFKyc|L*kx-N=LgxVut+=YD?QJQ9%P#0SE#< z9a!==`|p9NNf|~)Cbnt7A)IOzE3Hm$E2Yaysr4pW6^adrk_b&HAIWN;<+b58pT=sP zi-PKeob7I=F=Ph z`V3esaeWxQOQTnDAISDrrkGA{^CfFj*)o|4m~?w4_IY zBYVdh9jtO52)AIAq|dI?w^WCyy0!40#as@jVAy?t5R1BGbU+(7o*bec$QsKd&Jraz zXz-;mUx`9VB*9cljMid%PoKqZGnpLG4q$Q<8#oOrC~9$WItOl271jD3%y=}I7`sfF zA0R(z=2}N3tP!Q5v8Zjsr4Y_!Og)lroovfm1GMC!%Ct+oh&f0c21fCKpNkPn1Z zdU27d=Ruh+x^NZ!lVcTmKjTYUCZH|pZdVl>`ov|%uEmt)vnCA+RP)fjruM+AMzQH1 z$+kFr2r*;WEOnbRTNoxsU3OI296njLLfT7VsO53<0nUMkp0>5pp~if{6=ibhyW;ZO zT*)C7R*ag-hSoho43QQ7GB3C=#5HwJ7d|fg1Q*L{)F`{562zwc&!BcAX-m4)$D+3ha}ldYQ6mT75%;fYvxLS$V@F@8wa~3wUBzL~0cnSZ&T2!DU$` z#T6QIa$qqqlS-(95z0H{86PvLc2a%G$>gdyx|;H0(8jpYbA{{l~;m@Vcf0RKD0l}ku9DGL>RgrM}L;;zxvSXhkQNw&As`7h0 z<>XMk#l5jYmZsD7qCEx)xqW1jw+)1r?J)1&=RLX3jz%#VD(p>J20YxhyHM*MxUFat{F?n!b2wjwro& zwrpXxSRH!PG$aMt*4NzV4eu2NFuO2yre_iFjuQToRXOI1zs2+&XY}mmqSbMc z$Ohs&PAy9~=%Lviq61-bc|k!75w4($ceuVgTMlS{V|%Io{e^R^Lz6;w`J_bsiwtFn zIL(NI_v!g1P<|B;LTZbvCy38MbfS)`Y6IB}Vq47zvg^R<7VSf2B=?q7sj>%OA#v`w zB19AHYnl>fRPgL8dh>#*s!NsZE4($RW(&1vgEO7;n}&aD_f;&C#YBN(iJf4N?tnN`;ixQ3$pv)KE1W;Bp)j;xP6>74 z!k}E8R3m97A#Vgnf<0v=?}=-S!t-&EIeO-q(;T!i>YSOZv?@+EBODFHk5{|K2vVq@KUoxkjo+`?w64uX86w$(`&t>9iy&^Kf=R_*uy{C#Ne)J=fQdcjW8YGvg!NQeMcPc!#WiaTEUrwo zgfsrgJUOr{u?T6`bdmgFU=R+wDX4iV6>~e*EZz9w(NMNe046i%(Nl zT|1kPM|!>>J{F5XQ=*KPp2!-LghIT^tXiavpit!qd&}Sf&4@lIcz=4523GX480thY zeY%u%`fh2TRmOE0ygXyIR$V|QJ}hxLL+V`gy&ZIJh6Vi-`lfNBPGE-kEI$B(stk;^o_0_`? z47NlRk{KQp2}>XBfwBwTU%=Ho1TW+dZ9c5%7qh3^^_`ccdvh5IpRl0VaI#wZf#^F`h)cl_Pnuy zw!6M*BvxmV<_Ne4(Up)LOKF&=gIRurnz4zz5zPU?>qE`czOrT)Ie2Igz2Uq9W#srG zM;&>?Z8-2Q!C)yG!A&G{rN$jX#`hHg9_C$+GQrS)oVjkxjMxr|kh|{w(ASd|0u4{T zJEQruq|JGNXQ=%uPaho)D+wa*=&XZ6E1;a~#=hX5xL(-vY6&W=AZ=iE$bRSc$xpi~ zq{HN}Q*^4eW;uLIkba&`m8uV?=PD5H6qGdi`jjI<3t5Lm#4No{4z>*^WPO8CSX(TbuLe$U_YKEAG>?g)fsZ*B zbpnlL@NACLkVQ;3Z6TM>B^5$edib_k;C8g!$O!MWN)j(3qJCp}qr^!##m6yCJ_*|W z)+6PxIKy;1%e=Kbb`>{$|4yiT7k-o-Jz?St&SXKIv@s+~n!-SPbZWLt9F8l%K%5k=pV`YFF zb4Z0~CF=o)$>aOyWE;THs^uo)*ci!Ndmy9fy&9&bF z>XDhYhA7r_4z9fclE0abEorHGvvekJ9G~OiVXqHZ1QRhIwLb#pOx5>n>Du{o51G9b zT~>Ux7(V+}cWqN7|FxhZ6p8 zECYb`dV+;#(IjQ+m5J_JjD>*+*tgI+EF(junl46;B#T}tGUKFK@h$!gI1-I8r?7P0(Ze|D0d@)4v8R62%3)G1 zYE<0N$N&VId`=ofxx|Nx;6qD|!eT-KA{UJ8#50Mfid9>RN|_lEnM|jQGI{ll8t39D zR;i+1&`^=192xIXqkE?n;O86kIuU2@4a0DX1|F14#r^c;`Fe6DVwoFrJz$8vT$Ew^ z8@~qj*Gm-Nmb>k;O7T%>oI}o|Q+4E<^D@V!6LV~Sn@#v>9>ARGk3f4D*g}d~)zU(a z=*LIj`cuAusUWe?dJ_658~<#VPHnVOgjolPQmfRM8d)4m%uO*i@US)JC+y(h*sK_Q zPX@T;vJsFv^^)-G$-3wps!F@$pkaInv?%(#*Dc^EepBuG;aFTz{V?4L=fDj3z^Om) zm9HA8dO6Yr8drwCp*D3bv=cs2-+EV)ZHk<43vH64%}23*=wLr$!j@8pz)N~tN>6MT z|KCe4QlV2WR-rNL+Ag6EUapU@x;XHa*{jK19*2#FBs*`|Kpj^cQFPF|{s!F0H>)AH z_I+O9dP@$((Y3`6&ggO=-XglKG|^@;I~)NsS1ot|X43$kTROx!y<+YO67R$~eAs#K zn+fMCgXB!1x0w5oi@7i{*vF50Cohk`Hlj%DDfHZ=s@~X~Kc>`}+3)bLzXwb7};aNC44e@(+hDyo4?~r zhDg8`l^>L)-l8ua>$h*hW|zR}r{Isw6mG~on})qdfGF1kxDVm z4rRIr9xj(#|1#0gkP(%|cDjGF=qV^Dj&Tgtnzy*>CWGyW9LH(d6kKr(C8(Y;m~}Z= zU|BIR4sqb}mNu|IfM{8g@wkB83nX)unbrU`Oy-}n=yB(rutBo+2ytMa%_$M7;c}_q z7G~jGYJ825wld0gA75M36@}redOsKg;M}&Vm+9^Lm!@m_%dF13{L*t*@F>!-6RvU7b$_ZtDU8BN5!0vVY7H2vmuSTpvF|C>Qa;OF;(MaMn7kL@7+ zCX)I6GHyXX^QPW)%U>SRSb*3tZ*wnfMg73$p2DaeRW4z<2;s5tN%2r;ELpt%?zqVZ zvk&aJo4BBTMBQIo{fZDSP&4J20_ozHL|JFpAO;xHzt!%x5vr(uJseZdf*+6Xjr|y* z+l6qi_Pi4PHkAV;Zm0*SGpCdllLvf1VV#tz=cY5$7%(sjwCITx?cdZ&qf-{t60MUs z5kfsQ1O&7JKxNQ}pzDsrblMlh+;vPUH1ANTvrNh|DQH5U5iRBj^Q>Xm0Jysz?ppeV zm16Iq1H-kq#qZ8Y{yqRWpIdZyONx$uhgQ}i8EO?%Ivm!f4xDKeia#pvp z945KWm}S6)XSPNwnoJXJx$e8TQX^7*>AARSYvgOfAey-phdo)CeaOx#EeB@lR$s}B zw1VHDoY8YyEwkWsQP0_3CjhLGSKSh&dw|Zw-4%lKSqz{|KR)5#!t4{f?T2p86*B)u zX6+_a(@JZBb)eB*{e{jMa=O$v4Fi2^lxqm z^2TVVv(RH+pg9?=22$L3XPOX2Ixtp2gQ7Q-kdvDv2IkF73dUw?wK^1Cvq z)5&S}ft3@wvVBF;X~pt>#S&`OGWD)=_ypsn)dck0C4d};iibaT7U=sQ0?{@6_%#mk z*RL1S|3r8Z{?CM$#D6Oz|BdKkiR!i;phpg_&4(Xa1UorM-|;U9nM6@6l{|!?eUbs+ z7)r^`1waY^do~MpBBX+@!ZV1`bANyT{s{AHh91ejTbs9`aifq|!N{1_g#b-)eN83G z#w4?;C4}&KD9GQ?y)W(z=v3+C4F4XGV^*+(v3jm}>C?#QgoLSb<#F}9=Om5jWGwUr z^G88Sr)Kra0YsqR)0qAD2T22SYv@(X7aAxAiXH!{Y*pi8ww@!t!QZ(JW3D*_>aeI{V$ZfgsqdafweX9{~iBn^!~RS zW)eRk3B-UL@~z(79MfE#8r`WdYq_qmXxP0Y3J)S8PwtFiHg92lB*<6|Rlh^jP96Y- z*B6R7qbwo}OMIN=eGEl>jaO^;%e8*|h8a_*7575x>Ph5e6|7~wxv~Wdq+XdjUO^p- zP)=SCilhy}d1b%k=qYHOEwFC|Os6KQY^`1o%3V9L+-Mugi>6kf%Jy^84NOwuhk7TV zV$pmw?VfAGbJB-?%{0%`aiC1rUy1sMiI@C2aCEeOcG|-nv1W1P**9S;cbHk|HU_S} z(EWrYDE)`I{88Ta6vHk(Gh)MLNNEyf_1pTTM#Y~31!0rfKn2Ihz5m; zxfwDls<}?SB@70nzC-7Nhk%1wl%q@3p_zVwDQU#yKSr7ZwA${PKZNe|ez22bDc;YLj; zB`Stk%1lL7rDwO+qyuzF9@SxGkvWTx#TZkKRVQG#G7d^r>`W8fQZ-v$Ot5wB@!gU2 z=d{NckJ$5ZYv%JKNA0yV5spt%_*(JWq~mm_|MGmh(;FXg(})x_H8e#mGzBbGERm?N z-klgpiZnwsA&_)L$#f&CNJbh~Y+h#0fT@El8%Kf;4n}0pf~n2h*=<4GxQT+)Oq<@k zMvJcv)Jo|b>OHjKmL2pdqnufGxbN+#dCF>;_UF9Gw6TTE$&!MjBlQhq%_Qkd+tus{ z=#i-upYlH^W$Biu(nU=1?i=`AN>h*V-SH{z#Pyvf>wS-=%tmpEV;FK*TQ((WsBS7N z+r?yT^zERD?})d?#&M@rl2BZ1N5}Q9%jU#P8!57xL_iK49v;OoYDu;IB_c;(=G>Vv zmrEN>B88@$n>Z4}%r;0?H_nwRy2b-E}q^$T-tmCa%os+He(WH<%*%s9f zwit7hUt5GG(e@rva!qC(!B;k7xfU|ME4!K!R6Wc=vYDn%sv@B)jP6bqX3^|U#Ygt* zMC3DA%lyY|Vxmk(I_xnQHagyF<|Q%KcZ%^@Jj&uT>X~B@2mqd^%PV_5IU{vBQ>T$A zr_CyOO{VkQ7?x6EY^90`-(&+dd75OA)lj7DqKf%77+H>rp{PRy_30VG@ott}vd?6& zf^MNs30L8yGI_G?18Ged4NqkTvQyVAR8X1~z!n0OKo)bNhrvW%Gwr8LH1YiV8G}Jp zJ5L&v$jMLSGj&TtSr-mx!%ye|3^OTdcx=f1=nj@eHXViGl#b8(^yj6)5XYqPGo+0P z>xJK}P z(~9iD8Nh&0ct_%;>0kn;iBnIc?nSAIuI*VDqQ>iM6$y3;S>VO)Y5bf=3$ zDi|MnfWml@lcs3$Wxom=3kyn!0xE)}t`M;er!Oa!t6qf{1(g;g0HILBMUhA{km8|n z-BCna*nlv-?_2t-J=j`Uar)U*MiN7vl2|Zh$P66vR;mx@q>C<sLT!M1{%^%Bu#W2DLA?S-jnwoxuJ zob4B+rkFmn{xK{$C*sZ2AcoM;@!#5JUJRT zTtGvAK$AW@a}E50o5Uds1B&UWTp!-ylg7K91v@Q-=XZ6cPw>Zvk#=#7Nc7I)C75q- zwy#RLh}l(k)HCJ}_$;-TWzLb#)O~9*ZikyUv!BjGrL`4Aw|$=1n?F8?8{C_yo8>4@ zNb=XdKsLgfeG1N@}PGE!YqR(Y$`QOSjoebdU^ z$m+=>=3iO-YV(NkEZWd%ASYOmRl3Q#-IQ~vQ~U%bgZ>{w$@R|ENa)kXq6_&c-qhA9 zb!nmVB=DPr2on*a84r6w=42eeI>Yut?Jw~CT%pt!Fk$h)4HjW!h+zQ|by_{l=;E0r zs*?CA5|K^MBl^l={$d84LSu{Kz)!vc!7olOAm;L8b-evqKa7>r4& zG%Q4o`zVDJKI{673e738BaluHk0LfC{bn|Aqlm|0P+Gg-e{?(PZ20n8przC@oQ2yt zD;w=dq;OK&!0s;(gPwsP^-B4|@TV}inmHho4W=AM{u1)kP5Gv37`3>t223{s?fw;k zpx_x7-~DinD4|<~2M{}@jwC+08XqCvj8?xB9W;&yHgV{a;GZ-OcU10!^KSOZ?46vlp>-VSQIxf4wg>)zTl+(Dv)=b z`amUN*ozvhs%wPN=jVCi@&+?sKK*4<)U@cLlJXVhaZmGBtv>1}P@hc8eBwrb=)(^? zySknL?I-6K7d!KZ_JC6kd19z|KIL1D-`YpDQt0){dW-hF6wB2lNXw$MzwDv=upYwk z&&1kf)5XSCd;bJ$OQ%LX5Iz>++0U7n_6chiDV1RLi~490O0H;UZ65@nY8posNO&^= z?G34BOb(HeRy)Wmu&Zzx@2xOX^1jZV9<+Z6%Wk<#`tT}KVRB-BB~QZ^J-X7-B}wbe z_-e0S>a8elp>i&^3A)(wFMzN(RA`ArD5$85Dqa1No-E_`h%B37U>>3!Ha3?Xe zAzfWwpIcqcEWN;%Lazk)+sRhr;=m(qd>k_+bFQ;tF45&fO=Mue|$|GA@+-8qD#m@1TWKT)cn`^<;QcAI4;zFqc)OO#MjZR;= zas*|)Ri~%iDD>y&mhmYh>8Y2-ywjT`+2oq^E2-Bncly z^h)%aOre#rZ%q$}7HLw4?!mlbQpD8vEZLB~d#^KM=V$}jfgTl5SOvihf<|E*SaxK9MU0 zL~bDeRutdKkcElv;_jPk7lwXdmIC8UPsQry9x@;n-lEY!pjg|&j=gfYeUv_SC2sr` z@dhHCsjshWN44}0@Voza*O3PM&ja4)6oZ0+o#E5O8XDbeNdGsP8exL?RamroEmEo% z$FK$5b{_`VnBUT6>RMQ?PnUeGpy7Ic_d(dBeVGArEXq}?GB!>OF`_AVHOabx3$(8t zfOAd@@QqJw|FzI<+S>6R-~A1MN9v8ZVton&4OMLlRW_W&H;3OZEsUjde`D%U$zM4Z zxsyfeHU?@_nWI}>EW@-hE%NQ;eZ>5a7<^buOD+3j39)pBp}|{ z2y8fPQwkKabg74}cVs*t5MRiX0Pr!19~SWGbhKR`8pzGwao>ozRF>kVOo}pXFLlV7 zC=Ci4^<%AtE5^tzGyogXmcSx9#3}LPlF~&leK=v}CK`6)4opD{*TPk)zsa{1PE{qE zQ)DhA@Dtl#ax9wN%b9n20cA+;f%8PF3DwE;O_YfhlzU*9cqf$0uq|Jt$l1`B>ct8@ zD311YJtMvp0CztR?O@i&!Cx_j^$3Npfr-TM9S!D(;eKsTyca*k|?b6Y`1 zr1R+%zAi#ZeQp%-!NpukELW2_yOG1^&Q1?qOoRNA#NtuB<3xz#%S(4$1z6VZR) zWH~;#jg)ln^M}0{56X?W`=d(Pf$D{$ucbv=G-sz@6pFnbmpKCn#HC8I^F^;pUAQQ( z^@w=ypts-K$bu@Nr%Vd!%Z(1iJY9WCs(`QB-5*WY7X#h@?S*W`xbW9?#LT{uU9xhNul6+ z4ylUN zeg3Ll*yYjJNPhcjU4@Zm{2bmHKo+kdKq86!6eUUH5% z;tYcI8~OmpEjc}%f;WoWqi{3|%820I2bDG?U+y$v_VJ@juTV5dgK%QQ=iub1j<9@|6bV}1kv`DR6hW@AxGH_R&8g;`QqPVS zLy4eMh*4%ZI|X6_sc09qDkZ#41;k9f@t|DnE~UKCDB7;*ZrR4#nk{#@ta!b7RgvQo zh8SDLNv)*oOtpLXE(2-bS7YWHuq zChacV4;|i-b19-gDay+07AU*08=)rEoMz9UM4PU%zJrU9^ByS|BTJdJ#GR1H(RArR zT6Z5MZV|~+@bf{T8V>1z8cH6t78?+T!sdG<-7TOf5m7*4UlgQW#OfGRBTtRMQ5YXs z3ylbh1^c}M3glR!7r#ly#!Qfak1a6@gl@rnGQOjz(OFKxjd+w9EckGCm;M4WCwVI%HOZu9856^>RNR+x<{DN7fdn)! zyO+ItWnX?2pRbp|cTIji)_f=%8#EQukLaGCjUU_uNh-&tJTVt-RHDiN)jGa1#jA*( z6o6})GUli)4>6HV(;dc5&#fG~kv>?p(R|x(CkrzY1uL&*uS9rxDpVY5KjdA#Z^gB% z(kvnhNb)sWOo~iZTRuv5HMlKmq3-gjum^a&YWHve^G{QN&ElP;I{{YCJq0;U_QC1= zp|uhBeCp;?@?wIuh+O3w23K6CyapDjJMwk?PteYHrE%PW=} z{eu)JF+wa61|2r~wb7bRb=X-$H}nmWw+~V;pSTk#RRkdyM_^sBSq&geW~03{>?4oD zV||Xx!wy%#E2kj^ep-`nK3BoUZwxhduPSTcn%2$CfM|3B$i@i}&ZA0oZvqRd&nag$Row}Z3 znrnObbTaJ?E`+wb$PdL=#kINI_CDQM>`=djl<~c4k)`f38xUY&z9HIAp4l5&3&o`m zR++1w`y&3GltE>PcFkxn?J>!O`@s?YJyCuegyg6GLD3G0&I!L%p|CrWZPE-tC=+Ek}kmaSV;ypJmXA6&fBoMRmBs z+@f(Z^d8kPeE_Z%lmyAN_>XJcxpg1KYni5((`8JcwNp=J>|u!q>UQ=C zdR1Z>*Y-iEb}&WJN@u2h;c*=jb)&r8j>SHKtjW4fmXGdDQG*+p_0TAE9?#kXi*SFk20jj@E>g3@KP4`)kA4wz0u)PJZTO8T*qoYP>&Y|g^0SV{HEpHX7FC{g% zT%XDwcI=y>K6CD-B*CZdS8e*}nf^$Kos2XTVPW@S)6sn%&M@TRee^siXxFSZ;+ktC zl+G1#<3j7mIud$x)M%^or@aR^g!3=DU0=>bT6nR;9*JPOp6e`}K1kKGuCJ=7sBTU+ z@bBXU@y)Tc==nshwQkEE-&E-dJ9@YoR@8+ z4Ppp_4Q+W>)d0^dXlZaCF%yG5CV!zIzi(^i2sNs9BZ5b5G&q zzVyN_ViL%XoqLb<#5)3B>f$|>dY9zvprkyS)Z@`Wcu40evAPz72sLS7OR^VXX9v5P z#kr8xx&M{J>uBGLSVQE*OC%#@o^xyN8f&g5T|XE5YC*lcm0jV_1`i8(Z$BtQ`9Vij z0Z{~b;pg*VERejW(Iwzm#}>30H?$+zYhmia1{EZA0+DP@VX>E33mtjQ(ESwh4Uw1! zN)o4(qb%W2CHXa8{74lbg&owx1D4)(Gjr(MRO~|H!#sHHH-Nm)TcR_Z;&Up8f@Fcb zZ%8Kwann~yiBV43gJK4ko^x?&A|rsVP`uzDWKbn>uDCsEs=Z%Ck~DfHVUMH_OPy!E zP`X@sjcq<*oy{q(o6dlwj6UN|7T29L$F$gKP&Z*VKCkU~R;+qC1V4lBFe9V_x~f9N z4hlEiq`VdGq$LRvUT%%;UUNOUCwVe<81I9_LbgEv!9Qf*huZv7ygTNSVd#?Kp1(5y zl2neJowGcPe0w^?NkGublY1|9g~HOvUC_H0Q^8;Q=>7Ko5o26*pWHlSr^)7Wz05wt zt)Tc!s@Nn<q~^B_&I^ha|s$Bh-xFc84FI%B26gnErYyn=OnpN=N#Lo{*weM|{z^XL||%xp)ebaU)aTI#SU)5l}# z=kKnLyi*=ozi3QWRgZ{562J1VlyEm*1fYkPtNIL7Fm$Yj+GdY{yh+~;zRAn|qD!Nc zLTd0v^=8l@L6wFvTIMjO?H-gaG?ZHaHYI&^3lJ^$ts9-4SYh)Tn0}ef6qA#O4gF9nK#?RAS%>siYM`ywu9K0< zp{Qd_bG7KueJ|(Zbx0|aO1YYn`l{!Sx?Ey*ogM5Ta_ zq*&hB!;efrVT!%ISp{pM$c*-`R4;Lo?(6%z@sSfRcI~@+sh|(ogV0rN2-PA4Z3_;) z1mPZ!gx`0TlN-&GP!{A_+8qzaT!)aAg_l?0BmA~~PK`cuQSB86=uaIsB9%!}A1Vc6 z=tsIWNbBGLoM*ebz1wZ5)et4n{b*eV8BGU|U^CvS^u}-1BaGChPbm~JeJOYvm6i#T zN(O3scr*g~P@} zTEwv4f+8}%8=tUdTS3H*Q4V#koD!P=t_u4x=*`0I=S+1X!LidJ>g%pTesoB?jZjV+ zflU!BZ^*n-=GhBM`U@V~MP?RBP64TD6e=)zgOd0@D+WZ_p(bt(mqqDule17G%F`f- zb|TT`iJs4Df~taED&%}J4`Np3pm7C^>PozA!-#y-PZBXCMvYp5k! zKHWZf+^AFKx<6an5E`>_g6k_?HNW)rFeWax5z=}7M*fV$2jzPFjB&F_YoxX-4eh1)VI(l=kBfZamb!D z{Wa&Pu{& zbW+viAKnvYT;UsVR5K3jp3@^#60~AVhY5VBINdL})kALb%rg_N8&n8uwpO;L&njf7 z&e-!+b-Z(i+eE7~75eR$9lUc-Eeq-0qzvOTJ)e=`QAw@R`cZqQFp&Mv0q%IAH`*)8Eko-E8Dr0N*pWStX<9FpSgfK(q z{ac^YCOnVO7qVBeeqi!Kke=f)@a28U>o5-7bIw`R?pB{N5?mw7gQxXK9GV!uZqT}M z7j14p#^)7@89SyY8AtW&GJQQ&dU}ob)Z38S((>}C->O+=Y+troydx-V%wsC3wekdh z79MsEubf7IbJ_Zc551zgJ21DA9(+w&4&!VTs^zb)_$<%dvD92DW*Wu5Xhi+3dXrPn zSCJ&q)|`D92dbQk`o{X&l1ActMq%SKo#UUqhaVxcmDrkx^b;fv=$_3S1(mAkw4pZ! z;I^F+3#>pO?82VP^B5lQ^kd1qbLS{pE%s}vFp*i22}OX2coR?C7Knyf+v#nVpawqvjRlipV)kI z0(D@LJff=Bky{`HhO!3Zg>*4NBb6hEQ^R#j>Z5LQncDkHtrhP=AonG2%e${rxx#|- zA{-j0Y>rIecNtc)$k%he)W609fQ>mhJs;QmJRPB04Uln@_fE5_y$)9d2`IjPd*#tr<|?ne$)N%$&l>S~tzOtVh)&24g%jg}sp# zDfInFUnO>Ufj;E-qkw@Tk{UePyIeCOk?<^&F1U%PA01^^ZVu1nMqY~hc2msPg7o zwXrN7z4+*mW4tPFgsu5r1@^_K;A|Qsru;4&{0yg78O?_;_T#j}7FrSZb3RRnM>s6@ zHJCLz>aSg{A3bP(iVdf$r^`ejuPYsLIucb6#HnmZ#o1z)PUyjyJys%mxE+EiDeW~G zH-^TGIXA6q*mLw@oG}$wE&5Dl99Kj+x2#2v=U_K$(ce3#o)Qp@wa;^H-!5AWvmf{PxKVreFV@K)SI(8a6vwm^j{b@Rl$D z82{%l!9t$pvP<q*| z(Z0^>eeoipH^0EI3NI^2)(xjJs?~8uR>BCed0Xfb7eBlDc$`m8U-U@PBY4{?Q}5VN ziK-Xu_GB$VNu^g3?@7mJ@}^i?x!BUQ$r$Eypk#>io<~9=a>_y1!OO0R7%~=k4ztUW zK3n|9W?|C+ZXFj;Zr3nD$r{&;?7WLj?fpQ{Av4vFh)!5eEKa4C+nQ4&?;SkKK74V` z$&Syd>JbHWW7JQD8FLZz!_S3eBv~*;?KNJsvJ4-kI=&em_;Pt zP<+^KA95qMQ(=>;oPxSWyJ3oWP)JcpL($fC)C^)?pQqG0oCCzCLvjn#}xs_wvo|g?Ntuige(3spM;T? z>0U{&Ie`R`<|t56T6AP5nA!@GCTgDXQYAN^v&?l+3ilgmx4^*%kJL7Eio)UyVu`^k zi3EzmFZl>q#k^h~;R?1o65uWg*sysjkRCGFw>$J!Y z)ljpmxzUHbi}hOyb(DJ9Ybf`*<1A>Aqo3K$lM&pp9=$^r;l9}*sGT-L*Zqkyr3MkO ztaP6N^lm5QVW+ZV6(&QxnTAhV+?fG+^vW8ls6}x9vOBYePh&^f$?f;@r^X^O-V-PKI^TSxDF+S350;cZmjXSiBZbUTNxPj zLq-E-cO`ub-_}(1#ytYl7bOHsE^h74Hk`FD7FCs_F z&?=bN_*&Zrn&pyF|aSub&v_bPom|X`{_O6NI$#(g$*n)+ak!HHQT<; zi~FTNhU)00jfj<7QHZMxOKKAc#lh#4n_oX!o*#j6rXx8wv6XIk! zDdZaBhw!_V&VPC2@9!F5VOxNyg_*O1!7r++brfxEhZHb>ey5$GPFC1WMfHHXfLAbkNnrnh z&=LWM(O%*z?H9@&*F#}3(xw{+Bk1yl?eL>twlsYEs~8?d2Rn5o23bNH550S*_evkR zAZb}zr=Fd$MZwNkZ(ACm{+u_?iw|4TlVma4=(MMinYxGy-K>-Fz^FuHX3r{WFK(a# zb`2M^&FPti%k_t7GMy~t$=2bi-UhP~jFMbS~1)dv_swc+;yR zaRvm%=}>iV=Kkbt>})^w6KJFZsG>3`w2wQkenXu4OW1iwgoc0dEyi%`m>xk^FVMdw zF4O$_g7|mTiMp8>Isc+Jsj7{&(m0+EYNb&}1Dqj0iU~(Z>?e`~^VCQ87%gbtYe;6} zZXj!XB)FWY`E#g zKgk=qkx8vUvoPte87D(cwkTh5oImAT_s%-XJoWBB--y@qfm+b5r{3SGaSXl8jEvr% zl+@@Gz08~RVt?41`ZoN z&&%Jay$UHWW21HjuX==mF0jYetv-`u<-b*Vt3r2C>sjaNktdHp3L9YY(bQzsCyxd$ z7pNkE#qdlmp@3GpRQXM!M-S0yM%-koji!$qoNDHX{B}XEF4G&-=&S^d=@{(PLiNXS zm1ITttkAfUQ*AJnLIE4D_Zjp=YUEzmNwmq(ri3NE*XSdF^Dlv^TK_oSGQvQuY&i(X zvz|ahbaG+>ji-x`vqH<8jkBV3mpwrgn5sU!1+R4!lB(v(28QrzEw*W*Kvxu&9^(g8 z3wzt358%6>+2crlVG|oI;VFr+I>X#UHN+slw~e18yeiczH=Q&h?IQv=1(=HK_OL?N z8esZRJ9%@pfxeF|`;7X7kG*@KNrx1Up^y_f(}%+QpJLwlF>A3GDGEyk7Z7A&tH&)9 zGZ@ta4edwh;O%0XiQp{8XS1*-R9!cX5u@-{TT4o`RPgC@)E-vL>zl<>Rar*Qw@c~8 zlFifF<>%&Tm0Q=>&;Fk^a@YpH(y%p!E@=vX;9c2clD0#e~7?+G%>`kVe5Pwmh0u0Z;-Y!^`Vr< zvzDku2#)8IHwW=R)P<%9u~=>5xo40meq5mS6X*|2&wE{*k+YN{o^UeqyqU&;P9Lyq z&A8=ro#o2P9QeA~z^p?L#{x9yf!ka;3wj~>F+*b9O0`)&>JhsHPNJ<~4dAhM!Xx%{ zrx)=vq*YH%G~>l1U$I-cKv6-nzR7`ABBPVONUS3hV3^`5#<4oo*Uh+87|yGB@gO&R z_`BL%Z_wN$X*d#BBiMNsb>qnjOIMx@_FrUSY|0ZiGBls!InsCvpl zSt{+RX0=hww9oZ)pPLV`y9<9{eAW@h9ki7?9h;k9y0|Umg*C5_&L9bT55FG}f-@7= z(3wnkJSn6rhj4Um(AW5W(Zuyc>%nX*YwcGVRn?i2GaG-D2#ioGlQe`OmRll!1o56Q zjN{G(19_3htCJGqo*)WE=WXFNaxrejuMKgZBgkKw*>iR7LFt;2^ropwR;9T{VW7~R zx>(M6GA*KDb#n%~$*Q=tWk$=WuO;w9^0BUPk34%dS#fC7TtqlVfMAA?6@p(q@0H!o zb_0@2aZb@N16?OePY+v7(WIx5FF)vV=d$cnAw-rF79_U6T{RzQ*Ii#{Yi(nmhZ-dVG3&twm6NnLT_ObNI8D z?$%vkPYeUw$NUjCY!W^Cp6|@yc}o_T-55qF>e)6(GFxD-=iHHvpIkS7VmRqrU?VY{ z<$G$<+KA`%bcZMnPAKN&(lDiGW5fK(PFnlmfKk_}y*8diwCu8*dBW22gQevhW(C62 zkGU+iy=cKm ziKR>`jUIR~ZLiCmiAznM<9Ig(2*lq%c55G+bMrv8?Ooa}GH9*RQ-LZ?B|i8xob#2M zy9@Vff2$vFji&NtZ*_Boa~&I?qrtG*{b1K+<(bv+mQ6G}ra=c0>Z7%WD{}d(2ohFk5GQjh zzG|CYxM@?dHVc%Y3IIpv^VG;b1nQo!)`Toj36NwO9`dz~uBTY{v(9Fkl{a)cc?MrL zG&|LOQud7KIzd%k(g7}bA}&&^2WQ2NF@Jz}RF81KID^_gunIaLlk^UXIz!oOwUyj) z^+LFYc3qAMTacfK6krSYC5w+K!}1m7y$P{71~+3rm-PqDl4Ffj7kXRkoE0c}Q6Fxd0JzFy#jzttYd5r2)Z**Zu6(rTZo z#JUzXt2$RM*)J-lT7#v>@uYnyC*q|CF&7pP)ewcxhAv%~I$edQrHQ7{yi#F-c7NS| zA@la$-Q7NgLAu}|gt>6_FXjUDJxnM}C`3dE914mI>O)l?@g@osR3kMM6guRJ`J>5- zDhn}6%84<{ND7O}DT%5m!$CprLhlxPKf1B(@77F&d|m^2VgBHT1=6JH|J=w5$w`Wd zDk(F`iv3qtM4@h}cb#R_Ag{ZgJl_l*4GPNE!Hn7LU+tTijjS!0zr*1QIhZ-yKzfct zkX0sjzgSEDe8^N|&?OF#RYW>u6>*2#{bmv9ztj08FY;&SQSWJQB_Yn!AdBNWyx}*~ z#Q2@_zX`wx_!o0!@ zhJR1zPjwtWOAZQ?S`9$r$qaI8pZy@YiuVVS(f|nI;jh8{HCzbE!^z|yG_9x05;!3f z8UUHlABRax_6M4>7LJY(p4nfjkVyFxNw99gQWHdy9@3lp$6*dr{DI`(TCN5TCVxIm zz3W43S4iMc+V&iDsHDgdkhpYZ&i>iy%7 z2?xtRtNz2>eb2i6(Rl{Pe>nfwO#KEE{_~gldqnFUF87@w`o4YS9bMs@6)F8z>K{V=`&NT@+g{&n#qgJY|DYA+&&U3K`@J874c+878h>wm z{Pz}yKkIy7^?cXN@0;nH{+Z4{OxE`W!*|8g-|U6^F9rTvDfQ1s{(UX$k7vQvYMdv{Mz{+ '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/app/gradlew.bat b/app/gradlew.bat deleted file mode 100644 index 7e60b72..0000000 --- a/app/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/app/iris-scroll.sh b/app/iris-scroll.sh deleted file mode 100755 index 538d9f0..0000000 --- a/app/iris-scroll.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# RUST.md's I5 "Where iris's frame time goes" pass (2026-09-05). The same -# 24-swipe/6-cycle loop as transcript-bench.sh's, extracted for iris's own -# demo app -- transcript-bench.sh itself is Compose-specific (opens by -# session title through the Compose app's own UI) and cannot be called -# directly against dev.iris.android.demo. -# -# MUST be run from inside this checkout (not /tmp): ui-trace/adb pick which -# emulator to target from the current directory's basename (the -# per-checkout-AVD rule), and a previous pass lost two attempts to a `cd` -# into /tmp that made this resolve to a nonexistent "tmp" checkout. -set -eu -cd "$(dirname "$0")" -. ./android-env.sh >/dev/null 2>&1 - -cycles=${1:-6} - -ui-trace record -d 3000 --do "tap 'Reset frame report'" -o /tmp/iris-bench-reset.txt >/dev/null -adb logcat -c - -DO="" -i=0 -while [ "$i" -lt "$cycles" ]; do - DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'" - i=$((i + 1)) -done -eval ui-trace record -d $((cycles * 16000 + 20000)) $DO -o /tmp/iris-bench-scroll.txt >/dev/null - -ui-trace record -d 3000 --do "tap 'Frame report'" -o /tmp/iris-bench-report.txt >/dev/null -sleep 1 -adb logcat -d -s iris-android-app:I | grep "iris frame report:" diff --git a/app/run-android.sh b/app/run-android.sh deleted file mode 100755 index 3b65dae..0000000 --- a/app/run-android.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/sh -# Builds this app and runs it on this checkout's emulator. -# -# The emulator half of this -- which AVD this checkout means, creating it, -# booting it headless, and refusing to start one the machine has no room for -# -- lives in ~/repos/emulator-tools and is shared with every other Android -# checkout here. This script kept its own copy of that sequence until -# 2026-08-30, as did dev-updater's and ai-app's, and three copies of "boot an -# emulator" is three places for the memory check that was missing from all of -# them. -# -# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh, -# which can also be sourced directly for one-off commands. -set -eu - -APP_ID="com.example.aiapp" - -SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -cd "$SCRIPT_DIR" - -# shellcheck source=./android-env.sh -. ./android-env.sh - -if ! command -v emu >/dev/null 2>&1; then - echo "run-android.sh: no 'emu' command." >&2 - echo " It comes from ~/repos/emulator-tools; run that repo's ./install.sh." >&2 - exit 127 -fi - -# Prints the serial, having created and booted the AVD if it had to. Named -# after the checkout, so this cannot land on another session's emulator -- -# and refuses rather than starting one when the machine is short of memory, -# because what an OOM kills is somebody else's work rather than the emulator -# that asked for the memory. -echo "==> Emulator" -SERIAL=$(emu up) -export ANDROID_SERIAL="$SERIAL" - -echo "==> Building debug APK" -./gradlew :androidApp:assembleDebug - -APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk" -echo "==> Installing and launching $APK" -# ANDROID_SERIAL above is what aims these; the adb wrapper would work it out -# from the checkout anyway, but a script that says which device it means does -# not depend on being run from the right directory. -adb install -r "$APK" -adb shell am start -n "$APP_ID/.MainActivity" diff --git a/app-rust/run-bench.sh b/app/run-bench.sh similarity index 93% rename from app-rust/run-bench.sh rename to app/run-bench.sh index 4e2b9bc..b489478 100755 --- a/app-rust/run-bench.sh +++ b/app/run-bench.sh @@ -1,15 +1,15 @@ #!/bin/sh # Installs and runs the iris `bench` build on this checkout's own emulator # (per this-machine-android's per-checkout-AVD rule; `emu serial` picks it) -# and prints the report -- the iris half of `app/transcript-bench.sh`'s -# job. No coordinates: the button is found by its accessibility label -# through `ui-trace`, per AGENTS.md's "Driving the UI". +# and prints the report. No coordinates: the button is found by its +# accessibility label through `ui-trace`. # # Usage: ./run-bench.sh [--apk PATH] # Defaults to this checkout's own release APK # (android-project/app/build/outputs/apk/release/app-release.apk) if it # exists, else the -# debug one -- build one first with ./build-apk.sh. +# debug one. Build it first with: +# ./build-apk.sh release --features "transcript-screen bench" set -eu cd "$(dirname "$0")" diff --git a/app-rust/rust-toolchain.toml b/app/rust-toolchain.toml similarity index 100% rename from app-rust/rust-toolchain.toml rename to app/rust-toolchain.toml diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts deleted file mode 100644 index 71bf9cf..0000000 --- a/app/settings.gradle.kts +++ /dev/null @@ -1,30 +0,0 @@ -rootProject.name = "AiApp" - -pluginManagement { - repositories { - google() - gradlePluginPortal() - mavenCentral() - } -} - -dependencyResolutionManagement { - repositories { - google() - mavenCentral() - } -} - -include(":androidApp") - -// E3 (RUST.md): the Kotlin/Java shell over android-shell's JNI bridge, a -// separate module from :androidApp so the ~13,000 lines of working Compose -// UI there are untouched. See shellApp/build.gradle.kts's module comment. -include(":shellApp") - -// The app half of wg-app-link, resolved by path through the submodule so -// this checkout and the crate it consumes move together -- the same -// arrangement `server/` uses for the Rust half. See that repo's README. -include(":link") - -project(":link").projectDir = file("../wg-app-link/app") diff --git a/app/shellApp/build.gradle.kts b/app/shellApp/build.gradle.kts deleted file mode 100644 index e595f97..0000000 --- a/app/shellApp/build.gradle.kts +++ /dev/null @@ -1,163 +0,0 @@ -plugins { alias(libs.plugins.androidApplication) } - -// E3 (RUST.md): the Kotlin/Java shell being replaced by a thin JNI bridge -// into Rust (`../../app-rust`, the `shell` feature). Deliberately its own module rather -// than a rewrite of `:androidApp` in place -- that module is ~13,000 lines -// of working Compose UI this experiment does not touch, and the two can be -// installed side by side on the same development device (see -// `settings.SCHEME`'s doc in `app-rust/src/shell` for why the deep-link scheme -// and Keystore alias are not the production app's). No Compose plugin, no -// Kotlin source of its own: `MainActivity`/`NotificationService` are plain -// Java, and the CA constant below is generated as Java too. -// -// The CA this build pins is baked in the same way `androidApp`'s does -- -// see that module's `build.gradle.kts` comment for the reasoning (the -// trust boundary follows the machine that builds, never a pasted copy). -// `PinnedCa.java`'s package must match `app-rust/src/shell`'s -// `settings::load_pinned_ca` lookup (`com/example/aiapp/shell/PinnedCa`). -val pinnedCaPath: String = - System.getenv("AI_APP_CA") - ?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" + - "/ai-app/certs/ca.pem" - -abstract class GeneratePinnedCa : DefaultTask() { - @get:Input abstract val caPath: Property - - @get:InputFile - @get:Optional - @get:PathSensitive(PathSensitivity.NONE) - abstract val caCertificate: RegularFileProperty - - @get:OutputDirectory abstract val outputDir: DirectoryProperty - - @TaskAction - fun generate() { - val path = caPath.get() - val ca = File(path) - if (!ca.isFile) { - throw GradleException( - "No CA certificate at $path.\n" + - "Start ai-server (or app/ui-sandbox.sh) once on this machine first -- it " + - "generates the CA this build pins.\n" + - "Set AI_APP_CA=/path/to/ca.pem to build against a different one." - ) - } - val pem = ca.readText().trim() - if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) { - throw GradleException("$path is not a PEM certificate.") - } - val dir = outputDir.get().dir("com/example/aiapp/shell").asFile - dir.mkdirs() - // Same reasoning as androidApp's generatePinnedCert: the text block - // must start immediately after the opening `"""`, or - // CertificateFactory stops recognising the "-----BEGIN" preamble. - File(dir, "PinnedCa.java") - .writeText( - """ - |// Generated from $path by the generatePinnedCa task. Do not edit. - |package com.example.aiapp.shell; - | - |public final class PinnedCa { - | private PinnedCa() {} - | public static final String PINNED_CA_PEM = ""${'"'} - |$pem""${'"'}; - |} - |""" - .trimMargin() - ) - } -} - -val generatePinnedCa = - tasks.register("generatePinnedCa") { - val ca = file(pinnedCaPath) - caPath.set(pinnedCaPath) - if (ca.isFile) { - caCertificate.set(ca) - } - } - -android { - namespace = "com.example.aiapp.shell" - compileSdk = 37 - - defaultConfig { - applicationId = "com.example.aiapp.shell" - minSdk = 24 - targetSdk = 37 - versionCode = 1 - versionName = "1.0" - } - // Same reasoning and same key as androidApp's (see that module's comment): E5 (RUST.md) - // signs its own, Gradle-free build with this same keystore, and the two can only - // `adb install -r` over each other if they carry the same certificate. - val keystore = System.getenv("AI_APP_KEYSTORE") - signingConfigs { - if (keystore != null) { - create("release") { - storeFile = file(keystore) - storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") - keyAlias = "ai-app" - keyPassword = storePassword - } - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - if (keystore != null) signingConfig = signingConfigs.getByName("release") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - } -} - -// E5 (RUST.md): the xtask dexes and packages this module's Java sources itself, but it does -// not resolve Maven dependencies -- reimplementing a dependency resolver was out of scope for a -// packaging step, so this one task is the single place Gradle still runs in that pipeline. It -// asks the dependency graph for the *post-transform* jars (AARs already unpacked to a classes -// jar, the same artifact type AGP's own dexing task consumes) rather than the raw configuration, -// which would hand back .aar files d8 cannot read directly. -val artifactType = Attribute.of("artifactType", String::class.java) - -tasks.register("printRuntimeClasspathJars") { - description = "Writes the resolved release runtime classpath jars, one per line, for xtask." - val outputFile = layout.buildDirectory.file("xtask/runtime-classpath.txt") - outputs.file(outputFile) - val jars = - configurations - .getByName("releaseRuntimeClasspath") - .incoming - .artifactView { attributes.attribute(artifactType, "android-classes-jar") } - .files - // Captured as a plain FileCollection (not the ArtifactView itself, which the - // configuration cache cannot serialize) so this task is still cacheable. - inputs.files(jars) - doLast { - val file = outputFile.get().asFile - file.parentFile.mkdirs() - file.writeText(jars.joinToString("\n") { it.absolutePath }) - } -} - -androidComponents { - onVariants { variant -> - variant.sources.java?.addGeneratedSourceDirectory(generatePinnedCa, GeneratePinnedCa::outputDir) - } -} - -dependencies { - // The Keystore-sealed enrollment (ServerStore/ServerSettings) -- - // the shell bridge's settings.rs calls into this Kotlin class directly - // over JNI rather than re-sealing the token in Rust; see that file's - // module doc. - implementation(project(":link")) - // NotificationCompat/NotificationManagerCompat/NotificationChannelCompat/ - // ServiceCompat -- the shell bridge's notify.rs calls these classes over - // JNI so the pre-26 fallback behaviour (no channels) lives once, in - // the library that already has it, rather than being re-derived as a - // set of Build.VERSION.SDK_INT branches in Rust. - implementation(libs.androidx.core.ktx) -} diff --git a/app/shellApp/src/main/AndroidManifest.xml b/app/shellApp/src/main/AndroidManifest.xml deleted file mode 100644 index b40a7f7..0000000 --- a/app/shellApp/src/main/AndroidManifest.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java b/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java deleted file mode 100644 index 05a1fd1..0000000 --- a/app/shellApp/src/main/java/com/example/aiapp/shell/MainActivity.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.aiapp.shell; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.widget.Toast; - -/** - * E3's floor, per RUST.md's "How much Java is unavoidable": a class the framework - * constructs by name from the manifest, with its lifecycle methods handing straight to Rust - * (android-shell's {@code share::handle_intent}). No Compose, no layout -- there is no screen to - * draw yet (that is E4's job, on iris); {@link #toast} is this experiment's stand-in for showing - * something happened. - */ -public class MainActivity extends Activity { - static { - System.loadLibrary("ai_app"); - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - NotificationService.sync(this); - nativeHandleIntent(this, getIntent()); - } - - // launchMode="singleTop": a notification tap or a share while this activity is already on - // top lands here rather than in a second instance -- same reasoning as MainActivity.kt's. - @Override - protected void onNewIntent(Intent intent) { - super.onNewIntent(intent); - setIntent(intent); - nativeHandleIntent(this, intent); - } - - /** - * Called from android-shell, sometimes from a background thread (a share's network call is - * never made on the calling thread -- see share.rs). {@code Toast} itself is main-thread-only, - * so this hops there with a {@link Handler} rather than assuming the caller already has. - */ - static void toast(Context context, String message) { - new Handler(Looper.getMainLooper()) - .post(() -> Toast.makeText(context, message, Toast.LENGTH_LONG).show()); - } - - private static native void nativeHandleIntent(Activity activity, Intent intent); -} diff --git a/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java b/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java deleted file mode 100644 index b0923b7..0000000 --- a/app/shellApp/src/main/java/com/example/aiapp/shell/NotificationService.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.example.aiapp.shell; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.IBinder; - -/** - * E3's second unavoidable Java class (RUST.md): a foreground service constructed by the framework - * from the manifest, existing only to hand its lifecycle to android-shell's {@code notify} module - * -- the SSE follow loop, deciding what a notification says, and posting it are all Rust reached - * through these three native calls. See {@code Notifications.kt}'s {@code NotificationService} for - * the Kotlin original this mirrors. - */ -public class NotificationService extends Service { - static { - System.loadLibrary("ai_app"); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - return nativeOnStartCommand(this); - } - - @Override - public void onDestroy() { - nativeOnDestroy(); - } - - /** Starts this service if there is a server to connect to, and stops it otherwise. */ - static void sync(Context context) { - nativeSync(context); - } - - private static native void nativeSync(Context context); - - private static native int nativeOnStartCommand(Service service); - - private static native void nativeOnDestroy(); -} diff --git a/app-rust/src/android/app_log.rs b/app/src/android/app_log.rs similarity index 100% rename from app-rust/src/android/app_log.rs rename to app/src/android/app_log.rs diff --git a/app-rust/src/android/bench_client.rs b/app/src/android/bench_client.rs similarity index 99% rename from app-rust/src/android/bench_client.rs rename to app/src/android/bench_client.rs index 15ca029..cd4f607 100644 --- a/app-rust/src/android/bench_client.rs +++ b/app/src/android/bench_client.rs @@ -504,7 +504,7 @@ impl BenchClient { rsc.spawn_task(async move |mut ctx| { // The battery sampler runs for the whole run, once a second, - // the same cadence `BatterySampler` uses on the Compose side + // the benchmark's established battery-sampling cadence // -- via its own JNI-attached thread, not `ctx.update`, since // a sample needs no widget-tree access. let sampler_done = Arc::new(AtomicBool::new(false)); diff --git a/app-rust/src/android/bench_jni.rs b/app/src/android/bench_jni.rs similarity index 100% rename from app-rust/src/android/bench_jni.rs rename to app/src/android/bench_jni.rs diff --git a/app-rust/src/android/devlog.rs b/app/src/android/devlog.rs similarity index 100% rename from app-rust/src/android/devlog.rs rename to app/src/android/devlog.rs diff --git a/app-rust/src/android/enrollment.rs b/app/src/android/enrollment.rs similarity index 100% rename from app-rust/src/android/enrollment.rs rename to app/src/android/enrollment.rs diff --git a/app-rust/src/android/mod.rs b/app/src/android/mod.rs similarity index 100% rename from app-rust/src/android/mod.rs rename to app/src/android/mod.rs diff --git a/app-rust/src/android/transcript_client.rs b/app/src/android/transcript_client.rs similarity index 100% rename from app-rust/src/android/transcript_client.rs rename to app/src/android/transcript_client.rs diff --git a/app-rust/src/bin_desktop.rs b/app/src/bin_desktop.rs similarity index 100% rename from app-rust/src/bin_desktop.rs rename to app/src/bin_desktop.rs diff --git a/app-rust/src/client/ansi.rs b/app/src/client/ansi.rs similarity index 98% rename from app-rust/src/client/ansi.rs rename to app/src/client/ansi.rs index c23e806..6e5f25b 100644 --- a/app-rust/src/client/ansi.rs +++ b/app/src/client/ansi.rs @@ -23,8 +23,7 @@ pub struct AnsiPalette { pub background: Rgb, } -/// One span's worth of styling. `None` fields mean "unspecified", the same -/// meaning `Color.Unspecified` and a null `FontWeight` carried in the Kotlin. +/// One span's worth of styling. `None` means unspecified. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct Style { pub color: Option, diff --git a/app-rust/src/client/api.rs b/app/src/client/api.rs similarity index 100% rename from app-rust/src/client/api.rs rename to app/src/client/api.rs diff --git a/app-rust/src/client/config.rs b/app/src/client/config.rs similarity index 96% rename from app-rust/src/client/config.rs rename to app/src/client/config.rs index bb60505..d878d37 100644 --- a/app-rust/src/client/config.rs +++ b/app/src/client/config.rs @@ -5,11 +5,8 @@ use std::path::{Path, PathBuf}; /// `ca_pem` is the trust anchor to pin, when the link carried one (the /// `ca` parameter, `wg_app_link::enroll::ca_param`). It is optional -/// because an app built on the machine its server runs on pins the CA at -/// build time and needs nothing from the link; one built elsewhere -- the -/// iris Android client is cross-compiled in a VM and run against the -/// host's server -- has no other way to get it. A public certificate -/// rather than a secret, so it costs the link nothing but length. +/// for compatibility with older links. Current clients require it before +/// opening a transport. It is a public certificate, not a secret. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EnrolledServer { pub host: String, diff --git a/app-rust/src/client/durations.rs b/app/src/client/durations.rs similarity index 100% rename from app-rust/src/client/durations.rs rename to app/src/client/durations.rs diff --git a/app-rust/src/client/event_stream.rs b/app/src/client/event_stream.rs similarity index 98% rename from app-rust/src/client/event_stream.rs rename to app/src/client/event_stream.rs index 35e15c8..ccab015 100644 --- a/app-rust/src/client/event_stream.rs +++ b/app/src/client/event_stream.rs @@ -22,7 +22,7 @@ pub enum StreamItem { /// Follows `/sessions/{id}/events?after={after}`, calling `on_item` for /// each [`StreamItem`] until the connection drops or `on_item` asks to /// stop (by returning `false`). Reconnecting -- with the last seq seen as -/// the new cursor -- is the caller's job, same as in the Kotlin version. +/// the new cursor -- is the caller's job. pub fn follow_session_events( transport: &dyn Transport, session_id: &str, diff --git a/app-rust/src/client/highlight/languages.rs b/app/src/client/highlight/languages.rs similarity index 100% rename from app-rust/src/client/highlight/languages.rs rename to app/src/client/highlight/languages.rs diff --git a/app-rust/src/client/highlight/markdown.rs b/app/src/client/highlight/markdown.rs similarity index 100% rename from app-rust/src/client/highlight/markdown.rs rename to app/src/client/highlight/markdown.rs diff --git a/app-rust/src/client/highlight/mod.rs b/app/src/client/highlight/mod.rs similarity index 98% rename from app-rust/src/client/highlight/mod.rs rename to app/src/client/highlight/mod.rs index 8fbf4e1..455b9a4 100644 --- a/app-rust/src/client/highlight/mod.rs +++ b/app/src/client/highlight/mod.rs @@ -39,11 +39,8 @@ pub fn spans_of(code: &str, language: Language) -> Vec { } } -/// `code` read into the spans [`Rules`] describes. Also reachable directly -/// for a caller that already has a [`Rules`] (there is currently only one: -/// [`spans_of`]), kept public because the Kotlin original exposed it the -/// same way. -pub fn scan(code: &str, rules: &Rules) -> Vec { +/// Read `code` into the spans described by `rules`. +fn scan(code: &str, rules: &Rules) -> Vec { Scanner::new(code, rules).run() } diff --git a/app-rust/src/client/log_ring.rs b/app/src/client/log_ring.rs similarity index 100% rename from app-rust/src/client/log_ring.rs rename to app/src/client/log_ring.rs diff --git a/app-rust/src/client/markdown_blocks.rs b/app/src/client/markdown_blocks.rs similarity index 100% rename from app-rust/src/client/markdown_blocks.rs rename to app/src/client/markdown_blocks.rs diff --git a/app-rust/src/client/mod.rs b/app/src/client/mod.rs similarity index 100% rename from app-rust/src/client/mod.rs rename to app/src/client/mod.rs diff --git a/app-rust/src/client/notifications.rs b/app/src/client/notifications.rs similarity index 100% rename from app-rust/src/client/notifications.rs rename to app/src/client/notifications.rs diff --git a/app-rust/src/client/sse.rs b/app/src/client/sse.rs similarity index 100% rename from app-rust/src/client/sse.rs rename to app/src/client/sse.rs diff --git a/app-rust/src/client/text_cap.rs b/app/src/client/text_cap.rs similarity index 100% rename from app-rust/src/client/text_cap.rs rename to app/src/client/text_cap.rs diff --git a/app-rust/src/client/tool_summary.rs b/app/src/client/tool_summary.rs similarity index 100% rename from app-rust/src/client/tool_summary.rs rename to app/src/client/tool_summary.rs diff --git a/app-rust/src/client/transcript_cache.rs b/app/src/client/transcript_cache.rs similarity index 100% rename from app-rust/src/client/transcript_cache.rs rename to app/src/client/transcript_cache.rs diff --git a/app-rust/src/client/transcript_fold.rs b/app/src/client/transcript_fold.rs similarity index 99% rename from app-rust/src/client/transcript_fold.rs rename to app/src/client/transcript_fold.rs index 36139b7..3b79377 100644 --- a/app-rust/src/client/transcript_fold.rs +++ b/app/src/client/transcript_fold.rs @@ -122,9 +122,7 @@ impl TranscriptItem { } } -/// The run a call joins: the one it lands next to, or a new one named -/// after itself. See the Kotlin `runIdFor`'s doc for why the name, once -/// picked, never changes. +/// The run a call joins: the adjacent compatible run, or a new stable id. fn run_id_for(items: &[TranscriptItem], id: &str, tool: &str) -> String { let Some(TranscriptItem::ToolRun { run_id, @@ -674,7 +672,7 @@ pub enum ToolState { /// Stopped on the reader: a permission or question this call carries /// has not been answered, so nothing is happening until somebody /// answers it. Distinct from [`Self::Running`] because whose move it - /// is differs, which is the Compose card's "your turn". + /// is differs; the UI labels this state "your turn". Deciding, Succeeded, Failed, diff --git a/app-rust/src/client/transcript_source.rs b/app/src/client/transcript_source.rs similarity index 97% rename from app-rust/src/client/transcript_source.rs rename to app/src/client/transcript_source.rs index 289de4f..cf188b0 100644 --- a/app-rust/src/client/transcript_source.rs +++ b/app/src/client/transcript_source.rs @@ -6,8 +6,7 @@ use crate::client::transcript_cache::SessionCache; /// The server's own default page size, named here because the cached /// opening has to be the same size as the fetched one -- a reader must not -/// get a shorter first screen for having been here before (`OPENING_WINDOW` -/// in the Kotlin original). +/// get a shorter first screen for having been here before. pub const OPENING_WINDOW: u32 = 80; /// A transcript-line parse failure, told apart from [`ApiError`] so a @@ -189,10 +188,7 @@ impl TranscriptSource { result } - /// Leaves the cache with everything it was given -- called once a - /// caller is done with this source, mirroring the Kotlin `close`'s - /// final flush (that method's stream cancellation itself is the - /// runtime concern the module doc says is not ported here). + /// Flushes everything this source has given the cache. pub fn close(&self) { self.cache.flush(); } diff --git a/app-rust/src/desktop/app.rs b/app/src/desktop/app.rs similarity index 100% rename from app-rust/src/desktop/app.rs rename to app/src/desktop/app.rs diff --git a/app-rust/src/desktop/config.rs b/app/src/desktop/config.rs similarity index 100% rename from app-rust/src/desktop/config.rs rename to app/src/desktop/config.rs diff --git a/app-rust/src/desktop/mod.rs b/app/src/desktop/mod.rs similarity index 100% rename from app-rust/src/desktop/mod.rs rename to app/src/desktop/mod.rs diff --git a/app-rust/src/desktop/startup.rs b/app/src/desktop/startup.rs similarity index 100% rename from app-rust/src/desktop/startup.rs rename to app/src/desktop/startup.rs diff --git a/app/src/lib.rs b/app/src/lib.rs new file mode 100644 index 0000000..65efdb4 --- /dev/null +++ b/app/src/lib.rs @@ -0,0 +1,10 @@ +pub mod client; + +#[cfg(feature = "screens")] +pub mod ui; + +#[cfg(all(feature = "screens", not(target_os = "android")))] +pub mod desktop; + +#[cfg(all(feature = "screens", target_os = "android"))] +pub mod android; diff --git a/app-rust/src/ui/composer.rs b/app/src/ui/composer.rs similarity index 93% rename from app-rust/src/ui/composer.rs rename to app/src/ui/composer.rs index 78e1d12..5411fc1 100644 --- a/app-rust/src/ui/composer.rs +++ b/app/src/ui/composer.rs @@ -17,6 +17,11 @@ pub struct Composer { outer_pad: WeakWidget, } +pub struct BuiltComposer { + pub composer: Composer, + pub widget: WeakWidget, +} + impl Composer { /// Called by the platform shell (Android's `on_insets_changed`, e.g.) /// whenever the space below the bar changes: the IME's own inset while @@ -42,7 +47,7 @@ impl Composer { /// panics ("was already added", `core/src/widget/like.rs:12`) -- the same /// mistake this box's `row.rs` first made with its sender-label header, see /// that file's comment for the fuller account. -pub fn build_composer(rsc: &mut Rsc, theme: &Theme) -> (Composer, WeakWidget) +pub fn build_composer(rsc: &mut Rsc, theme: &Theme) -> BuiltComposer where Rsc::State: FocusHost, { @@ -70,5 +75,8 @@ where let outer_pad: WeakWidget = content.pad(Padding::ZERO).add(rsc); - (Composer { field, outer_pad }, outer_pad) + BuiltComposer { + composer: Composer { field, outer_pad }, + widget: outer_pad, + } } diff --git a/app-rust/src/ui/fixture.rs b/app/src/ui/fixture.rs similarity index 97% rename from app-rust/src/ui/fixture.rs rename to app/src/ui/fixture.rs index 1daac68..d230d77 100644 --- a/app-rust/src/ui/fixture.rs +++ b/app/src/ui/fixture.rs @@ -4,7 +4,7 @@ use iris::prelude::*; pub const BACKLOG_COUNT: usize = 3202; -const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); +const FIXTURE_JSONL: &str = include_str!("../../bench-fixture/assets/transcript.jsonl"); pub const PHONE_WIDTH: f32 = 1080.0; pub const PHONE_HEIGHT: f32 = 2424.0; diff --git a/app-rust/src/ui/markdown.rs b/app/src/ui/markdown.rs similarity index 100% rename from app-rust/src/ui/markdown.rs rename to app/src/ui/markdown.rs diff --git a/app-rust/src/ui/mod.rs b/app/src/ui/mod.rs similarity index 99% rename from app-rust/src/ui/mod.rs rename to app/src/ui/mod.rs index c5c1700..8b262f4 100644 --- a/app-rust/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -297,7 +297,10 @@ where }) .add(rsc); - let (composer, composer_bar) = composer::build_composer(rsc, &theme); + let composer::BuiltComposer { + composer, + widget: composer_bar, + } = composer::build_composer(rsc, &theme); let tree = (list.width(rest(1)).height(rest(1)).masked(), composer_bar) .span(Dir::DOWN) diff --git a/app-rust/src/ui/row.rs b/app/src/ui/row.rs similarity index 97% rename from app-rust/src/ui/row.rs rename to app/src/ui/row.rs index 4d8aa8f..2d74ca1 100644 --- a/app-rust/src/ui/row.rs +++ b/app/src/ui/row.rs @@ -6,7 +6,7 @@ use crate::client::transcript_fold::{ use crate::ui::markdown::{BlockFrame, Link, frame_of, render_block}; use crate::ui::tap::{hold_edge, on_tap}; use crate::ui::theme::Theme; -use crate::ui::tool::{ToolRow, build_tool_row}; +use crate::ui::tool::{BuiltToolRow, ToolRow, build_tool_row}; use iris::prelude::*; use std::{ cell::RefCell, @@ -44,7 +44,7 @@ pub(crate) fn item_content(item: &TranscriptItem) -> (Option<&str>, String) { TranscriptItem::Note { text, .. } => (None, text.clone()), TranscriptItem::ClearedNote { .. } => (None, "_Context cleared._".to_string()), // Epoch seconds as-is until the port has a relative-time formatter - // (P1); the Compose `LimitRow` draws it as a countdown. + // (P1); the eventual limit control draws it as a countdown. TranscriptItem::LimitNote { resets_at, .. } => ( None, match resets_at { @@ -154,6 +154,8 @@ struct RowSource { } #[derive(Clone)] +// Rebuilt markdown replaces link ranges while the retained tap callback keeps +// the same handle. Iris callbacks are single-threaded, so Rc/RefCell is enough. struct LinkTargets(Rc>>); impl LinkTargets { @@ -488,7 +490,8 @@ where }; if let Some(calls) = calls { let key = row_key(&calls[0].key()); - let (widget, tools) = build_tool_row(rsc, list, key, calls.to_vec(), working, theme); + let BuiltToolRow { widget, row: tools } = + build_tool_row(rsc, list, key, calls.to_vec(), working, theme); return BuiltRow { key, widget, diff --git a/app-rust/src/ui/tap.rs b/app/src/ui/tap.rs similarity index 100% rename from app-rust/src/ui/tap.rs rename to app/src/ui/tap.rs diff --git a/app-rust/src/ui/theme.rs b/app/src/ui/theme.rs similarity index 100% rename from app-rust/src/ui/theme.rs rename to app/src/ui/theme.rs diff --git a/app-rust/src/ui/tool.rs b/app/src/ui/tool.rs similarity index 86% rename from app-rust/src/ui/tool.rs rename to app/src/ui/tool.rs index 4e236a7..91af5d7 100644 --- a/app-rust/src/ui/tool.rs +++ b/app/src/ui/tool.rs @@ -31,24 +31,30 @@ struct ToolRowState { whole: HashMap<(String, Part), bool>, } +struct ToolRowData { + calls: Vec, + view: ToolRowState, + working: bool, +} + #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] enum Part { Input, Output, } -struct Shared { - calls: RefCell>, - state: RefCell, +struct ToolRowShared { + // The row owner and `'static` tap callbacks share this model. Callbacks + // are single-threaded but cannot borrow from `ToolRow`, hence Rc/RefCell. + data: RefCell, /// One `WidgetPtr` per call, in order -- what makes a result cost one /// card. Empty while the group is collapsed, because a collapsed group /// draws no cards at all. Its path out is [`build_content`], which /// clears it before building whatever replaces them. cards: RefCell>>, - content: RefCell>>, + content: Cell>>, list: WeakWidget, key: RowKey, - working: Cell, theme: Rc, } @@ -57,7 +63,12 @@ struct Shared { /// [`crate::ui::row::RowBlocks`], and the reason a `ToolEnd` costs one card /// rather than a row. pub struct ToolRow { - shared: Rc, + shared: Rc, +} + +pub struct BuiltToolRow { + pub widget: StrongWidget, + pub row: ToolRow, } fn text(content: impl Into, size: f32, color: PaintId) -> TextBuilder { @@ -115,8 +126,7 @@ fn state_mark(state: ToolState, theme: &Theme) -> Option<(&'static str, PaintId) /// What a screen reader is given for one card, and what a `ui-trace` /// script taps by: the tool, what the call is for, and how it went when -/// that is anything but "fine" -- the same three things the Compose card's -/// own text says, in the order it says them. +/// that is anything but "fine". fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String { let mut name = tool.to_string(); if let Some(title) = parsed.title() { @@ -131,8 +141,7 @@ fn card_label(tool: &str, parsed: &ToolInput, state: ToolState) -> String { name } -/// The heading a group carries, closed or open. Compose's exact wording, -/// because it is also the name every `ui-trace` script taps it by. +/// The heading a group carries, also used as its accessibility label. fn group_label(count: usize) -> String { format!("Called {count} tools") } @@ -145,10 +154,11 @@ fn capped(body: &str, whole: bool) -> (&str, usize, bool) { } } -fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool { +fn wants_whole(shared: &ToolRowShared, id: &str, part: Part) -> bool { shared - .state + .data .borrow() + .view .whole .get(&(id.to_string(), part)) .copied() @@ -161,7 +171,7 @@ fn wants_whole(shared: &Shared, id: &str, part: Part) -> bool { /// "more" tells them apart not at all. fn show_all( rsc: &mut Rsc, - shared: &Rc, + shared: &Rc, index: usize, id: &str, part: Part, @@ -182,8 +192,9 @@ where on_tap(rsc, more, shared.list, move |rsc| { hold_edge(rsc, shared_for_tap.list, shared_for_tap.key); shared_for_tap - .state + .data .borrow_mut() + .view .whole .insert(key.clone(), true); redraw_card(rsc, &shared_for_tap, index); @@ -193,7 +204,7 @@ where fn output_block( rsc: &mut Rsc, - shared: &Rc, + shared: &Rc, index: usize, id: &str, output: &str, @@ -229,11 +240,15 @@ where column.width(rest(1)).add_strong(rsc).any() } -fn build_card(rsc: &mut Rsc, shared: &Rc, index: usize) -> StrongWidget +fn build_card( + rsc: &mut Rsc, + shared: &Rc, + index: usize, +) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { - let call = shared.calls.borrow()[index].clone(); + let call = shared.data.borrow().calls[index].clone(); let TranscriptItem::ToolRun { id, tool, @@ -246,9 +261,11 @@ where return Span::empty(Dir::DOWN).add_strong(rsc).any(); }; let parsed = parse_tool_input(tool, input); - let call_state = ToolState::of(&call, shared.working.get()).expect("matched ToolRun above"); - let open = shared.state.borrow().open.get(id).copied().unwrap_or(false) - || call_state == ToolState::Deciding; + let data = shared.data.borrow(); + let call_state = ToolState::of(&call, data.working).expect("matched ToolRun above"); + let open = + data.view.open.get(id).copied().unwrap_or(false) || call_state == ToolState::Deciding; + drop(data); let mut header = Span::empty(Dir::RIGHT).gap(dp(GAP_DP)); header.push( @@ -346,7 +363,7 @@ where .any() } -fn redraw_card(rsc: &mut Rsc, shared: &Rc, index: usize) +fn redraw_card(rsc: &mut Rsc, shared: &Rc, index: usize) where Rsc::State: FocusHost + OpenUrl, { @@ -360,7 +377,7 @@ where fn build_card_ptr( rsc: &mut Rsc, - shared: &Rc, + shared: &Rc, index: usize, ) -> (StrongWidget, WeakWidget) where @@ -384,19 +401,20 @@ where return; }; let was = for_tap - .state + .data .borrow() + .view .open .get(&id) .copied() .unwrap_or(false); - for_tap.state.borrow_mut().open.insert(id, !was); + for_tap.data.borrow_mut().view.open.insert(id, !was); redraw_card(rsc, &for_tap, index); }); (strong.any(), ptr) } -fn collapse_bar(rsc: &mut Rsc, shared: &Rc) -> StrongWidget +fn collapse_bar(rsc: &mut Rsc, shared: &Rc) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { @@ -421,19 +439,19 @@ where /// Rebuilt whole when the group opens or closes, because that is a change /// of what the row *is* rather than of one card in it. Everything a single /// card's tap does goes through [`redraw_card`] instead. -fn build_content(rsc: &mut Rsc, shared: &Rc) -> StrongWidget +fn build_content(rsc: &mut Rsc, shared: &Rc) -> StrongWidget where Rsc::State: FocusHost + OpenUrl, { shared.cards.borrow_mut().clear(); - let count = shared.calls.borrow().len(); + let count = shared.data.borrow().calls.len(); debug_assert!(count > 0, "a tool row with no calls has nothing to draw"); if count == 1 { return build_card_ptr(rsc, shared, 0).0; } - if !shared.state.borrow().group_expanded { + if !shared.data.borrow().view.group_expanded { let heading = group_label(count); return text(heading.clone(), NAME_SIZE, shared.theme.text.clone()) .pad(dp(CARD_PAD_DP)) @@ -471,20 +489,21 @@ where .any() } -fn toggle_group(rsc: &mut Rsc, shared: &Rc) +fn toggle_group(rsc: &mut Rsc, shared: &Rc) where Rsc::State: FocusHost + OpenUrl, { hold_edge(rsc, shared.list, shared.key); - let was = shared.state.borrow().group_expanded; - shared.state.borrow_mut().group_expanded = !was; + let mut data = shared.data.borrow_mut(); + data.view.group_expanded = !data.view.group_expanded; + drop(data); let content = build_content(rsc, shared); shared.set_content(rsc, content); } -impl Shared { +impl ToolRowShared { fn set_content(&self, rsc: &mut impl UiRsc, content: StrongWidget) { - let Some(ptr) = *self.content.borrow() else { + let Some(ptr) = self.content.get() else { debug_assert!( false, "the row's content pointer is set before anything can tap it" @@ -495,7 +514,7 @@ impl Shared { } fn call_id(&self, index: usize) -> Option { - match self.calls.borrow().get(index) { + match self.data.borrow().calls.get(index) { Some(TranscriptItem::ToolRun { id, .. }) => Some(id.clone()), _ => None, } @@ -517,26 +536,31 @@ pub fn build_tool_row( calls: Vec, working: bool, theme: Rc, -) -> (StrongWidget, ToolRow) +) -> BuiltToolRow where Rsc::State: FocusHost + OpenUrl, { - let shared = Rc::new(Shared { - calls: RefCell::new(calls), - state: RefCell::new(ToolRowState::default()), + let shared = Rc::new(ToolRowShared { + data: RefCell::new(ToolRowData { + calls, + view: ToolRowState::default(), + working, + }), cards: RefCell::new(Vec::new()), - content: RefCell::new(None), + content: Cell::new(None), list, key, - working: Cell::new(working), theme, }); let content_strong = WidgetPtr::new().add_strong(rsc); let content = content_strong.weak(); - *shared.content.borrow_mut() = Some(content); + shared.content.set(Some(content)); let inner = build_content(rsc, &shared); content(rsc).set(inner); - (content_strong.any(), ToolRow { shared }) + BuiltToolRow { + widget: content_strong.any(), + row: ToolRow { shared }, + } } impl ToolRow { @@ -544,7 +568,7 @@ impl ToolRow { /// back to [`Self::apply_calls`] when something other than the calls /// themselves changed (the session's status). pub fn calls(&self) -> Vec { - self.shared.calls.borrow().clone() + self.shared.data.borrow().calls.clone() } #[cfg(test)] @@ -561,7 +585,7 @@ impl ToolRow { where Rsc::State: FocusHost + OpenUrl, { - if self.shared.state.borrow().group_expanded != expanded { + if self.shared.data.borrow().view.group_expanded != expanded { toggle_group(rsc, &self.shared); } } @@ -585,7 +609,7 @@ impl ToolRow { { return false; } - let old = self.shared.calls.borrow().clone(); + let old = self.shared.data.borrow().calls.clone(); if calls.len() < old.len() { return false; } @@ -593,8 +617,6 @@ impl ToolRow { return false; } let changed: Vec = (0..old.len()).filter(|&i| old[i] != calls[i]).collect(); - self.shared.working.set(working); - *self.shared.calls.borrow_mut() = calls.to_vec(); let ids: HashSet = calls .iter() .filter_map(|c| match c { @@ -603,9 +625,11 @@ impl ToolRow { }) .collect(); { - let mut state = self.shared.state.borrow_mut(); - state.open.retain(|id, _| ids.contains(id)); - state.whole.retain(|(id, _), _| ids.contains(id)); + let mut data = self.shared.data.borrow_mut(); + data.working = working; + data.calls = calls.to_vec(); + data.view.open.retain(|id, _| ids.contains(id)); + data.view.whole.retain(|(id, _), _| ids.contains(id)); } if self.shared.cards.borrow().is_empty() { diff --git a/app/stream-bench.sh b/app/stream-bench.sh deleted file mode 100755 index 5535609..0000000 --- a/app/stream-bench.sh +++ /dev/null @@ -1,94 +0,0 @@ -#!/bin/sh -# Streams a message into the sandbox session on screen and prints the app's -# render report for the time it took -- the standard measurement for "is a -# reply that is still arriving cheap", the way transcript-bench.sh is for -# scrolling one that has settled. -# -# ./stream-bench.sh FILE restart the app, open the session the -# sandbox lists first, stream FILE into -# it, report -# ./stream-bench.sh -k FILE keep whatever session is open now -# -# The session is the first one the sandbox lists (ui-sandbox.sh spawn makes -# one), and the file is echoed back a word at a time, which is the shape a -# real reply arrives in. The numbers to read are `markdown reparsed while -# streaming` -- how many times, and how long each -- and the worst -# `record: one block`; both are proportional to how much of the reply the -# tail reparse has to cover, which is what the live parse exists to bound. -set -eu -cd "$(dirname "$0")" -. ./android-env.sh >/dev/null 2>&1 -. ./bench-lib.sh - -keep="" -while getopts k flag; do - case $flag in - k) keep=1 ;; - *) exit 2 ;; - esac -done -shift $((OPTIND - 1)) -file=${1:?usage: stream-bench.sh [-k] FILE} - -sid=$(./ui-sandbox.sh api /sessions | python3 -c 'import json,sys; print(json.load(sys.stdin)[0]["id"])') - -if [ -z "$keep" ]; then - title=$(first_session_title) - [ -n "$title" ] || { echo "stream-bench: no sessions to open" >&2; exit 1; } - open_session "$title" -fi - -# Pinned to the newest end before anything is sent. The transcript never -# moves under a reader who is further back (see TranscriptList), so a reply -# streaming into a session parked at an older row arrives entirely -# off-screen: nothing recomposes, nothing draws, and the report comes back -# with two recompositions in it and no streaming counters at all. That -# reads exactly like a build where the work vanished. The control is the -# app's own "Jump to latest", which is only there while the newest message -# is off screen -- so when it is absent the list is already where it needs -# to be. Looked for before it is pressed rather than pressed blindly: a tap -# by name fails the whole run when its label is missing, and missing is the -# ordinary case here. -ui-trace record -d 1200 -o /tmp/bench-jump.txt >/dev/null 2>&1 -if ui-trace show /tmp/bench-jump.txt -m 'Jump to latest' --field box | grep -q '[0-9],[0-9]'; then - ui-trace record -d 2500 --do "tap 'Jump to latest'" --do 'wait 1000' \ - -o /tmp/bench-tolatest.txt >/dev/null -fi - -# The first copy resets the report's window; see transcript-bench.sh. -copy_render_report /tmp/bench-reset.txt -adb logcat -c - -./ui-sandbox.sh send "$sid" "@$file" >/dev/null - -# Until the reply has finished, measured by the transcript rather than by -# the status. A session is idle at both ends of a turn, and polling for -# "idle" answers on the first poll -- before the turn has even started -- -# so the report then covers the moment between the send and the first -# delta, and prints a window with nothing in it. The event count only -# grows, so "it stopped growing" is the one signal that cannot be true -# before the work begins. -events() { - ./ui-sandbox.sh api "/sessions/$sid/transcript?limit=1" | - python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[-1]["seq"] if d else 0)' -} -last="" -still=0 -i=0 -while [ "$i" -lt 180 ]; do - now=$(events) - if [ "$now" = "$last" ]; then - still=$((still + 1)) - [ "$still" -ge 2 ] && break - else - still=0 - fi - last=$now - sleep 2 - i=$((i + 1)) -done -sleep 1 - -copy_render_report /tmp/bench-report.txt -sleep 1 -adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //' diff --git a/app-rust/tests/catch_a_fling.rs b/app/tests/catch_a_fling.rs similarity index 100% rename from app-rust/tests/catch_a_fling.rs rename to app/tests/catch_a_fling.rs diff --git a/app-rust/tests/fence_fling.rs b/app/tests/fence_fling.rs similarity index 100% rename from app-rust/tests/fence_fling.rs rename to app/tests/fence_fling.rs diff --git a/app-rust/tests/gesture_cancel.rs b/app/tests/gesture_cancel.rs similarity index 100% rename from app-rust/tests/gesture_cancel.rs rename to app/tests/gesture_cancel.rs diff --git a/app-rust/tests/input_log_roundtrip.rs b/app/tests/input_log_roundtrip.rs similarity index 100% rename from app-rust/tests/input_log_roundtrip.rs rename to app/tests/input_log_roundtrip.rs diff --git a/app-rust/tests/phone_screen.rs b/app/tests/phone_screen.rs similarity index 100% rename from app-rust/tests/phone_screen.rs rename to app/tests/phone_screen.rs diff --git a/app-rust/tests/top_edge.rs b/app/tests/top_edge.rs similarity index 100% rename from app-rust/tests/top_edge.rs rename to app/tests/top_edge.rs diff --git a/app-rust/touch/flick-120hz.touch b/app/touch/flick-120hz.touch similarity index 100% rename from app-rust/touch/flick-120hz.touch rename to app/touch/flick-120hz.touch diff --git a/app-rust/touch/flick-cancelled.touch b/app/touch/flick-cancelled.touch similarity index 100% rename from app-rust/touch/flick-cancelled.touch rename to app/touch/flick-cancelled.touch diff --git a/app-rust/touch/long-press.touch b/app/touch/long-press.touch similarity index 100% rename from app-rust/touch/long-press.touch rename to app/touch/long-press.touch diff --git a/app-rust/touch/tap.touch b/app/touch/tap.touch similarity index 100% rename from app-rust/touch/tap.touch rename to app/touch/tap.touch diff --git a/app/trace-draw.sh b/app/trace-draw.sh deleted file mode 100755 index 248b347..0000000 --- a/app/trace-draw.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bash -# What a scrolling frame is actually spending its time in, by name. -# -# The app's own counters can time the code we wrote, and they showed that almost none of the frame -# is that code -- roughly a fortieth of the draw phase. The rest is inside the framework, which -# already brackets its own work with trace sections (measure, layout, draw, the position-callback -# dispatch, the per-node rect bookkeeping, semantics). This turns those on, drives a fling, and adds -# up what each section cost, so "the other eighty percent" gets a name instead of a hypothesis. -# -# atrace's text output rather than perfetto's protobuf on purpose: this needs no trace_processor -# build, and the question here is which sections dominate, which the text format answers directly. -# -# The absolute milliseconds from an emulator are worthless -- it renders in software, and its stock -# apps miss frames as badly as ours do. The *ranking* is what transfers, which is what this prints. -set -euo pipefail - -app=com.example.aiapp -secs=6 -swipes=12 -out=/tmp/ai-app-trace.txt -top=25 -# Empty means whatever `adb` picks by itself, which in this checkout is its own emulator. A phone -# needs naming, and a phone is the only place these numbers mean anything -- see the note at the -# foot of this file. -serial=() - -while [ $# -gt 0 ]; do - case "$1" in - -t) secs=$2; shift 2 ;; - -n) swipes=$2; shift 2 ;; - -o) out=$2; shift 2 ;; - --top) top=$2; shift 2 ;; - -s) serial=(-s "$2"); shift 2 ;; - -h|--help) - echo "usage: $0 [-s serial] [-t seconds] [-n swipes] [-o file] [--top n]" - exit 0 ;; - *) echo "$0: unknown argument $1" >&2; exit 2 ;; - esac -done - -if ! adb "${serial[@]}" shell pidof "$app" >/dev/null 2>&1; then - echo "$0: $app is not running -- open a session in it first" >&2 - exit 1 -fi -pid=$(adb "${serial[@]}" shell pidof "$app" | tr -d '\r') - -# `view` carries Compose's measure/layout/draw and the View system's own; `gfx` carries the render -# thread and the frame boundaries. Buffer sized for a few seconds of a busy main thread: a fling -# emits a great many sections and a full buffer silently drops the end of the trace. -# -# A blocking capture with the gestures alongside it, rather than atrace's own --async_start / -# --async_dump pair: measured on this emulator, the asynchronous form returns a buffer of -# `entries-in-buffer: 0/0` however long it runs, and an empty trace reads exactly like an app that -# emitted no sections. Blocking, the same categories fill it immediately. -# -# `-a` is the flag the whole thing turns on. Without it atrace records only what the system emits, -# and every section Compose writes -- measure, layout, recomposition -- comes from `android.os.Trace` -# inside the app process, which stays switched off. The result looks like a successful capture and -# answers the question with the framework's half of the frame, which is not the half being asked -# about. -adb "${serial[@]}" shell atrace -a "$app" -b 65536 -t "$secs" -c view gfx input 2>/dev/null | tr -d '\r' >"$out" & -capture=$! - -for _ in $(seq "$swipes"); do - adb "${serial[@]}" shell input swipe 540 1800 540 700 80 >/dev/null 2>&1 -done -wait "$capture" - -if ! grep -q tracing_mark_write "$out"; then - echo "$0: the trace holds no sections; another capture may hold the ftrace buffer" >&2 - exit 1 -fi - -python3 - "$out" "$pid" "$top" <<'PY' -import collections, re, sys - -path, pid, top = sys.argv[1], sys.argv[2], int(sys.argv[3]) -# ftrace text: "- () [cpu] flags : tracing_mark_write: B||" -mark = re.compile(r"^\s*\S+-(\d+)\s+\(\s*(\d+|-+)\)[^:]*?\s+(\d+\.\d+):\s+tracing_mark_write:\s+(.*)$") -stacks = collections.defaultdict(list) -total = collections.Counter() -count = collections.Counter() -worst = collections.Counter() -frames = 0 - -for line in open(path, errors="replace"): - m = mark.match(line) - if not m: - continue - tid, owner, ts, body = m.group(1), m.group(2), float(m.group(3)), m.group(4) - parts = body.split("|") - if parts[0] == "B" and len(parts) >= 3: - if parts[1] != pid: - continue - stacks[tid].append((parts[2], ts)) - elif parts[0] == "E": - if not stacks[tid]: - continue - name, began = stacks[tid].pop() - ms = (ts - began) * 1000.0 - total[name] += ms - count[name] += 1 - worst[name] = max(worst[name], ms) - if name.startswith("Choreographer#doFrame"): - frames += 1 - -if not total: - print("no sections for pid " + pid + " -- was the app in the foreground?") - raise SystemExit(1) - -print(f"{frames} frames traced, {sum(count.values())} sections") -print() -print(f"{'section':<44}{'calls':>7}{'total ms':>10}{'mean':>8}{'worst':>8}") -for name, ms in total.most_common(top): - n = count[name] - label = name if len(name) <= 43 else name[:40] + "..." - print(f"{label:<44}{n:>7}{ms:>10.1f}{ms/n:>8.2f}{worst[name]:>8.1f}") -left = len(total) - top -if left > 0: - print(f"... {left} more sections not shown (--top to raise the limit)") -PY - -# A note on where to run this. -# -# Not here. Measured on this checkout's emulator, a scrolling frame is 15ms of `Drawing` of which -# 10ms is `dequeueBuffer` and `postAndWait` -- the main thread blocked on the buffer queue, because -# the emulator renders in software -- while Compose's own `AndroidOwner:draw` is 0.40ms. The -# ranking that comes out is the ranking of the emulator's graphics stack, and it says nothing about -# a phone whose whole draw phase is 3.6ms. Point it at the device the numbers came from. diff --git a/app/transcript-bench.sh b/app/transcript-bench.sh deleted file mode 100755 index a72fdd7..0000000 --- a/app/transcript-bench.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/sh -# Scrolls the transcript and prints the app's own render report -- the -# standard measurement for "is the transcript smooth", so a perf session -# starts from a number instead of re-inventing this loop in /tmp. -# -# ./transcript-bench.sh restart the app, open the session the -# server lists first, scroll, report -# ./transcript-bench.sh -s TITLE open that session instead -# ./transcript-bench.sh -k keep whatever screen is open now -# ./transcript-bench.sh -n 4 swipe cycles (default 6; one cycle is -# two swipes back and two forward) -# -# What it prints is the report the in-app copy button produces, plus any -# duplicate-key warnings the flatten logged. Absolute frame times from the -# emulator are worthless (it renders in software); what transfers is the -# report's own accounting -- what was on screen, and where the draw phase -# went. Compare two runs of this with the same gestures, not one run -# against a phone. -# -# The swipes are still coordinates, because a gesture across a scrolling -# area is a distance rather than a control. Every *control* is pressed by -# name -- see bench-lib.sh. -set -eu -cd "$(dirname "$0")" -. ./android-env.sh >/dev/null 2>&1 -. ./bench-lib.sh - -keep="" -cycles=6 -session="" -while getopts kn:s: flag; do - case $flag in - k) keep=1 ;; - n) cycles=$OPTARG ;; - s) session=$OPTARG ;; - *) exit 2 ;; - esac -done - -if [ -z "$keep" ]; then - [ -n "$session" ] || session=$(first_session_title) - [ -n "$session" ] || { echo "transcript-bench: no sessions to open" >&2; exit 1; } - open_session "$session" -fi - -# The first copy resets the report's "work since last copied" window, so the -# numbers cover exactly the swipes between the two. -copy_render_report /tmp/bench-reset.txt -adb logcat -c - -DO="" -i=0 -while [ "$i" -lt "$cycles" ]; do - DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 700 540 1600 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'" - DO="$DO --do 'swipe 540 1600 540 700 200' --do 'wait 500'" - i=$((i + 1)) -done -eval ui-trace record -d $((cycles * 4400 + 2000)) $DO -o /tmp/bench-scroll.txt >/dev/null 2>&1 - -copy_render_report /tmp/bench-report.txt -sleep 1 -adb logcat -d -s ai-app:I | sed -n '/ai-app render report/,$p' | sed 's/^.*ai-app : //' -adb logcat -d 2>/dev/null | grep -i "duplicate unit key" | head -3 || true diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index 789ef47..6b9e92d 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -405,10 +405,7 @@ done # token with '+' in it enrols as one with a space, and nothing reports it. enc=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TOKEN") # The CA rides in the link (`wg_app_link::enroll::ca_param`: base64url of -# the DER, which needs no percent-encoding). The Compose app ignores it and -# pins the copy its APK was built with; the iris app has no baked copy at -# all -- it is cross-compiled and could be pointed at any machine -- so -# without this it enrols and then trusts nothing. Minted here rather than by +# the DER, which needs no percent-encoding). Minted here rather than by # `--enroll-link` because this token is the sandbox's own, carried across # restarts so the emulator stays enrolled (see the top of this file). ca=$(python3 - "$CERTS/ca.pem" <<'CA' diff --git a/docs/CLIENT_CORE.md b/docs/CLIENT_CORE.md index 16c11d0..099e37a 100644 --- a/docs/CLIENT_CORE.md +++ b/docs/CLIENT_CORE.md @@ -1,6 +1,6 @@ -# `app-rust`'s `client` module +# `app`'s `client` module -`app-rust/src/client` contains platform- and UI-independent client logic. It +`app/src/client` contains platform- and UI-independent client logic. It must not depend on iris; a `use iris::` below this directory is a layering defect. `event-model` remains a separate crate because the server and client both depend on that wire contract. @@ -71,4 +71,4 @@ shared non-UI consumer needs them. Run `./scripts/run-tests.sh` from the repository root. For this crate alone, run `cargo test`, `cargo clippy --all-targets`, and `cargo fmt --check` from -`app-rust/`. +`app/`. diff --git a/docs/IRIS_TODO.md b/docs/IRIS_TODO.md index 1bd9271..cdb4565 100644 --- a/docs/IRIS_TODO.md +++ b/docs/IRIS_TODO.md @@ -16,16 +16,16 @@ Framework capabilities needed by `RUST.md`'s port plan: - [ ] **Expose the distance from a `LazySpan` viewport to its unloaded edge.** (**P1**.) `viewport_len` and the visible extents are already measured internally, but a paging caller cannot ask whether it is within - the Compose app's six-viewport `HISTORY_SCREENS` cushion. The API should + the product's six-viewport `HISTORY_SCREENS` cushion. The API should answer in pixels or viewport multiples, never rows: a row ranges from one line to a screen, so a fixed row count is not a distance. - [ ] **Let an image fit a bounded box while preserving its aspect ratio.** (**P1**.) `Image` currently always reports and draws the decoded texture's natural pixel size. Decoding and fetching a server-produced attachment - belong in `app-rust`; iris only owes the generic fit/scale widget used to + belong in `app`; iris only owes the generic fit/scale widget used to draw its thumbnail. - [ ] **Per-range backgrounds for rich text.** (**P1**.) Inline code is - already monospace and coloured, but matching Compose's chip also needs + already monospace and coloured, but the inline-code chip also needs the glyph run's boxes so a surface can be drawn behind exactly that byte range. The shared `TextSelection` engine already computes the same geometry for selection highlights; expose one shared primitive rather than giving @@ -53,7 +53,7 @@ Framework capabilities needed by `RUST.md`'s port plan: pay nothing and import nothing for them. - [ ] **Remove `WidgetView` unless a real composite adopts it.** Every - composite in `app-rust/src/ui` uses ordinary child handles plus a root; + composite in `app/src/ui` uses ordinary child handles plus a root; `WidgetView` and its derive are used only by `iris/examples/view.rs`. It currently demonstrates itself rather than shortening production code. diff --git a/docs/PLAN.md b/docs/PLAN.md index c28c664..0e6d700 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -6,8 +6,8 @@ need a front end at all, and owning the client means fixing what the official app gets wrong (it won't deliver a typed message until the turn fully finishes, where the TUI injects it at the next tool boundary). -Same shape as `../dev-updater`: a Rust (Axum) backend on the desktop, a -Kotlin/Compose Android app, pinned self-signed TLS between them. +Same shape as `../dev-updater`: a Rust/Axum backend on the desktop and a +shared Rust client for Android and desktop, with pinned self-signed TLS. This file records decisions with their date, their rationale, and what was rejected. Update it in place when one changes; `AGENTS.md` is the working @@ -23,7 +23,7 @@ screens). SSH falls out of the same shape: a remote session is the identical command wrapped in `ssh host …`, and the driver never learns which it got. ``` -Android app (Compose) +Android or desktop app (Rust/iris) │ HTTPS (pinned CA) — REST for actions, SSE for live events ▼ backend (Rust/Axum, desktop) @@ -702,15 +702,10 @@ ssh case are one implementation. a hardcoded IP. The CA is created once and left alone; the leaf is reissued every start, so covering a new address is a restart. **Regenerating the CA strands the installed app** — the one-way door. - - Unlike dev-updater, the pinned CA is **not a constant in the source**: - the build reads `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` from the machine - doing the build and generates the constant (`generatePinnedCert` in - `app/androidApp/build.gradle.kts`; `AI_APP_CA` overrides). That does - three things at once — the trust anchor follows the build machine, so an - APK built in the dev VM is only good for its emulator; there is no second - anchor to add for development and forget to remove; and regenerating a CA - needs a rebuild rather than a paste, so a stale constant cannot quietly - disagree with the server. + - The enrollment link carries the CA certificate. `app/src/client/config.rs` + decodes it and the transport pins it for every connection. A malformed or + absent CA refuses enrollment rather than silently weakening TLS. This lets + one APK enroll against either the host or an isolated development server. - **The dev VM is untrusted** (2026-08-25): not malicious, but it could become so. The repo is a read-write mount shared between the VM and the backend host, so everything in it — source, binaries, and the shell scripts @@ -797,8 +792,10 @@ ssh case are one implementation. ## App (`app/`) -Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as -dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). +One Rust crate owns platform-free client logic and Iris widget trees. Android +and desktop entry points contain only their host integration. Android is +packaged by a thin Java activity under `android-project/`; it does not contain +a second UI implementation. 1. **Session list** — kind icon, title, setup, model, status, last activity. Sessions awaiting an answer sort to the top: the "your turn" inbox. diff --git a/docs/RUST.md b/docs/RUST.md index 54c4993..dc6fc3a 100644 --- a/docs/RUST.md +++ b/docs/RUST.md @@ -1,906 +1,198 @@ -# Moving the app to Rust - -Plan for a native Rust phone app with full feature parity and a shared desktop -UI. It uses [iris](https://github.com/cat16/iris); platform-specific entry -points are acceptable, but shared screens, widgets, and styling are not -duplicated. The result must stay lightweight and preserve native behavior and -performance. - -## Keep this file current as you work - -Keep open work, current design, measured constraints, and dead ends that would -otherwise be repeated. Delete completed plans and migration narratives. Name -the command and measured value when evidence matters. - -## Current status - -- **The framework is decided and built on.** iris draws the transcript - screen on the desktop, on this checkout's emulator and on Iris's phone. -- **P0 (the phone benchmark gate) passed** -- both apps ran on her own - phone and the reports are under `docs/bench/`. -- **P1 (session screen parity) is the current work**, and is where the - next session should start. Its box below has the state. -- The port is one crate under `app-rust/`; `iris/` is only the UI framework. -- **Open across the rest of the docs**: `docs/IRIS_TODO.md` is iris's own - list; `docs/TODO.md` is the Compose app's. - -## Desktop and phone share the code - -Iris plans to develop a desktop app as well, and asked that most code be -sharable between desktop and phone. The tree already has that -shape -- `iris` and `app-rust`'s `client` and `ui` modules are -platform-free, and `src/android`/`src/desktop` are the entry points -- -so the rule is about keeping it: **a platform module holds only what the -platform forces.** Today that is JNI, the IME and insets bridge, the -surface lifecycle and the bench JNI on Android; winit, argv and the -config file on the desktop. **What differs is the screen layout**, since a phone -screen with a finger and a desktop screen with a mouse want different -arrangements -- a session list beside the transcript rather than a -screen behind it, hover states, keyboard shortcuts. **What does not -differ is everything a layout is built from**: the widgets (a tap -button, a text field, a list, a card, a tool-call row), gestures, -folding, paging, selection, and the styling -- colours, spacing, type, -the surface ladder -- which is the exact same code on both, never a -desktop palette beside a phone one. Those are written once in a shared -module, with a platform trait underneath when a behaviour genuinely -differs (`FocusHost`, `OpenUrl`, and the insets/`ime_visible` -feed are the existing examples). Two checks before finishing a change -under `iris/`: does `ai-app-desktop` still build and run with it, and is -any UI logic newly in `src/android` that a desktop would also need? -The bench client (`app-rust/src/android/bench_client.rs`, ~1000 lines) is -the first thing to look at moving, since a desktop bench on the same -fixture is layer 2 of the test rig below. - -## Three test layers, cheapest first (decided 2026-09-07) - -Iris's suggestion, adopted and layered: test at the cheapest layer that -can answer the question, and go up only when it cannot. The emulator -costs minutes a cycle; the desktop window seconds; the headless harness -runs inside `cargo test`. - -1. **Headless, in-process, no compositor and no GPU -- the default.** - `iris::harness` (`iris/src/harness.rs`), plus the fixture crate it - opens. `Harness::new(size, density)` builds an `Rsc` whose `Ui` owns - the retained render state, and a state whose `FocusHost`/`OpenUrl` - *record* what - the platform was asked for; `frame(t_ms)`/`frames_until(..)` run - frames on a clock the test owns, and `replay(&TouchScript)` feeds a - recorded gesture one sample at a time exactly as - `IrisViewPeer::on_touch_event` replays Android's historical samples. - The recordings are plain `t_ms action x y` files under - `app-rust/touch/`, and `flick-120hz.touch` is the - phone's own shape: DOWN, four samples 4ms apart, UP, 20ms in total. - - cd app-rust && cargo test - - runs in about a second and asserts (a) the flick releases with a real - velocity (`List::fling_velocity`, which only `Released(Some(v))` - fills), (b) the list travels and settles inside the AOSP spline's own - `FlingCalculator::duration`, (c) a tap moves nothing and opens no - link, (d) a long-press-then-drag leaves selected text and does not - pan, and (e) the composer clears a simulated 1000px IME inset - (`Composer::set_bottom_inset`). Each was confirmed to fail without - its subject rather than assumed: dropping `animate(id)` from - `SelectionController::drag` -- the phone's own "fling does nothing" defect -- - and starting the fling curve at the wall clock each fail only the - flick test; flinging on `Tapped` fails only the tap test; a 5s - `LONG_PRESS` fails only the selection test; a `set_bottom_inset` that - ignores its argument fails only the composer test. - - **What still cannot be answered below layer 3**: nothing renders - here, so anything about pixels -- glyph rasterisation, the atlas, - stale or duplicated primitives, colour, the surface lifecycle, the - renderer rebuild -- is invisible to layer 1 and only *looked at* in - layer 2. Frame *times* are not measurable at either: layer 1 does no - GPU work at all and layer 2 runs a debug build on this VM's virtio - GPU, so a number from either is not the phone's. Anything JNI (the - IME, real insets, the clipboard, battery) is layer 3 by construction: - layer 1 records that the platform was asked and layer 2 has no - Android platform to ask. - - **The one exception, added 2026-09-08**: `iris/tests/mask_sdf.rs` - needs a GPU but no compositor and no window -- it asks wgpu for an - adapter, runs two functions lifted out of `shader.wgsl` itself in a - compute pass, and compares the answers with the CPU transliteration - in `iris_core::render::sdf`. It sits inside `cargo test` because what - it checks is arithmetic rather than pixels: the fragment stage and - the hit test have to agree about where a rounded edge is, and neither - layer 1 (which cannot run the shader) nor layer 2 (where a - half-pixel disagreement is invisible) can say whether they do. Reach - for this shape only when the question is "do these two - implementations of one function agree" -- anything about what is - *drawn* is still layer 2. - -2. **A phone-shaped desktop window under headless sway -- for looking.** - - cd iris && ./run-headless.sh phone --phone --dir ../app-rust --shot /tmp/p.png - - About 15 seconds warm. `--phone` sets the private sway output to - 1080x2424@120Hz and exports `IRIS_SCALE=2.55`, which reaches iris the - way `DisplayMetrics.density` does on Android - (`iris::default::content_scale`) -- the desktop backend now lays out - in physical pixels with a density instead of dividing into a separate - logical space, so both platforms run one path. `app-rust`'s - `phone` example opens the same screen from the same bytes as layer 1 - and the Android bench. - - A gesture on screen uses the *same recordings*: - - ./run-headless.sh phone --phone --dir ../app-rust \ - --replay ../app-rust/touch/flick-120hz.touch --shot /tmp/p.png - - writes `/tmp/p-before.png` and `/tmp/p.png` either side of the flick; - looked at 2026-09-07, the list moved back about seven turns of the - fixture and settled. - - **`swaymsg seat - cursor` cannot drive it, and that cost an hour.** - This compositor runs the headless backend with no input devices - (`WLR_LIBINPUT_NO_DEVICES=1`, `LIBSEAT_BACKEND=noop`): the cursor - commands all report `success` and nothing whatever reaches the - client, with `swaymsg -t get_seats` showing `capabilities: 0` as the - only sign. wlroots 0.19 dropped `WLR_HEADLESS_INPUTS`, and ydotool's - uinput device would be ignored by a compositor that is not reading - libinput. `iris/rig-input`'s `replay-touch` uses the - **virtual-pointer protocol** instead, which is a client protocol and - needs neither devices nor root, and it parses `iris::harness`'s own - `TouchScript`. Two traps inside it, both found by printing winit's - events: a button sent in the same frame as the motion that first puts - the pointer over the window is dropped (the client sees the enter, - the moves and the *release*, never the press), so the pointer is - positioned and left to settle 200ms first; and a leftover window from - an earlier manual run **tiles beside the new one**, halving the width - and producing a screenshot that looks exactly like a duplicated- - primitive rendering bug -- `swaymsg -t get_tree` and `pgrep -af - examples/phone` are the check. - -3. **The Android emulator -- platform plumbing and the final pass.** - JNI, IME, insets, surface lifecycle, the renderer rebuild, and one - verification run before a build goes to the phone. Not for iterating - on layout. - -## What has to be reproduced - -The app is ~19,000 lines of Kotlin. It splits three ways, and the split is -what decides how much of a port is mechanical. - -**Pure logic with no Compose or Android in it, ~4,500 lines.** `Api.kt` -(1,142), `Events.kt`, `EventStream.kt`, `Sse.kt`, `TranscriptCache.kt` -(589, touches `java.io.File` only), `TranscriptSource.kt`, -`MarkdownSyntax.kt`, `Languages.kt`, `Highlighter.kt`, `Ansi.kt`, -`ResetCountdown.kt`, `Durations.kt`, `Sizes.kt`, `ModelName.kt`, -`LoadState.kt`, `ImportableStream.kt`. `TranscriptUnits.kt` and -`TranscriptItems.kt` (the event fold into rows, ~940 lines) are logic with -a handful of Compose annotations. This is also exactly the code that has -JVM unit tests today. All of it ports directly, and most of it already has a -Rust twin in `server/`: `Events.kt` is a hand-kept mirror of -`session/driver.rs`'s enum, the highlighter and the syntax scanner exist on -the server for the explorer, and the cache compares the server's own JSON -lines. **Sharing these types between server and app is the single largest -"keep things in sync" win available, and it does not depend on which UI -framework wins.** - -**Compose UI, ~13,000 lines.** Screens, dialogs, the transcript list, the -markdown renderer's customisations, tool cards, the file explorer viewer and -editor. This is the part a UI framework choice is about. - -**Android platform code, ~1,500 lines**, spread over 20 files. Every one of -these is a Java-side object that no Rust framework can replace, because -Android only offers them as Java classes: - -- `NotificationService` — a **foreground service** holding the - `/notifications` SSE stream while the app is closed, with its ongoing - notification, `specialUse` type and the `POST_NOTIFICATIONS` request. -- `MainActivity` — edge-to-edge, the `ACCESS_LOCAL_NETWORK` runtime - permission (Android 17), `singleTop` intent routing for `aiapp://enroll`, - notification taps, and the **share sheet** (`ACTION_SEND`, any MIME type). -- `ServerConfig` — the bearer token sealed under an **Android Keystore** - AES-GCM key, shared with Dev Updater through `wg-app-link`'s `:link`. -- `EnrollmentScanActivity` — the in-app **QR scanner** (zxing, camera). -- `Attachments` — `ContentResolver` reads of shared URIs, `BitmapFactory` - decode and downscale, **EXIF** orientation. -- `SessionImage` — bitmap decode for produced images. -- `ScrollAnchor`, `Drafts` — `SharedPreferences`; `CrashLog` — `filesDir`. -- `TranscriptCache` — `cacheDir`. -- `DebugStats`/`FrameStats` — `Choreographer` frame timing and the render - report; `runtime-tracing` names composables in a system trace. - -So **"pure Rust" on Android means Rust owns every line of logic and -drawing, behind a thin shell of Java stubs**, and a packaging step that -produces a signed APK. How thin, and whether Gradle is inevitable, are -answered below. - -### How much Java is unavoidable, and why - -Rust can *call* any Android API through JNI (`jni` crate, with -`ndk-context` handing over the `JavaVM` and the Activity): posting a -notification, `startForegroundService`, the Keystore, `ContentResolver` -reads, permission requests, `WindowInsets`, the clipboard. None of that -needs a line of Kotlin. What JNI cannot do is *define* a class that the -system instantiates **by name from the manifest** — an `Activity`, a -`Service`, an `Application`, a `BroadcastReceiver`. Those must exist as dex -bytecode inside the APK before any Rust runs, because the framework -constructs them and only then calls into native code. `NativeActivity` is -the platform's own stub for the Activity case; there is no -`NativeService`, and android-view ships its own `View` subclass for the -same reason. - -So the floor is roughly **two Java classes of ten lines each**: an -`Activity` and a `Service` whose lifecycle methods are declared `native` -and registered from `JNI_OnLoad`, plus whatever android-view already -provides. Everything they would have done in Kotlin — insets, intent -routing, the SSE follow loop, the notification builder — is Rust reached -through those stubs. Writing the stubs in Java rather than Kotlin drops -`kotlinc` from the toolchain; `javac` comes with the JDK Gradle already -needs. Generating the dex from Rust is not worth it: there is no mature -Rust dex writer, and the stubs never change. - -### Can the APK be built without Gradle? - -Yes. An APK is a zip containing a binary-XML `AndroidManifest.xml`, -`resources.arsc`, `classes.dex`, `lib//*.so` and assets, aligned and -signed with the v2 scheme. The tools are `aapt2` (manifest and resources), -`d8` (Java bytecode to dex), `zipalign` and `apksigner`, all in the SDK's -`build-tools`, none of them Gradle. Three ways to drive them: - -- **A `cargo xtask`** (or `build.rs`-adjacent script) that runs `cargo ndk` - for each ABI, `javac` + `d8` for the stubs, `aapt2 link`, `zipalign`, - `apksigner`. About 150 lines, every step visible, no AGP, no Gradle - daemon holding 2.8 GB between builds. The pinned-CA constant becomes a - `build.rs` reading the same `certs/ca.pem` path. -- **[cargo-apk2](https://github.com/mzdk100/cargo-apk2)**: the maintained - successor to cargo-apk, and unlike it compiles `java_sources` / - `kotlin_sources` into the dex and declares multiple activities **and - services** with intent filters from `[package.metadata.android]`, with - per-profile keystores and optional `aapt2`. Exactly the shape needed; - the question is whether a third-party tool with one maintainer beats - 150 lines we own. -- **cargo-apk / xbuild**: unmaintained and `NativeActivity`-only. No. - -What Gradle would take with it: Android Lint (which found two real bugs -here, but in Kotlin that would no longer exist — with forty lines of Java -stubs there is little left for it to find), manifest merging, R8, and the -generated-source plumbing. What it gives back: one toolchain, `cargo` -end to end, and Dev Updater keeps calling `build-apk.sh` exactly as now. -**Recommendation: the xtask**, with cargo-apk2 read for the details it -already got right (v2 signing, `uses-feature`, ABI splits). - -### The behaviours that are hard to get back - -Reading the Compose code for what a replacement must be able to express, -rather than what it happens to look like: - -1. **The transcript is one selectable body of text.** A - `SelectionController` is registered directly on the lazy list, with no - selection widget in the layout tree, so a selection runs from a reply into - the tool output beneath it. Each parent supplies either draw order or one - visual axis for its immediate children; ordering is resolved only when - selection queries it. Iris owns the selection handles because its text is - drawn into one surface, while the platform supplies the clipboard and - related system services. -2. **Rich inline text**: markdown with links (one tap detector per text, - not a node per link), inline code chips drawn behind the text, tables - with wrapping cells and a sideways scroll, syntax-highlighted fences, - ANSI colour in tool output, Nerd Font icon glyphs. Needs a text layout - engine with spans, not just styled labels. -3. **A bottom-anchored virtualised list of variable-height rows**, paged in - both directions (800-event pages, `HISTORY_SCREENS` measured in - viewports), with a saved scroll anchor per session, "hold the edge - nearest the tap" when a row expands (`holdTopEdge`, done in the layout - pass so the wrong frame is never drawn), and rows keyed so that a run of - tool calls stays one row while it grows. -4. **The soft keyboard**: the composer resizes with the IME, the guard - against a stuck inset animation, drafts per session, autocorrect and - suggestions from the phone's own keyboard. This is where most Rust - frameworks fail on Android today; see below. -5. **Platform integration through the app model**: foreground service, - notifications, share sheet, deep link, Keystore, camera, back gesture, - edge-to-edge insets, local-network permission. -6. **Accessibility names on icon buttons**, which the bench scripts depend - on (`ui-trace` taps by label). A framework with no accessibility tree - also breaks the measuring rig. -7. **Measurable frames**: the debug render report, and a way to attribute - a frame's cost to a widget on the real phone. - -## Measurements and constraints - -### The Android release profile and APK size - -Iris asked why the iris bench APK was double the Compose one (20.6 MB vs -10.1 MB). It was almost all `libmain.so`, built with `panic = "abort"` and -nothing else. Measured cumulatively, arm64 release: - -| profile.release | APK bytes | `.so` bytes | delta | -|---|---|---|---| -| `panic="abort"` only (baseline) | 20,678,956 | 18,546,488 | -- | -| + `strip = true` | 16,435,156 | 14,302,688 | -4,243,800 | -| + `lto = "fat"` | 15,751,212 | 13,618,744 | -683,944 | -| + `codegen-units = 1` | 15,185,204 | 13,052,736 | -566,008 | -| + `opt-level = "s"` | 13,326,076 | 11,193,608 | -1,859,128 | -| + `opt-level = "z"` (**not adopted**) | 12,507,276 | 10,374,808 | -818,800 | -| + platform fonts, no bundled Noto | 9,577,940 | 7,445,472 | -3,748,136 | - -`opt-level = "z"` was not taken: 0.8 MB is not worth the loop -vectorisation on a renderer. Everything else is -`app-rust/Cargo.toml`'s `[profile.android-release]` -- a profile of its -own rather than `release`, so the desktop build is not also optimised for -size. - -### The fling stutter and what a frame report cannot say - -Iris, from her phone: *"I'm noticing some stuttering when flinging in -particular. Harder to notice with my finger directly moving the scroll."* -Her report had the fling phase at 3396 frames over 33.0s -- 103fps on a -120Hz screen -- with p50 6.3ms and 13.4% "late". - -**The report was not measuring what its own labels claimed.** Three -things came out of chasing it, and the first two are corrections to the -instrument rather than to the renderer: - -1. **The swapchain acquire was counted as iris's CPU work.** - `AndroidRenderer::draw` timed `queue.submit` + `present()` and called - everything before it `redraw_to_submit`, but `get_current_texture` -- - which *blocks* until the compositor frees an image -- sits in that - span. An app comfortably ahead of the display spends most of every - frame there, so a healthy fling read as several milliseconds of iris - being slow. A frame is now three measured parts (`FrameParts`: - `build`, `acquire`, `submit`), per phase as well as per run, because - they do not divide the same way in every phase. - -2. **Nothing could say a frame was never produced.** `late` counts frames - that cost more than a budget, which is not the thing a reader sees: - a frame that is late but drawn shows up on the next vsync, while a - frame that never happens leaves the previous one on screen for two - refreshes. `PhaseStats::missed` counts vsyncs nothing was drawn for, - from the gap between consecutive frame times. - -3. **The frame loop asked for its next frame after doing the work.** - `Choreographer.postFrameCallback` schedules for the next vsync *after - the call*, so any frame whose work ran past the vsync boundary - registered too late for the next one and got the one after -- one - frame over budget silently cost a second frame as well. It is asked - for immediately after `tick_animations`, before the layout and the - draw. - -And one that is about the animation rather than the report: **the fling -was advanced on `Instant::now()`, not on the vsync the callback carried.** -`do_frame`'s `frame_time_nanos` was discarded. Frames are *presented* on -an even cadence whatever clock they are computed on, so sampling the -spline at "whenever the callback got to run" moves the content by an -uneven distance every frame -- a shimmer with no frame late enough to -appear in any report, and it is exactly the asymmetry Iris described, -since a drag's positions come from the finger's own timestamped samples -and never had it. `sense::PointerClock` is now `sense::DeviceClock` and -the view keeps **one**, anchored by whichever of a touch or a frame -arrives first, so a fling is advanced on the clock its velocity was -measured on. - -What the CPU side is *not*: `scripts/rigs/ui-profile`'s `frame_profile.rs` (AGENTS.md's -rig list) puts iris's own per-frame work during a warm fling at p99 -0.26ms, with only one frame in six laying anything out at all. The -multi-millisecond spikes are first-pass only. - -**The result, from Iris's phone the same day: "now THAT is smooth. I -couldn't actually see any lag myself."** With three corrections to what -the report meant, found by reading that run against the bench's own -timings: - -- **The frame rate never was the problem, and the first reading of it was - wrong.** "103fps on a 120Hz screen" divided the fling phase's frames by - its whole duration, which includes sixteen deliberate 300ms rests. Both - runs sustained ~120.3fps through the motion itself. So the callback - ordering was not costing frames -- what changed is the *clock*, which - moves no frame count and is the whole point: an uneven sample of an - even cadence cannot show up in any frame-time percentile. -- **`missed vsyncs` counted idleness.** Every gap was treated as cadence, - so the bench's own pauses read as stutter: 276 for sixteen 300ms rests, - 2410 for twelve hundred 50ms keystroke gaps, 821 for four hundred 50ms - stream gaps -- each within a few percent of the arithmetic. A gap now - measures anything only if the frame before it had asked for another. -- **`late` counted the vsync wait as cost.** A well-paced loop spends - each frame blocked in the acquire, so its total sits at exactly one - refresh period and every frame lands on the budget boundary. It is - judged on `FrameParts::work` -- the total minus the acquire -- now. -- **The refresh rate is the larger of what the platform claims and what - the run sustained**, because each can only be wrong one way. - `Display.getRefreshRate()` answered 60 for a run that drew 3405 frames - in 33.1s, since a phone that varies its rate answers with whatever mode - it is in when asked. And `FrameReport::sustained_frame_hz` is a *floor*: - an app that cannot keep up says nothing about the panel. The first - version of it took the fastest tenth of the gaps rather than the - sustained rate and reported **88Hz for this repo's 60Hz emulator**, - whose app manages 51 -- a budget no frame there could meet, invented - out of the app's best moments, and caught only by running the corrected - report on the emulator before shipping it. The two are printed together - whenever they disagree. - -The signature of the fixed loop, from that run: `build p50 0.4ms, -acquire p50 5.7ms, submit p50 1.7ms` -- four tenths of a millisecond of -work and the rest of the refresh period spent waiting its turn. - -### Streaming frame time - -Measured after the fling was fixed, and it is not where it looks. -`frame_profile.rs`'s stream run: folding an arriving event is 0.35ms and -applying the diff to the widget tree is 0.41ms, while the frame that -follows is 3.86ms on this desktop and 9.5ms of `build` on Iris's phone -- -over a 120Hz budget on its own. 401 streamed events move the item count -from 652 to 654, so nearly every one is a *delta into the same row*: the -cost is re-laying out and re-shaping one growing markdown message on -every delta. - -`fold_event`'s `items.to_vec()` per event was the hypothesis -- it is the -exact shape of the Compose lesson in AGENTS.md's "Things that have -bitten" -- and measuring it is what ruled it out. - -### Incremental text shaping - -Iris asked to investigate incremental text rendering and hoped parley -supported it. **It does not, by design.** The crate's own docs: a -`Layout` "supports re-linebreaking and re-aligning many times... but if -the text content or the styles applied to that content change then a new -`Layout` must be created". Its `LruCache` caches harfrust's per-font -shaper data, instance and plan -- not shaped runs -- and its own -`PlainEditor::update_layout` rebuilds the whole layout from the whole -buffer on **every keystroke**. So there is nothing to adopt, and adding -it would be upstream work in parley. - -**And the app already does the thing incremental layout would buy.** -`RowBlocks::apply_delta` keeps one `Text` per top-level markdown -block and re-shapes only the block a delta landed in; re-splitting the -markdown to find that block is 18µs at 18,000 characters and comparing -the blocks is 470ns. Neither is the cost. - -**The 9.5ms is a bench-fixture artifact.** Measured with -`frame_profile.rs`: - -- Re-shaping a block is linear in its length -- ~0.23ms per 1,000 - characters on this desktop, so a message grown to 17,600 characters - costs 4.1ms on its *last* delta and 842ms of shaping over the whole - reply. -- The fixture's streamed message is **14,888 characters in one block** -- - a synthetic run-on paragraph with no blank line in it, so every delta - reshapes all of it. That is the whole of the frame: 3.5ms of the - measured 3.86ms. -- Real replies are not like that. Over **7,706 top-level blocks from - 3,675 real assistant messages** on this machine (block lengths only; - no content left the machine): p50 147 characters, p90 449, p99 836, - largest 1,580, and **nothing above 4,000**. Code fences are smaller - still -- 170 of them, p50 126, largest 589. -- At those sizes a live reshape is 48µs (p50), 208µs (p99) and 372µs - (the largest block ever seen), or roughly 0.12-0.93ms on the phone. - Comfortably inside a 120Hz budget, with no incremental anything. - -**So: incremental text is not worth building** -- and Iris agreed, with -the fixture changed instead (2026-09-09: *"let's switch to new lines for -the test, and also let's keep the single line around for stress + could -be something to try to optimize later"*). What landed: - -- The streamed reply gets a blank line every 4-12 deltas, so it is 53 - blocks with a longest of 502 characters instead of one of 14,888. The - streaming frame went from p50 3.86ms / p90 8.65ms / worst 10.95ms to - **p50 2.20ms / p90 5.90ms / worst 8.78ms** here. -- The run-on message is kept as the first two backlog events, sized just - under `text_cap`'s 16 KiB so it draws in full. The *streaming* - pathology is kept in `frame_profile.rs` instead of the fixture, because - it needs a growing block and iterating on it there costs a second - rather than a two-minute phone run. -- Adding it is **purely additive**: the random state is saved and - restored around those two events, so every other backlog event is - byte-identical. That is not cosmetic -- `phone_screen.rs`'s - `a_long_press_and_drag_selects_text` replays a real recording at - (300, 1000) and failed the first time round, when the insertion shifted - what was under it. -- `BACKLOG_COUNT` is 3202 now, in `generate.py`, `fixture.rs` and - `BenchFixture.kt`. The split is by line index, so a stale copy opens a - different half of the file. - -**The cap does not save a streamed reply, and this is worth knowing -before optimising anything here.** Iris asked whether the newest message -caps: it does not, deliberately -- `row::build_row`'s `cap` is `false` -for the live tail because a row that grew while capped would appear to -stop growing, and a reply that grows *past* the cap never gets caught -either, since it grows through `apply_delta`. So a streamed block's -shaping cost has no ceiling: at the measured ~0.23ms per 1,000 -characters (about 2.5x that on the phone), a 50,000-character block -would be ~29ms per delta and a 100,000-character one ~58ms. Real replies -do not do this, which is why it is not urgent; nothing *stops* one doing -it, which is why the stress case is kept. - -**What the remaining streaming cost is, and is not.** With realistic -blocks the reshape is no longer the cost: layer 1's frame went to p50 -2.20ms, spread over frames that added a block (p50 3.56ms, 56 of 401) -and frames that did not (p50 1.94ms). Folding is 0.12ms and applying the -diff 0.35ms. - -**But the emulator's `stream: build p50` did not move -- 10.4ms before -the fixture change, 10.5ms after** -- while layer 1's CPU frame nearly -halved. So most of a streaming frame on a real GPU path is something -layer 1 builds and never uploads, and therefore cannot time. The -candidate, and the arithmetic behind it: - -- The screen holds **11,568 primitives** by the end of the stream phase. -- `UiRenderNode::update` re-uploads the *entire* instance and primitive - arenas whenever `primitives.updated` is set, which a text change sets - every delta -- about 370 KB per delta at 32 bytes an instance, before - the primitive data itself. `ArrBuf::update` also **recreates the - buffer** whenever its length changes, which adding glyphs does on - nearly every delta, and a recreated buffer means a fresh bind group - too. -- The fling phase is the control that makes this convincing: it moves - the same 11,568 primitives every frame through `move_offsets` -- a - small buffer, no arena rewrite -- and its `build p50` is **0.4ms** - against streaming's 10.5ms, on the same screen and the same content. - -So the next thing to look at for streaming is **uploading only what -changed** rather than the whole arena, not anything about text. Splitting -the reply into blocks was still right -- it is what makes the fixture -representative, and it halved the CPU half -- but it was never going to -move this, and it slightly increases the primitive count. - -### Arena delta uploads - -Done, and measured by `scripts/rigs/ui-profile`'s `arena_churn` -- see -AGENTS.md's entry for the rig and the numbers. The arithmetic above was -right about the symptom and wrong about the cause being the upload -strategy alone. Three things, in the order they had to be fixed: - -1. **`ArrBuf` reallocated on every length change**, and a fresh buffer's - contents are undefined, so a partial upload could not have been - correct in the first place. It has a capacity now: geometric growth, - never shrinking, and `update` says whether the `Buffer` identity moved - so a caller can rebuild its bind group and force the whole range - dirty. This alone took the glyph array from 95% re-uploaded to 3%. -2. **A redraw freed its primitives and pushed new ones.** Freed slots are - not reusable until the end of the frame (a layer's draw order still - names them), and nested provisional layout meant the arena's high-water was - the *transient* push count: 17 million pushes across 401 deltas, and - 127,443 slots for 11,569 live primitives, growing linearly with the - transcript. A redraw now gets its old handles back as a recycle pool - (`Painter::take_recycled`, `Primitives::recycle`) and writes into the - slots it already holds. The arena is exactly the live count now, and - the CPU frame fell from p50 2.20ms to 1.39ms as a side effect, since - the freeing and draw-order renumbering went away. -3. **Nothing tracked which entries changed.** `util::Dirty` is a bitset - per uploaded array, coalesced into ranges at a 1 KiB gap. Marking is - O(1), and the read-back is one word per 64 entries. A `min..max` span - was rejected on measurement (a frame's changes land in 5-20 scattered - runs, so a span is nearly the whole buffer) and so was a `Vec` of - indices (thousands of marks per frame would mean an allocation and a - sort). - -The trap that only the rig could have caught: writing an entry is not the -same as changing it. Recycling rewrote every glyph of every moved row with -identical bytes, marking 73% of the glyph array against 0.6% genuinely -changed. `PrimitiveVec::set` and `Primitives::set_instance` compare before -marking. Layout can also write a provisional instance and restore it within -one frame; `Primitives` remembers the pre-frame bytes and cancels that dirty -bit when the GPU-visible result is unchanged. `arena_churn` prints both -numbers so either gap cannot reopen unnoticed. - -**Layout has no measurement mode.** A widget is drawn provisionally only -when its size cannot be known yet, and that retained drawing is moved into -place. `Widget::size_hint(axis)` lets context-free wrappers such as `Sized` -report an exact `Len`; a debug assertion compares every hint with the real -draw result. If final allocation changes a child's size, `Painter::place` -redraws it in that box. Otherwise placement is one move-offset write. - -Measured over the fixture's 401 streamed events, streamed-frame CPU p50 is -0.12ms, from 1.18ms before this layout change. Arena size and upload floors -are unchanged. - -Pinned growth now uses the same subtree translation as scrolling. A -container can retain a child-coordinate move slot through -`Painter::set_child_offset`; `LazySpan` keeps retained rows in stable local -boxes and changes that one slot when its anchor moves. It still walks the -visible run to virtualise it, but unchanged rows no longer acquire new -absolute primitive regions. Over the fixture's 401 streamed events, instance -upload is **1.1% against a 1.1% floor**, from 71.9% against 71.8%; median -instance bytes per frame are **1,488**, from 176,496. This is framework -layout/rendering behaviour and the transcript screen contains no special -case for it. - -### The Android release profile uses `opt-level = 3` - -The table above was measured in bytes only. `"s"` costs the loop -vectorisation and inlining a renderer runs on: over the same warm fling -eight times, iris's own per-frame work is p90 0.15ms / p99 0.42ms at -`"s"` against p90 0.09ms / p99 0.26ms at `3`. The arm64 release APK goes -from 9,745,704 to 11,542,646 bytes (+1.8 MB) -- the same trade the table -refused for `"z"`, one level further up. Iris raised it herself -(*"I'd make sure it's in release mode"*); the build always was, and this -was the part of "release" that was not about speed. - -### Platform fonts - -Iris: *"remove the font for now; just match what compose does."* The -Compose app takes body text from `FontFamily.Default` and code from -`FontFamily.Monospace` and ships no text font, only its Nerd Fonts icon -subset. So `TextData::register_bundled_fonts`, the six `include_bytes!` -Noto constants and `iris/core/assets/fonts/`'s `.ttf`s are gone. - -The reason this works at all: `FontContext::new()` was already finding the -platform's fonts underneath the bundled ones -- `fontique`'s -`CollectionOptions::system_fonts` defaults to `true`, with a real backend -on both platforms iris ships on (`fontconfig` on Linux, `/system/fonts` + -`/system/etc/fonts.xml` on Android). The **icon** font is the opposite -case and is still bundled: a small, closed set of codepoints no system -font is guaranteed to have (AGENTS.md's "Icons"). - -**Still unverified, and it is the half that can fail** (review R6, -2026-09-07): the bundled fonts originally existed because *"bold spans on -a real phone rendered as blank gaps of the correct advance width"*, and -the replacement was checked with CJK and emoji **on the desktop**. The -fault was Android's font enumeration resolving a weight/style, so the -desktop cannot answer it. Before the next phone build, look at a bold run -and at `CLOSED_MARK`/`OPEN_MARK`/`UP_MARK` (U+25B8/BE/B4) on Iris's own -device; the emulator's font set is not evidence for hers. - -## The port, in order - -Every screen is a module under -`app-rust/src/ui`, which holds a `Screen` enum and a back stack -- the -direct equivalent of `AppRoot.kt`'s `when` and `MainScreen.kt`'s tab -`enum` -- with each Compose screen becoming one `iris::widget` subtree. -`src/desktop` and `src/android` are thin entry points that call into it, -the way `AppRoot`/`MainActivity` today call into Compose screens they do -not otherwise own. Platform-only code (the notification foreground -service, the share target, the QR scanner, the Keystore-sealed token, -deep-link enrolment) stays in `src/shell` + `app/shellApp`, since none of -it is a screen `ui` could draw. - -Order is by **risk to the daily-use path**, not by screen count: the -session screen is what the app is for and where every hard behaviour -(paging, cache, keyboard insets, selection) already lives, so it goes -first and on the phone as reachable code as soon as possible, before the -lower-risk screens. - -Every step below assumes the `app/ui-sandbox.sh` fixtures (AGENTS.md's -"The rigs") and the `this-machine-android` skill's facts (per-checkout -AVD, `ui-trace` by accessibility name, GrapheneOS phone quirks, the -`adb shell` quoting traps) apply unchanged -- read that skill before -running any pass condition below that touches an emulator or a real -device. - -**Bench-only cleanup still open**: the diagnostics report pane draws over -transcript rows. `REPORT_MAX_HEIGHT_DP` constrains its claimed height, but the -pane is neither masked nor scrollable despite its construction comment saying -it is both. This is an `app-rust` defect, not an iris framework item. - -- [ ] **P1 — session screen parity.** Continue in this order: - - [ ] **P1c — history paging and jump-to-latest.** Wire - `client::transcript_source` into `src/ui`: - the opening page, paging back on scroll with the cushion - measured in on-screen viewports (`HISTORY_SCREENS`, IRIS_TODO - "Build (for the port)"), the `NothingLoaded`/empty/error - states drawn distinctly (UI_RULES: design the unknown state - first), `join_pages` at each seam, and a jump-to-latest - control that pins to the newest end. Pass condition: the - P1 pass condition below, against `ui-sandbox.sh` with - `AI_SANDBOX_BIG_MB` and `--delay`. - - [ ] **P1d — images, the session settings dialog, attachments, - usage bar.** `SessionImage` thumbnails (the scaled image - widget), the existing overlay facility and - `SessionSettingsDialog`/`UsageDialog`, `PendingAttachments` over the attachments - route (`api.rs` gap), `SessionUsageBar` (the gauge widget). - - [ ] **P1e — keyboard and insets behaviours** from AGENTS.md's - "Things that have bitten", re-verified on the phone build: - composer never left floating after the keyboard closes - mid-stream, `adjustResize` + edge-to-edge together, one - recomposition-equivalent per keyboard toggle (the - `iris insets:` log line count). - - The remaining screen work is history paging and jump-to-latest, the - session settings and usage dialogs, images and composer attachments, - and the keyboard/insets behaviours AGENTS.md's "Things that have - bitten" names (the floating-composer bug, `adjustResize`, the - `imePadding`-vs-raw-inset rule). This is the highest-risk step: it is - the screen the app is used for, every hour of the day. - - **Kotlin it replaces**: `SessionScreen.kt`, `TranscriptList.kt`, - `SessionSettingsDialog.kt`, `ToolInput.kt`, `ToolRows.kt`, - `AskQuestion.kt`, `Compaction.kt`, `SessionUsageBar.kt`, - `PendingAttachments.kt`, `Attachment.kt`, `Attachments.kt`, - `SessionImage.kt`, `MemoryNote.kt`, `PeerMessage.kt`, `RawBlock.kt`, - `CodeFence.kt`, `MarkdownLinks.kt`, `MarkdownPieces.kt`, - `Markdown.kt`, `Bubble.kt`, `ScrollAnchor.kt`, `Drafts.kt`, - `UsageDialog.kt`, `Chevron.kt`, `Dividers.kt`. (`src/ui` - already covers the row/markdown/selection/composer core these sit - on top of or beside.) - - **`app-rust`'s `client` needed** (`CLIENT_CORE.md`): the paging half - is ready — `transcript_source`, `join_pages` with seam healing, and - `markdown_blocks` are all ported. The remaining client gap in P1 is - the attachments route (`/sessions/{id}/attachments`), needed by P1d's - `PendingAttachments`/`Attachment`. - - **iris widgets missing, → `IRIS_TODO.md`'s "Build (for the port)" - section**: the distance-to-unloaded-edge query P1c needs for its - viewport-sized history cushion; per-range text backgrounds for inline - code; a fitted image widget; and a horizontal gauge for - `SessionUsageBar`. The settings and usage dialogs can use Iris's - existing local overlay hosts. Tappable links already exist. Row - accessibility names are app content applied through iris's existing - `.label()` API, not a missing framework widget. - - **Pass condition**: `app/ui-sandbox.sh`'s fixtures driven by - `ui-trace record --do "tap '