From b172c464ea06dc170757158dba0bd2bbfb07aca5 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 20:29:07 -0400 Subject: [PATCH] ai-app: a phone interface to Claude Code and llama.cpp sessions A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here. --- .claude/settings.json | 8 + .dev-updater.ron | 48 + .gitignore | 23 + .gitmodules | 3 + AGENTS.md | 580 +++ CLAUDE.md | 1 + PLAN.md | 1031 +++++ app/android-env.sh | 55 + app/androidApp/build.gradle.kts | 150 + app/androidApp/src/main/AndroidManifest.xml | 99 + .../src/main/kotlin/com/example/aiapp/Api.kt | 826 ++++ .../main/kotlin/com/example/aiapp/AppRoot.kt | 214 + .../kotlin/com/example/aiapp/AskQuestion.kt | 234 ++ .../kotlin/com/example/aiapp/Attachments.kt | 104 + .../main/kotlin/com/example/aiapp/BusyItem.kt | 101 + .../main/kotlin/com/example/aiapp/Chevron.kt | 53 + .../main/kotlin/com/example/aiapp/Commands.kt | 153 + .../kotlin/com/example/aiapp/Compaction.kt | 68 + .../main/kotlin/com/example/aiapp/CrashLog.kt | 66 + .../kotlin/com/example/aiapp/DebugStats.kt | 164 + .../main/kotlin/com/example/aiapp/Dividers.kt | 54 + .../main/kotlin/com/example/aiapp/Drafts.kt | 36 + .../kotlin/com/example/aiapp/EventStream.kt | 101 + .../main/kotlin/com/example/aiapp/Events.kt | 281 ++ .../kotlin/com/example/aiapp/FrameStats.kt | 155 + .../kotlin/com/example/aiapp/ImportScreen.kt | 602 +++ .../kotlin/com/example/aiapp/LoadState.kt | 28 + .../kotlin/com/example/aiapp/MainActivity.kt | 184 + .../kotlin/com/example/aiapp/MainScreen.kt | 140 + .../main/kotlin/com/example/aiapp/Markdown.kt | 222 ++ .../kotlin/com/example/aiapp/MemoryNote.kt | 119 + .../kotlin/com/example/aiapp/MessageBlocks.kt | 96 + .../kotlin/com/example/aiapp/ModelName.kt | 35 + .../kotlin/com/example/aiapp/ModelsScreen.kt | 381 ++ .../kotlin/com/example/aiapp/NerdIcons.kt | 219 ++ .../kotlin/com/example/aiapp/Notifications.kt | 366 ++ .../kotlin/com/example/aiapp/PeerMessage.kt | 58 + .../com/example/aiapp/PendingAttachments.kt | 125 + .../kotlin/com/example/aiapp/PinnedCert.kt | 18 + .../main/kotlin/com/example/aiapp/RawBlock.kt | 38 + .../com/example/aiapp/ResetCountdown.kt | 57 + .../kotlin/com/example/aiapp/ScrollAnchor.kt | 57 + .../kotlin/com/example/aiapp/ServerConfig.kt | 28 + .../kotlin/com/example/aiapp/SessionAlerts.kt | 186 + .../kotlin/com/example/aiapp/SessionImage.kt | 191 + .../com/example/aiapp/SessionListScreen.kt | 378 ++ .../kotlin/com/example/aiapp/SessionScreen.kt | 1978 ++++++++++ .../example/aiapp/SessionSettingsDialog.kt | 189 + .../com/example/aiapp/SessionUsageBar.kt | 224 ++ .../com/example/aiapp/SettingsScreen.kt | 202 + .../kotlin/com/example/aiapp/SetupsScreen.kt | 395 ++ .../kotlin/com/example/aiapp/SpawnScreen.kt | 358 ++ .../main/kotlin/com/example/aiapp/Theme.kt | 282 ++ .../kotlin/com/example/aiapp/ToolInput.kt | 188 + .../main/kotlin/com/example/aiapp/ToolRows.kt | 440 +++ .../com/example/aiapp/TranscriptItems.kt | 439 +++ .../com/example/aiapp/TranscriptList.kt | 104 + .../com/example/aiapp/TranscriptUnits.kt | 135 + .../kotlin/com/example/aiapp/UsageDialog.kt | 233 ++ .../src/main/res/font/nerd_icons.ttf | Bin 0 -> 2400 bytes app/build-apk.sh | 102 + app/build-icon-font.sh | 78 + app/build.gradle.kts | 6 + app/debug-transcript.sh | 149 + app/gradle.properties | 7 + app/gradle/libs.versions.toml | 82 + app/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 47505 bytes app/gradle/wrapper/gradle-wrapper.properties | 7 + app/gradlew | 248 ++ app/gradlew.bat | 82 + app/run-android.sh | 48 + app/settings.gradle.kts | 25 + app/trace-draw.sh | 129 + app/ui-sandbox.sh | 175 + resources.ron | 27 + run-tests.sh | 11 + server/Cargo.lock | 2096 ++++++++++ server/Cargo.toml | 54 + server/src/auth.rs | 187 + server/src/config.rs | 545 +++ server/src/main.rs | 310 ++ server/src/media.rs | 62 + server/src/models.rs | 677 ++++ server/src/routes.rs | 1265 ++++++ server/src/session/claude.rs | 1591 ++++++++ server/src/session/claude/translate.rs | 1297 ++++++ server/src/session/driver.rs | 671 ++++ server/src/session/echo.rs | 852 ++++ server/src/session/import.rs | 967 +++++ server/src/session/llama.rs | 811 ++++ server/src/session/mod.rs | 3461 +++++++++++++++++ server/src/session/process.rs | 483 +++ server/src/session/transcript.rs | 550 +++ server/src/session/transport.rs | 184 + server/src/setups.rs | 184 + server/src/ssh.rs | 276 ++ server/src/usage.rs | 506 +++ test-wg-tunnel.sh | 132 + wg-app-link | 1 + wg-setup-host.sh | 154 + 100 files changed, 31795 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .dev-updater.ron create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 PLAN.md create mode 100755 app/android-env.sh create mode 100644 app/androidApp/build.gradle.kts create mode 100644 app/androidApp/src/main/AndroidManifest.xml create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt create mode 100644 app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt create mode 100644 app/androidApp/src/main/res/font/nerd_icons.ttf create mode 100755 app/build-apk.sh create mode 100755 app/build-icon-font.sh create mode 100644 app/build.gradle.kts create mode 100755 app/debug-transcript.sh create mode 100644 app/gradle.properties create mode 100644 app/gradle/libs.versions.toml create mode 100644 app/gradle/wrapper/gradle-wrapper.jar create mode 100644 app/gradle/wrapper/gradle-wrapper.properties create mode 100755 app/gradlew create mode 100644 app/gradlew.bat create mode 100755 app/run-android.sh create mode 100644 app/settings.gradle.kts create mode 100755 app/trace-draw.sh create mode 100755 app/ui-sandbox.sh create mode 100644 resources.ron create mode 100755 run-tests.sh create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/src/auth.rs create mode 100644 server/src/config.rs create mode 100644 server/src/main.rs create mode 100644 server/src/media.rs create mode 100644 server/src/models.rs create mode 100644 server/src/routes.rs create mode 100644 server/src/session/claude.rs create mode 100644 server/src/session/claude/translate.rs create mode 100644 server/src/session/driver.rs create mode 100644 server/src/session/echo.rs create mode 100644 server/src/session/import.rs create mode 100644 server/src/session/llama.rs create mode 100644 server/src/session/mod.rs create mode 100644 server/src/session/process.rs create mode 100644 server/src/session/transcript.rs create mode 100644 server/src/session/transport.rs create mode 100644 server/src/setups.rs create mode 100644 server/src/ssh.rs create mode 100644 server/src/usage.rs create mode 100755 test-wg-tunnel.sh create mode 160000 wg-app-link create mode 100755 wg-setup-host.sh diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..d0f0a29 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "env": { + "ANDROID_HOME": "/home/bob/Android/Sdk", + "ANDROID_SDK_ROOT": "/home/bob/Android/Sdk", + "PATH": "/home/bob/Android/Sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/usr/lib/rustup/bin:/home/bob/.local/bin" + } +} diff --git a/.dev-updater.ron b/.dev-updater.ron new file mode 100644 index 0000000..30df5a5 --- /dev/null +++ b/.dev-updater.ron @@ -0,0 +1,48 @@ +// Read by Dev Updater. Structured as the body of the config: no outer +// parentheses, so nothing here is indented for the sake of a wrapper. +// +// This file sits at the checkout root because the project Dev Updater +// serves is the whole repository -- one checkout producing two things. +// Each component says where it lives with `cwd`, relative to this +// directory, rather than one of them reaching out of the other with `../`. + +// What to call this project before there is a build to read a label from. +// A built APK wins: it is the authority on what will actually install. +label: "AI Sessions", + +// Where this project's own state lives, said rather than guessed: it is +// what the Uninstall dialog offers to delete, and a plausible-looking +// path it worked out would read as "this component keeps nothing here" +// when it was wrong. `Ron` rather than `Script` because the answer is +// 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. +components: [ + Server( + name: "server", + build: "cargo build --release", + cwd: "server", + // Dev Updater's own built-in service implementation, generated + // into its data directory and driven through the same interface a + // project-supplied script would be. ai-app carried a script of its + // own until 2026-08-28; it ran `target/release/ai-server` with no + // arguments and no environment, which is the generic case exactly, + // so it was two copies of one thing and the copy that could not be + // tested from here -- the OpenRC branch -- was duplicated with it. + // + // Resolved against this component's `cwd`, so this is + // `server/target/release/ai-server`. + service: Managed("target/release/ai-server"), + ), + Apk( + name: "app", + // 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", + cwd: "app", + ), +], diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3f8a85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +.gradle/ +build/ +app/androidApp/build/ +local.properties +.kotlin/ +*.iml +.idea/ +.DS_Store +server/target/ + +# Server logs from a development run (ai-server.log by convention, +# wg-test.log from ./test-wg-tunnel.sh). +*.log + +# The state and key material below all live outside the repo now -- under +# $XDG_CONFIG_HOME/ai-app and $XDG_DATA_HOME/ai-app, because this repo is +# a mount shared with a VM the host doesn't trust (see AGENTS.md). These +# entries stay as a backstop so a stray --config or --certs pointed at the +# checkout can't commit a CA private key, a token hash, or a transcript. +certs/ +config.ron +config.json +sessions/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6f76f13 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "wg-app-link"] + path = wg-app-link + url = git@git.arirex.me:iris/wg-app-link.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..448e210 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,580 @@ +# ai-app + +A phone interface to AI coding sessions (Claude Code and llama.cpp via pi), +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. + +**`PLAN.md` is the design source of truth.** Read it before building or +changing anything structural. It records every decision with its date, its +rationale, and the alternatives that were rejected and why — keep that habit +when a decision changes: update the plan in place, don't let this file and +the plan drift into two versions of the truth. This file is the working notes +layer: conventions, commands, and things that have bitten. + +The central design point, worth not undoing by accident: **a session is a +child process speaking JSONL over stdio, translated into one common event +model.** Claude Code (stream-json) and pi (RPC mode) are two translators +behind one `Driver` trait; the transcript, the SSE stream, the phone UI, and +SSH spawning (the same command wrapped in `ssh host …`) all work purely in +the common model. A new session type is a new driver — never a +session-type branch in shared code (routes, transcript, app screens). + +## 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 (every +session mutation funnels through the manager so in-memory and on-disk state +can't come apart). Read dev-updater's `README.md` and `AGENTS.md` for the +conventions before diverging from them; module-by-module intent for this +repo is in PLAN.md's "Backend layout" section. + +- `server/src/session/import.rs` — continuing a Claude Code session the + machine already has. Claude Code keeps each one as JSONL under + `~/.claude/projects/`, and the CLI resumes one with `--resume ` — + which `claude.rs` already does for crash recovery, so an import is that + same path with the token written up front rather than a second way to + start a session. The phone picks an **id**, never a path: the server + resolves which file that is, so an enrolled token cannot become "read me + an arbitrary file" — the same rule that keeps a command out of + `POST /setups`. Only the tail is replayed (`REPLAY_LINES`) because these + files reach tens of megabytes and the CLI reads the real one itself; what + crosses the tunnel is what a person reads, not what the model is given. + Images in the replayed tail are written into the session's `files/` by + the same function the live translator uses, so a screenshot looks the + same whether it was watched happening or replayed afterwards, and the + phone fetches the bytes only when it draws one. + An imported session then **keeps itself level with that file**, so work + done at a terminal appears without anyone pressing anything. Which new + lines came from *here* is answered by counting the events this session + has recorded, **not** by looking at its status — a turn that starts and + finishes between two polls reads as idle at both, and its own output + gets replayed on top of itself. That bug was visible on screen as + `donedone`. +- `server/src/usage.rs` — rate-limit windows, asked **of each machine that + can run Claude**, not of the backend. Credentials are read through the + session `Transport`, so a remote setup is an ssh round trip and the local + one is unchanged; the HTTP call stays here. A machine with no Claude + provider is never asked. The four states (`ok`, `notLoggedIn`, + `unreachable`, `failed`) exist because a machine nobody logged in on is a + choice rather than a fault, and one `error` string made it look like one. +- `server/src/models.rs` — downloaded GGUF models and the HuggingFace + browsing behind them. Downloads are keyed by the model rather than by + who asked, so any device can watch one; they resume through HTTP Range, + refuse to resume onto a partial from a different revision, and are + checked against HuggingFace's published sha256 before the file gets its + real name. +- `server/` — Rust backend (`ai-server`). `main.rs` bootstraps (TLS, the + auth layer, token/QR enrollment, wg0 binding), `routes.rs` has the HTTP + table in its module doc comment, `auth.rs` the bearer-token middleware, + `config.rs` the persisted schema (written in the shared RON house rules), + `session/` the manager (registry pattern), `Driver` trait + event model, + `EchoDriver`, and transcripts. +- `app/` — Compose Android app, single `:androidApp` module, package + `com.example.aiapp`, label "AI Sessions". `AppRoot.kt` is the navigation + `when`; `MainScreen.kt` the root's four tabs (sessions, import, models, + setups) with settings and refresh on the title row; `Api.kt`/`EventStream.kt` + the REST + SSE clients; `Events.kt` the event model mirror; + `ServerConfig.kt` settings + Keystore-sealed token; screens in + `SessionListScreen/SessionScreen/SpawnScreen/SettingsScreen`. + `Notifications.kt` is the foreground service holding the notification + stream and the one place that decides where a notification is said -- + nothing for the session on screen, a `SessionAlerts` banner while the app + is up, Android's drawer otherwise, never two of them. See PLAN.md's + "Notifications: two places, never both". + **Icons are 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: 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 — that is + what makes two icon buttons the same width without either being given + one, and it is why `GLYPH_SIZE` is smaller than it looks like it should + be. +- `.dev-updater.ron` — what Dev Updater is asked to do with this checkout: + the server (built in `server/`, run as `service: Managed(...)`) and the + APK (built in `app/`), built in parallel. The project it serves is the + repository, not either half of it, which is why this sits at the root + rather than in `app/`. + It points at `resources.ron` beside it, which says this project keeps its + state as `ai-app` — so the Uninstall dialog offers `~/.local/share/ai-app` + and `~/.config/ai-app` instead of saying it cannot tell. That file is + *ours*, not Dev Updater's: it ignores keys it doesn't know, so anything + else worth keeping in one place belongs there too. Note what deleting the + config directory takes with it — the CA under `certs`, which is the + one-way door described below. + `Managed` means Dev Updater supervises `ai-server` with its own built-in + service implementation rather than a script kept here. ai-app had such a + script until 2026-08-28 and it was the generic case exactly — no + arguments, no environment — so the two projects were maintaining one + behaviour twice, including the OpenRC branch neither can test from a + systemd machine. + Worth knowing before pressing it: **Stop** on the server card stops the + server that a phone reaches through the tunnel, so on that phone it stays + down until someone starts it again from Dev Updater. Dev Updater reaches + it over its own port and is unaffected, which is what makes the button + safe to press and easy to regret. +- `wg-app-link/` — a **git submodule**, and the half of this backend that + dev-updater also needed: 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`). Both projects had written all five and they had drifted; see + that repo's `README.md` for the diff that decided each one. Clone with + `git clone --recurse-submodules`, or `git submodule update --init` in an + existing checkout — `server/` will not build without it, since it is a + path dependency rather than a registry one, which is what keeps the two + projects version-locked to the commit this repo pins. + The certificates are the one-way door: the CA is generated once on first + start into `$XDG_CONFIG_HOME/ai-app/certs` and regenerating it strands + the installed app. + What deliberately did **not** move is the API surface and the config + *schema* — routes, drivers, sessions and setups are what makes this + project itself. +## Status + +Phases 1–3 done 2026-08-24 (PLAN.md's phase list says what each verified): +the skeleton pipe, the full Claude driver (streaming, tools, permission + +AskUserQuestion cards, steering, interrupt, `--resume` crash recovery, +images both ways), and the usage screen. + +**Phase 5 (SSH)** is written and exercised (2026-08-28): a session names a +host, `session::transport` turns that into an `ssh host …` invocation, and +the driver never learns which it got. + +**Phase 4 (llama.cpp)** works end to end, phone included (2026-08-28). +Models are browsed and downloaded from HuggingFace (`models.rs`, resumable +and verified), and `session::llama` runs one through `llama-server` over +its OpenAI-compatible streaming endpoint. Two things are deliberate and +easy to undo by accident: the conversation is rebuilt from the +**transcript** rather than kept in the driver, because driver memory is +invisible to a second device; and a llama session is refused on an ssh +host, because the model is reached over HTTP and forwarding that port is +not built. + +Setups — machines, each carrying what it can run — are added, renamed, +re-probed and removed from the app; providers are **discovered by asking +the machine**, never typed, so the enrolled token cannot introduce a +command. What is left is real-phone/WireGuard bring-up, which is +operational rather than code. + +**`command -v` follows PATH under a non-interactive ssh session**, which is +not the PATH a login shell shows, so a binary somewhere unusual is +invisible to discovery — llama.cpp unpacked into `~/.local/opt` needs a +symlink into `~/.local/bin` before a setup finds it. The escape hatch for +anything odder is editing `config.ron` on the backend, deliberately the one +authority the phone does not have. + +**Testing llama.cpp here:** the prebuilt CPU build 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. + +**How to test SSH here, since there is no second machine:** 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.** Note the remote login shell +here is **fish**; the remote script (`cd '…' && exec '…'`) 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 isn't either is the thing to suspect +first if a remote spawn ever mangles an argument. + +## Checking your work + +- Server: `./run-tests.sh` from the repo root (or `cargo test` from + `server/`) + + `cargo clippy --all-targets` + `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: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat + :androidApp:compileDebugKotlin :androidApp:lintDebug` — format, typecheck + and lint, the app-side equivalent of the line above. Then `./build-apk.sh` + to produce the APK to install on a phone (through Dev Updater), or + `./run-android.sh` to build, install, and launch on the emulator. +- **A row something is happening to is dimmed, drained of colour, inert, + 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, and the inertness is the overlay consuming pointer events rather + than each caller remembering to disable its own click handler. +- **The import screen selects in batches: hold to enter, tap to add.** The + options that act on a selection appear along the bottom, and are Delete + and Import only. Submitting clears the selection immediately and marks + every chosen row -- the one in flight as "importing" or "deleting", the + rest as "waiting" -- so the bar goes away and the affected set is what + says the work is happening. Rows are taken out as each one lands rather + than all at the end: a finished row still sitting there looks exactly + like one that has not been imported, and tapping it starts a second CLI + on the same transcript. What that costs is that the rows below slide up + under the reader's finger, so a row that has just moved ignores taps for + half a second (`SETTLE_MS`). +- **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. +- **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. It is 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. +- **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) and + generated into a constant. 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. +- 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 — capture it from the log. +- **`app/debug-transcript.sh` puts a real conversation on the emulator.** + The echo driver stays the right rig for most things and is 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. `-b` takes the biggest conversation on + the machine rather than the newest, which is what a scrolling test wants; + `--stop` takes it all down again. + 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. +- **`app/ui-sandbox.sh` is the rig for anything that lists or deletes + sessions.** It starts 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. Neither is a price worth paying to + look at a list. It shares the real TLS certificates, because the + installed APK pins that CA, so run it while the ordinary server is down. + It passes `--delay` by default for the reason the next entry gives. +- **`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. +- **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. Sibling to `debug-transcript.sh`, and the two cover different + halves: reach for this when what is under test is *whether a process is + running*, and for the script when it is *what the transcript draws*. + (From the ai-app-2 session, 2026-08-30, which found a clock bug with it + that the tests did not have.) +- 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` (`~/.config` when that is unset), 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 it with + `adb -s "$SERIAL" shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"` + (quote so the device shell doesn't eat the `&`s). +- **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 — refusing when the machine has no room for one; `emu list` + says what is attached and what it costs; `emu down` stops it. + `run-android.sh` is that plus a build and an install. Run that repo's + `install.sh` once if `emu` is missing. + The `adb` on `PATH` after sourcing `android-env.sh` is that repo's wrapper, + which fills in `-s` from the same rule — so a bare `adb shell` reaches this + checkout's emulator and refuses to reach another one's. That defaulting is + what makes the old advice unnecessary rather than wrong: with two attached + and no `-s`, a bare `adb shell pm list packages` comes back **empty**, + which reads as the app having been uninstalled rather than as the question + being ambiguous. + **Gradle does not go through that wrapper**, so it had the same hole until + 2026-08-31: `installDebug`, `uninstallDebug` and `connectedAndroidTest` ask + the adb server for every attached device and act on all of them, which is + how one session's debug build landed on another's emulator. A Gradle init + script from `emulator-tools` now runs `emu check` before those tasks and + fails the build rather than fanning out. When it refuses, say which device + you mean at the moment you use it — `ANDROID_SERIAL=$(emu serial) + ./gradlew …` — rather than exporting a serial into the shell, which goes + stale the next time an emulator restarts and another checkout's takes the + port. + +## Where things run (host vs this VM) + +Established 2026-08-25. The machine itself — the two boxes, the shared +`~/repos` mount, and why the VM is untrusted — is described once in +`~/.claude/MACHINE.md`; what follows is only what that means here. + +- **`ai-server` belongs on the host in production.** That is where the LAN + address the phone can reach is, and where WireGuard terminates. + `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 — + `ai-server` with no `--bind` — is exercisable during development. It has + no reachable peer and doesn't need one. Consequence: **with no `--bind` + the emulator can't reach the server** (it dials 10.0.2.2), so keep using + `--bind 127.0.0.1` for app work. +- `./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's how to verify the wg0-only posture. +- **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: a configured host, + and a session that names it. +- **Nothing secret goes in the repo**, which is shared with the host and + attacker-writable under this project's threat model (PLAN.md's security + section). State lives outside it: `$XDG_CONFIG_HOME/ai-app/config.ron` + and `certs/`, `$XDG_DATA_HOME/ai-app/sessions/`, owner-only. +- Certificates are generated **by the server, on first start**, into + `$XDG_CONFIG_HOME/ai-app/certs` (`--certs` overrides). The CA is created + once and left alone; the leaf is reissued every start, so covering a new + address is a restart. Starting the server in the VM therefore makes a + separate throwaway dev CA — never install a build pinning that on the + real phone. +- Point development at a scratch state directory rather than the real one: + `--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`. + +## Sessions outlive the backend + +Since 2026-08-29 a session's process is **deliberately left running when +`ai-server` stops**, and adopted again when it starts — so restarting the +backend does not end a turn. PLAN.md has the design; what matters day to +day: + +- **Stopping the server no longer stops the sessions.** After `pkill + ai-server` the `claude` processes are still there, on purpose, and the + next start picks them up (`reattaching to the claude-cli it left + running` in the log). To end one, either `POST /sessions/{id}/stop` — + which keeps the session and its transcript, and `POST .../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.** `POST + .../message`, `.../command` and `.../compact` go through + `SessionManager::send_message` and `::run_command`, which start a process + first when the session is known to have exited and then hand the thing to + the driver that has one behind it. Only on `exited`: `unknown` has a + process that may well be reading its fifo. `/rename` starts one too, and + for a sharper reason than the rest: the CLI keeps its own copy of the + name, that copy is what its session picker and other agents' session + lists show, and a session is only ever *given* a name at birth — every + later start is a `--resume` — so a rename that reached no process would + leave the two lists disagreeing for good. Its save happens before the + telling, so a failure there says the telling failed rather than the + rename. 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** (2026-08-30). It picks up + the processes still running and leaves every other session as it found + it: listed, with its transcript and its stream, reporting `exited`, with + no process and no driver until somebody asks for one. Restarting the + server used to relaunch a driver for every session, which started a CLI + for each one that had none — so a session stopped on purpose came back at + the next rebuild, and the `Idle` the new driver announced stamped every + row as active just now. If you are looking for a stopped session's + process after a restart, there is deliberately none; press Start, or send + it anything. +- **A launch never moves a session's clock.** A status it has to correct is + written at the time of the last thing the session actually did, not at + `now()`, and a session that has never done anything reports + `SessionConfig::created` rather than the clock — its transcript is empty, + since a driver announcing the state it starts in is not news, so there is + no line to read a time off. Both are the same rule as + `Transcript::last_activity`: a restart has been told nothing, so it must + not claim anything happened. +- **A session spawned while testing cleans itself up: `--throwaway-sessions`** + (2026-08-30), which a **debug build defaults to on**. Every session + spawned by such a server is marked `throwaway: true` in `config.ron`, and + its process is stopped — SIGTERM, then SIGKILL after + `process::STOP_GRACE` — when the server exits or is sent SIGTERM/SIGINT. + Sessions outliving the backend is right for the ones somebody is using + and wrong for the ones a test made: those leave a `claude` behind that + every later server adopts, and they pile up unnoticed (twelve on this + machine in a day, each holding a conversation open). + Two things worth knowing. The flag decides only what **new** sessions are + marked as; what happens on the way out is decided by the **mark**, which + is the session's own — so a session you spawned deliberately keeps + running whichever server is up when one exits, and a throwaway one is + cleaned away even by a server started without the flag. And the waiting + is not optional: `process::stop` leaves its SIGKILL on a tokio timer, + which a runtime that is shutting down never runs, so + `process::wait_gone` does the waiting on the way out. Pass + `--throwaway-sessions=false` to keep what a development server spawns. +- **A process that has exited but not been reaped reads as dead**, not + alive. `/proc//stat` keeps the entry — same pid, same start time — + until the status is collected, so a zombie used to answer "still there", + which made `exited` unsayable: the session showed `unknown`, its Start + button never appeared, and stopping it said there was nothing to stop. + `process::stat_of` reads the state field alongside the start time. +- **Each session directory now 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. +- **`--resume` only ever runs when nothing is running.** That check is the + fix for the incident below, and the reason there is one entry point + (`ClaudeDriver::launch`) rather than a spawn and an attach. The status a + launch reports obeys the same rule: a session recorded as `exited` whose + launch has just started a process reports `idle`, because `exited` is the + word that refuses every command and offers a phone the chance to start a + second CLI on a live conversation. +- **`exited` is never taken on trust; it is checked against the process + record** (`corrected` in `session/mod.rs`). It is the one status that draws + the phone's Start button and lets `start_session` build a driver, so a + record that is not known to be dead makes it false and the session reports + `unknown` instead. Without that, a session adopted at a backend start kept + the transcript's `exited` while its CLI was running, Start was accepted + every press, and each press left another reader on the same process — + which reads on screen as one reply written several times, interleaved + (`GotGotGot it — it — it —`), not as anything to do with a button. + A driver that `start_session` replaces gets `Driver::detach` for the same + reason: swapping the `Arc` does not end the tasks the old one is running. +- Remote sessions are adopted too. The pid recorded for one is the **`ssh` + client's**, on this machine — that is the process the backend owns, and it + lives as long as the remote command does. (This said "local only" until + 2026-08-29; the code never had that branch.) Note the far `claude` always + has an sshd pipe on stdin whichever version started it, since the fifo is + on the backend's side — so you cannot tell a backend's version by looking + at a remote session's stdin. + +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 now — it reads `~/.claude/sessions/.json`, which Claude +Code keeps for every live session, and checks the pid's start time so a +descriptor left by a crashed CLI doesn't count. Refused rather than warned +about, because on 2026-08-29 an agent imported the session it was *itself* +running in. That put two `claude --resume` processes on one file: the whole +65 MB conversation, 154 embedded screenshots included, was re-appended to +the transcript under a new prompt id, both copies replayed each other's +writes as work done elsewhere, and the adopted one was billed for re-reading +all of it. It ended at the account's session limit, with three `claude` +processes running against one checkout. + +## Things that have bitten + +Project-specific only — a lesson that would bite any project on this +machine belongs in `~/.claude/TOOLCHAIN.md` (toolchain versions) or +`~/.claude/MACHINE.md` (the machine itself) instead. + +- **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's why the auth middleware has a single + combined gating+logging test. +- **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 doesn't 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. +- **A reconnecting phone used to be sent the entire backlog.** The SSE + stream replayed everything after the client's cursor, unbounded, while + *opening* a session was bounded to a page — so a long disconnect + delivered thousands of events one frame at a time. Past + `CATCH_UP_LIMIT` the stream now sends a `reset` frame and the newest + window instead, and the client rebuilds from it exactly as it does when + the screen opens. The reset is not optional: without it the window is + spliced onto rows that are no longer adjacent to it, which reads as + ordinary output. +- **The five-hour window has no reset time between blocks, and that is not + a missing value.** The usage API anchors it to the block it started in -- + measured 2026-08-31, the reset came back as exactly five hours after work + resumed, and the weekly windows in the same response carried the identical + microsecond, so both are computed from one `now()` at request time. When + no block is running there is nothing to reset and `resets_at` is `null`; + the same response shows other idle windows with the same shape. The weekly + ones always have a reset because a week is always running, which is why + "the others seem fine". + So `resets_at` absent means **not running**, and only a timestamp that + arrives and cannot be parsed is unknown. The app collapsed both into one + null and the session bar said "reset time unknown" for a machine behaving + perfectly -- while the usage dialog, reading the same field, quietly drew + nothing. `WindowEnd` in `ResetCountdown.kt` is now the one rule both go + through. +- **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: + on a 21 MB, 24,000-event transcript one page took ~500ms of server time to + return 620 KB, and took the same 500ms whichever page was asked for. A + phone scrolling back paid it per page and every stream reconnect paid it + again to find out nothing had happened. 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 and only the + window is built. 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. + `RUST_LOG=ai_server=debug` logs each page with what was asked and what + came back, which is how to see a phone paging back in real time. +- **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, and 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. Measured at the server, which is + the one number here that does not depend on how the emulator renders: + against a 24,000-event transcript, ten swipes asked for ten pages before + and three after. +- **What the transcript screen costs to scroll, for whoever measures it + next.** Taken 2026-08-30 on the GPU emulator (`emu up` provides one; a + frame number from the software rasteriser means nothing -- see + `~/.claude/MACHINE.md`), against a real imported transcript with the debug + 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 and + what is left is the emulator rather than the app. 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`.** +- **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`, over every assistant message loaded, on + every page. Both are off it now. The shape to watch for is a + `withContext` that wraps the *fetch* and leaves the work done with the + result outside 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. `EnrollmentScanActivity` also + turns off the library's 10% framing-rect inset (it decodes only what is + inside it) and its laser/result-point decorations. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a0c9ee1 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1031 @@ +# ai-app — plan + +A phone interface to AI coding sessions — Claude Code and llama.cpp for now — +built to replace the Claude app for day-to-day use. Two motivations: local +models need a front end at all, and owning the client means fixing the things +the official app gets wrong (e.g. it won't deliver a typed message until the +session fully finishes its turn, 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. + +## The one idea everything hangs off + +Both session types are **a child process speaking JSONL over stdio**: + +- Claude Code: `claude -p --input-format stream-json --output-format stream-json` + — bidirectional streaming JSON. User messages sent while a turn is running + are injected at the next opportunity (the TUI behavior we want), a control + protocol carries interrupts and permission requests, `--resume ` picks a + session back up after a backend restart. +- llama.cpp: **pi in RPC mode** (`pi --mode rpc`), pointed at a llama-server + endpoint. Same deal: JSONL on stdio, `prompt` (with images), `steer` for + mid-run injection, `abort`, `set_model`, `compact` / `set_auto_compaction`, + session files that survive restarts, structured events for streaming text + and tool executions. + +So the backend has one abstraction — spawn a process, translate its dialect to +a common event stream, keep an append-only transcript — and two translators. +SSH support falls out of the same shape: a remote session is the identical +command run as `ssh `; stdio doesn't care. + +Decisions already made (2026-08-24): + +- llama.cpp harness: **pi RPC now**, with the session abstraction kept clean + enough that a custom Rust agent loop can be added as a third driver later. +- The backend **manages llama-server itself** (start with a chosen GGUF, stop, + swap models), locally and over SSH. +- Claude permission prompts are **interactive in the app**, with a per-session + permission mode chosen at spawn. +- **One backend** on the main machine; the phone talks only to it, and it + reaches other hosts via SSH. Remote hosts need the CLIs installed but no + backend. + +## Architecture + +### Setups and providers (decided and built 2026-08-28, superseding the below) + +**A setup is a machine, and it carries the providers that machine has.** +Optional ssh details, plus the list of what can be run there. Spawning is +then two choices in order: pick a setup, then pick one of its providers. + +This replaces the independent providers × hosts model recorded below, +which is what the code does today. What went wrong with it: the two axes +are not actually independent. A provider is only real on a machine where +that CLI is installed, so a free cross-product offers combinations that +cannot work — `claude-cli` on a machine with no `claude`, and every +provider paired with a host the driver ignores entirely (`EchoDriver` +takes no host, so "Run on" is a control that silently does nothing for +it). Grouping providers under the machine they exist on makes the picker +show only what is true. + +Settled while building it: + +- **Echo is seeded, not implicit.** It lives in the setup with no ssh, + because it runs in-process and has no transport to cross. It is written + into `config.ron` on first run rather than conjured at read time — a + provider nobody can see in the file is one nobody can edit from the + phone, which is the opposite of what this app is for. +- **Migrated once, then the migration was deleted** (2026-08-28). Unknown + fields default away, so a `providers:`/`hosts:` file would have loaded as + an empty config and then been seeded over, losing everything silently. + The first answer to that was to *refuse* such a file, which was the wrong + trade and proved it: this process is how a phone reaches the backend at + all, so refusing to start stranded the person who would have to fix it, + as a crash loop with nothing reachable to explain it. It was replaced by + a migration that kept the token hashes, backed the old file up, and + rebuilt the rest — which is discoverable now anyway. + That migration has since run on the one host there is, so it is gone + again, per the standing rule that migration code is deleted once the + update carrying it has been received. With one backend and one phone, + nothing is left on the old shape, and a second parsing path nothing + exercises only constrains later changes to the schema. A file in the old + shape now fails to parse, which is correct because no such file exists. +- **Still to do: editing setups from the phone.** `GET /setups` exists; + writing them is not built, so a new machine is still a hand edit on the + backend. That is the remaining gap against the standing preference that + configuration be reachable from the app. Key material is the honest + exception — a setup names an identity file that must already exist on + the backend machine, because a private key must not travel. + +The superseded model, for the reasoning it recorded: + +Two independent axes, configured separately and chosen per session: + +- A **provider** is *what* runs: a driver kind, the command to invoke, and + the models worth offering. `claude-cli` is the first — named for the CLI + specifically, since bare "claude" would suggest the credit-billed API, + which this is not. llama.cpp becomes a second provider later. +- A **host** is *where* it runs: an ssh target. Absent means the backend + machine itself. + +Sessions name both. Keeping them independent is what the motivating setup +requires: the backend runs on the machine the phone can reach (where +WireGuard terminates), which is not necessarily where a CLI is installed — +here the Claude CLI lives only in a VM on that machine, while llama.cpp +will be on the host itself. Pinning a host into a provider would make "the +Claude CLI" and "the Claude CLI over there" two things to configure and +choose between, and would stop the same provider from being sent somewhere +else for one session. + +``` +Android app (Compose) + │ HTTPS (pinned CA) — REST for actions, SSE for live events + ▼ +backend (Rust/Axum, desktop) + ├─ SessionManager ── Session ── Driver (trait) + │ ├─ ClaudeDriver (claude stream-json) + │ └─ PiDriver (pi --mode rpc) + │ each driver's process is spawned locally or as `ssh host …`, + │ decided per session by the host it names + ├─ LlamaServerManager (llama-server lifecycle, local + SSH) + ├─ UsageMonitor (Anthropic OAuth usage endpoint) + └─ config.ron + per-session transcript files +``` + +### Backend layout (`server/`) + +Mirroring dev-updater's stack: axum 0.8, axum-server + rustls, tokio, serde, +clap, tracing. Rust edition 2024, warning-clean, clippy in CI habit. + +- `main.rs` — bootstrap, TLS listener. +- `routes.rs` — the whole HTTP table in one module doc comment (as in + dev-updater). +- `session/mod.rs` — `SessionManager`: the live session registry, every + mutation funnels through it (the `registry.rs` pattern: in-memory and + on-disk state can't come apart). +- `session/driver.rs` — the `Driver` trait and the common event model. +- `session/claude.rs`, `session/pi.rs` — the two translators. +- `session/transcript.rs` — append-only JSONL event log per session, with + monotonically increasing sequence numbers (the phone's resume cursor). +- `llama.rs` — `LlamaServerManager`. +- `ssh.rs` — the ssh command builder (host configs ended up in `config.rs` + with the rest of the schema, so this module is only the wrapping; named + for what it does rather than `hosts.rs` as first sketched). +- `usage.rs` — Anthropic usage polling. +- `config.rs` — persisted schema. +- `certs.rs` — the TLS certificates, generated in process on first start + (added 2026-08-25, replacing a `gen-dev-cert.sh` that shelled out to + openssl). +- `private.rs` — creating files and directories owner-only. One module + owns the modes so "nothing this server writes is readable by anyone + else" is checkable in one place instead of re-argued at each `create` + (added 2026-08-25; config, certs, and session dirs had three copies). +- `media.rs` — the image media-type/extension table, shared by the four + places that have to agree on it: storing an upload, serving it back, + handing one to a driver's dialect, and saving one a tool produced. + +`session/pi.rs` and `llama.rs` are phase 4 and not built yet; everything +else above exists. + +### The common event model + +Driver output, whatever the dialect, is normalized into one event enum before +it touches the transcript or the phone: + +- `UserMessage { text }` — what the user sent, echoed into the transcript + by the manager (not by drivers) so every device renders the conversation + from the one stream. (Added 2026-08-24 during phase 1: without it, + reconnects and second devices would lose the user's side.) +- `AssistantText { delta }` — streaming text (rendered as markdown). +- `ToolStart / ToolUpdate / ToolEnd { tool, input, output }` — the "view tools + it's running" screen is just these. +- `Image { ref }` — images in output (screenshots from tools, etc.) are saved + under the session dir and referenced by id; the phone fetches them by URL. +- `Question { id, prompt, options }` — anything the session needs a human for: + Claude's AskUserQuestion, and **permission requests** (canUseTool) are the + same shape with approve/deny options. Answered via one endpoint. +- `Answered { id, answer }` — the manager's record of a question being + answered, so a rendered question card resolves on every connected device, + not just the one that answered (added 2026-08-24, same reasoning as + `UserMessage`). +- `Status { state }` — idle / running / awaiting-input / compacting / exited. +- `UsageDelta { tokens, context }` — what a turn cost, and how much the + model was holding when it ended, where the dialect reports them (both do). + `context` is prompt plus both cache figures, taken from the **last + assistant message** rather than the turn's `result`: measured 2026-08-30 + against CLI 2.1.237, the result adds a turn's messages up, so its cache + read of 40,211 was the same conversation counted twice and no size the + model ever held. It is carried rather than summed by readers because it + goes *down* — a compaction replaces it with what the compaction reports, + and a clear leaves it unmeasured. `driver::context_after` is that rule, + and the phone folds with the same one (2026-08-30: this replaced a running + spend total, which could only climb and so kept reporting a context a + compaction or a clear had already taken away). + A session the server has no measurement of asks the CLI's own file + instead of waiting for a turn — `import::context_of`, the same three + fields the import list reads, in the background at load so a start never + waits on an ssh. A clear needs no special case: it gives the CLI a new + session id, so the lookup lands on a file with no usage in it and + answers "unknown", which is true. +- `Error { message }`. + +Every event is appended to the session's transcript file with a sequence +number, then fanned out to any connected SSE subscribers. The phone renders +purely from this stream: reconnecting means "give me events after seq N" — +no separate "load history" path to drift from the live one. + +Inbound, the driver trait is small: + +```rust +trait Driver { + fn send_user_message(&self, text: String, images: Vec); + fn answer_question(&self, id: QuestionId, answer: Answer); + fn interrupt(&self); // stop mid-run, session survives + fn set_model(&self, model: &str); + fn compact(&self); // pi: native; claude: /compact + fn shutdown(&self); // graceful process exit +} +``` + +`send_user_message` during a run is the point of the whole app: both dialects +queue it for injection at the next tool boundary rather than the end of the +turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`. + +### Claude driver specifics + +- Spawn: `claude -p --verbose --input-format stream-json --output-format + stream-json --permission-mode ` in the chosen working directory, plus + `--model` at spawn. Permission mode (default/plan/acceptEdits/ + bypassPermissions) is chosen on the spawn screen. +- Interactive permissions: run with the stream-json control protocol's + permission request flow (the same mechanism the Agent SDK's `canUseTool` + uses) so tool approvals arrive as control requests, become `Question` + events, and our answer goes back as the control response. **Verify the + exact control-request wire format against the current CLI early in + implementation** — it's the least-documented part of this plan. +- Interrupt: control-protocol interrupt request. +- Model change mid-session: try the control protocol's set-model; if the + installed CLI doesn't support it, fall back to `shutdown` + respawn with + `--resume --model ` — cheap, since Claude persists + sessions in `~/.claude/projects` anyway. That resume path is the recovery + story for a process that has genuinely died; a backend restart never takes + it — it adopts the process that is still there, and starts nothing for the + session that has none (see below). + **Resuming is only ever safe when nothing else has that session open.** +- Images in: base64 image content blocks in the stream-json user message. +- Working directory, host, and model are spawn-screen fields. + +### Session processes outlive the backend (decided 2026-08-29) + +A session's process is **left running when the backend stops, and adopted +again when it starts.** Restarting the server — a rebuild, a service +restart, a crash — must not end a turn somebody is waiting on, and a turn +can easily be minutes long. + +What this replaces: `shutdown_all` asked every driver to stop, then the +process exited immediately. The SIGKILL escape hatch was a timer inside the +runtime that died with it, so the stop was unreliable; whatever survived was +orphaned with nothing written down to find it by. Processes leaked either +way. The change is that they are now left on purpose and can be picked back +up. + +How it works, all inside the session directory beside the transcript: + +- `process.json` — the pid, the kernel's **start time** for that pid, and + how much of the output log has been read. The start time is what makes + the pid an identity: pids are reused, and adopting a stranger's would mean + never resuming the real conversation and signalling something unrelated. +- `stdin.fifo` — opened **read-write** and inherited by the process, so it + is its own last writer and never reads EOF when the server goes away. + Closing stdin therefore stops being the graceful-exit signal; ending a + process is a signal now, and only `Driver::stop` does it. +- `stdout.log` / `stderr.log` — plain appended files, read from a byte + offset. A fifo would fill its 64 KB buffer and block the process while + nothing was draining it, which would stall the very turn the leak exists + to protect. Measured: the CLI writes to a file unbuffered, so streaming is + unaffected. + +Two consequences worth stating: + +- **`--resume` is reachable only when nothing is running.** This is the same + rule as the import refusal below, and for the same reason: two CLIs on one + session file duplicate the conversation into it and bill the second for + re-reading all of it. +- **Remote sessions are adopted too, and the recorded pid is the `ssh` + client's.** This was written down as "local only" and that was wrong about + the code: `start` records a pid whatever the transport, and for a remote + session the process the backend owns *is* the ssh client. Adopting it is + coherent — the fifo still feeds it, its logs still capture the far end's + output, and `ssh` lives exactly as long as the remote command does, so its + liveness is the session's liveness. + The consequence worth knowing: **the remote `claude` always has an sshd + pipe on stdin, under old code and new alike**, because the fifo is on the + backend's side of the connection. So the far process's stdin says nothing + about which version of this server started it. + +`Driver` therefore has two ways out rather than one: `detach` (the server is +going away and means to come back) and `stop` (the session is being deleted, +so the process must not survive). Every driver owes exactly one of them. + +### Stopping and starting a session's process (decided 2026-08-30) + +If a session outlives the backend, the person holding the phone needs the +other direction too: **end the process without ending the session, and start +it again on the same conversation.** `POST /sessions/:id/stop` and +`/start`. + +Three decisions worth not undoing: + +- **Stop signals the recorded process and says nothing else.** It does not + go through the driver and it does not announce `Exited`. The record is the + session's rather than any dialect's, so signalling it here works for a + session whose driver is in no state to be asked and adds no trait method a + new driver could implement wrongly — and the driver's own reader already + reports the death correctly, draining the last output and recording the + status. Announcing it from here would be a guess arriving ahead of the + measurement, and wrong for the grace period a process that ignores SIGTERM + keeps running. +- **Start replaces the driver and nothing else.** The transcript, the event + pump and the SSE stream every open phone is reading stay where they were, + so starting a session again is not a reconnect for anybody watching, and + there is still exactly one writer of the transcript — which relaunching + the whole `LiveSession` would not be, since the old pump outlives its + session and an imported session's sync task would go on feeding it. + `LiveSession` and `Commands` therefore share one `Mutex>` + rather than each holding a copy. +- **Start is refused unless the session is *known* to have exited.** + `Unknown` means nobody could find out whether the process is alive, and + starting one on that is exactly the two-CLIs-on-one-conversation fault + `session::process` exists to prevent. + +That last rule found a real bug in the launch path, which is where the phone +would have hit it: a relaunched session took its status from the transcript, +so one whose process had died before a backend restart reported `Exited` +while the launch it had just gone through was starting a new process — +`Exited` there is not merely stale, it is the word that refuses every command +and invites somebody to start a second process against a live conversation. + +**Who says so matters as much as what is said.** The first fix wrote `Idle` +straight into the manager's view, and that produced a second bug on the +phone: the session list reads the manager's status and the session screen +replays the transcript, so a status written in one and not the other is two +screens disagreeing about one session — visible as a stop button that turned +into a play button a moment after the screen opened. So the rule is that +**a driver announces the state it starts in, through the event sink**, which +is what `EchoDriver::new` and `LlamaDriver::attached` already did; +`ClaudeDriver` was the one that started a process silently. It says `Idle` +only when it *started* one — adopting says nothing, because a process that +was already running may be mid-turn and the transcript's last word is the +better answer until its output says otherwise. Coming from the driver also +orders it against the exit `follow` reports, which a status written from the +manager could not be. + +**`Exited` is a claim about a process, and the record is what settles it.** +Adopting saying nothing left one word standing that a live process +contradicts. A session whose process was reported gone and then found again +at the next backend start kept `Exited` from the transcript — and `Exited` is +the word that draws a Start button. Start was then accepted every time it was +pressed, and since starting replaces the driver, each press attached *another* +reader to the one process: every line the CLI wrote was translated once per +reader, so three presses put three interleaved copies of one reply on screen. +Two rules come out of it, and neither is optional: + +- **`Exited` is checked against `session::process` before it is believed** — + `corrected`, called in `launch` and again in `start_session`. A record that + is not known to be dead makes it false, and what replaces it is `Unknown`: + there is a process, and nothing here has heard from it, which is the answer + `status_of_unlaunched` already gave to the same question. Every other status + is left exactly as it was — those are the pump's, written from what the + process itself said, and none of them authorises starting anything. The + correction goes out through the sink for the reason above: written into the + manager's view alone it would be the list and the screen disagreeing again. +- **A driver that is replaced is detached.** Swapping the `Arc` does not end + the tasks the old one is running. `Driver::detach` is what does — it already + existed for the backend going away — and it is the whole of what a driver + whose process has exited is owed. + +**A message or a command starts the process if there isn't one** (decided +2026-08-30). Refusing was work handed back: read the status word, find the +other button, press it, type the thing again. Both plainly mean "do this +now", and `--resume` puts the new process on the same conversation, so +nothing about what was typed changes — only whether there was anything there +to read it. A rename is included, and for a sharper reason than the rest: +Claude Code keeps its own copy of the name, that copy is what its session +picker shows and what other agents read when they list sessions, and a +session is only ever *given* a name at birth, since every later start is a +`--resume`. So a rename that reached no process would leave the two lists +disagreeing permanently, with this app's the only one that had moved — and +the cost of a resume buys the one thing renaming is for. It stays +`rename_session` rather than becoming a command like the others, because the +name is persisted and listed as well as forwarded and that is one operation; +the save happens first, so a failure to start reports that the telling +failed, not the rename. The manager's `send_message` and `run_command` and +the Start button ask one function +(`start_if_exited`) and want opposite answers from it: "there is already a +process" is a refusal worth showing to somebody who pressed Start, and +nothing at all to a message. Deciding it in one place under one write lock is +also what stops two requests arriving together from starting two CLIs. Only +`Exited` starts anything, for the reason above — `Unknown` has a process that +may well be reading its fifo, and what was typed goes to the driver as it +always did. + +A command needs one thing a message does not. `Commands::submit` refuses on +`Exited`, and a driver that has just started a process announces `Idle` +through the sink rather than writing it — so a command judged against the +session's own status would be refused by the word the start had just +replaced. `start_if_exited` returning `Exited` is what says a process was +started, so `run_command` judges against `Idle` from there rather than +re-reading a status the pump may not have caught up with. + +The phone's half is that the process button is disabled while its own request +is in flight, so a second press cannot be decided against a status the first +one has not changed yet. That is a courtesy rather than the fix: the server +refuses the second request either way, because a phone that has lost the +stream cannot be relied on to know. + +On the phone this is one button in the composer, left of Send, whose mark and +colour say what pressing it would do now: an orange pause while a turn is +running (interrupt — the process stays), a red stop when it is not (end the +process), and a green play when it has exited (start it again). One button +rather than three that come and go, so its presence is never the signal. + +### A backend start adopts, and starts nothing (decided 2026-08-30) + +Starting the server is not something a session should be able to tell +happened. `SessionManager::new` takes charge of the processes that are still +running and **leaves every other session exactly as it found it** — listed, +with its transcript, its event pump and the SSE stream a phone reads, and no +driver at all until somebody asks for one. + +What it did before was launch a driver for every session in the config, and +`ClaudeDriver::launch` starts a process when there is none to adopt. So a +session somebody had deliberately stopped came back at the next rebuild, +which is the decision Stop exists to make being undone by an unrelated +event — and since a driver announces `Idle` for a process it started, the +session was also stamped as active at the moment of the restart. On the +phone that read as *every* session idle and "just now" after every restart, +with the list — sorted by that time — in an order that meant nothing. + +- **`Launching` is the parameter that says which it is**, and the seed an + import carries rides on the asked-for variant, because a restart re-seeding + a transcript would write the imported conversation into it twice. +- **A session with no process has no driver.** `DriverCell` is an option + rather than a driver whose requests go nowhere, so "nothing is running + this" is a state the code can be asked about instead of one it discovers by + sending into a dead fifo. `LiveSession::ask` is the one place that answers + it, with an `Event::Error` naming what could not happen — a request nobody + can carry out is reported, never swallowed. +- **`--resume` on a crashed session is now a press rather than a restart.** + That is the whole of what is given up, and it is small: a session whose CLI + died reports `Exited` and draws the Start button, and *sending it anything + at all* starts it (above). What is bought is that the two are told apart by + who asked, rather than a restart guessing that everything it found should be + running. +- **What a launch settles the status to is written into the transcript, at + the time of the last thing the session actually did.** Adopting, the + transcript's word stands except for the `Exited` a live process disproves. + Taking charge of nothing, every word but `Exited` is disproved at once — a + backend killed mid-turn leaves a transcript saying `Running`, and that + draws a stop button for a turn that ended hours ago. The correction goes in + the transcript because the list reads the manager's status and the session + screen replays the file; it is stamped with the transcript's own last time + because it is not something the session did — this server noticed, at a + moment of its own choosing, and `now` there is the same lie in the same + field that `Transcript::last_activity` exists to prevent. +- **A session that has never done anything reports when it was created.** Its + transcript is empty — a driver announcing the state it starts in is not + news, so nothing is written — which makes it the one session with no line to + read a time off. The clock was the fallback, so a session nobody had sent + anything to climbed to the top of the list at every restart. Not the + transcript file's mtime, which is the same instant for an empty file and a + worse answer for a shared checkout that can be copied or touched; + `SessionConfig::created` is recorded rather than inferred. + +### Sessions spawned while testing clean themselves up (decided 2026-08-30) + +`--throwaway-sessions`, **on by default in a debug build**. Every session +spawned by such a server is marked `throwaway` in the config, and a marked +session's process is stopped when the server exits or is signalled, instead +of being left for the next start to adopt. + +Leaving processes running is the design and it is right for the sessions +somebody is using. It is exactly wrong for the ones a test made: those leave +a `claude` behind that every later server adopts, they cost tokens if +anything ever speaks to them, and nothing ever says they are there — twelve +accumulated on this machine in a day. An agent testing this app should not +have to remember a cleanup step, and "remember to" is not a mechanism. + +- **The flag marks; the mark decides.** What a server was told at startup + governs only the sessions it spawns, and the mark is written into the + session, so it outlives that server. A session spawned deliberately keeps + running whichever server happens to be up when one exits, and a throwaway + one is cleaned away even by a server started without the flag. The + alternative — the exiting server stopping whatever it happens to have + marked in memory — makes cleanup depend on which process is up, which is + the thing that fails at exactly the wrong moment. +- **Stopping is not asking.** `process::stop` sends SIGTERM and leaves its + SIGKILL on a tokio timer, and a runtime that is shutting down never runs + it. That is precisely how the original `shutdown_all` leaked the processes + it reported stopping, so the exit path waits for them with + `process::wait_gone` — one deadline for all of them, since they were + signalled together — and kills whatever is left. `Driver::stop` is the + per-driver half, the same one a delete uses; only the waiting differs. +- **A zombie is dead.** Found by the test for the above: `/proc//stat` + keeps the entry, with the same pid and the same start time, until the exit + status is collected — so a process that had plainly finished answered + "still there" for as long as nothing reaped it, and `Liveness::Alive` is + the word that makes `Exited` unsayable. The state field is read alongside + the start time now. This was reachable outside the test: anything that + blocks the runtime delays tokio's own reaping. + +### Importing refuses a session that is already open (decided 2026-08-29) + +Claude Code keeps a descriptor per live session at +`~/.claude/sessions/.json` carrying the `sessionId` and a `procStart` +— the same pid-plus-start-time identity used above. So "is this session +open right now" is a **measurement**, not a heuristic, and the import list +reports it as `no` / `yes` / `unknown`. Three answers because a machine that +keeps no such record cannot answer, and "could not check" is not "nobody is +using it". + +`yes` is refused. This is not hypothetical: on 2026-08-29 an agent imported +the session it was itself running in. Two `claude --resume` processes then +edited one checkout and appended to one transcript, the whole 65 MB +conversation — 154 embedded screenshots — was duplicated into the file under +a new prompt id, and the adopted copy re-read all of it. It ended at the +account's session limit. + +### pi driver specifics + +- Spawn: `pi --mode rpc --provider openai-generic --model ` (endpoint = + the llama-server the LlamaServerManager provides), `--session-dir` under + our session storage so transcripts and pi's own session files live together. +- Auto-compaction on by default (`set_auto_compaction`), threshold + configurable per session; manual `compact` exposed as a button. +- `steer` for mid-run messages, `abort` for stop, `set_model` when the target + endpoint changes. +- pi's session JSONL gives resume-after-restart, same as Claude's. + +### Models (built 2026-08-28) + +The owner asked for listing and downloading models from HuggingFace and running +them with different parameters, which makes model management part of the +feature rather than something done by hand beforehand. + +- **A download belongs to the model, not to the request.** Keyed by + `owner/repo/file.gguf` and owned by the server, so a second device can + watch one it did not start, and so an hour-long fetch survives a phone + locking its screen. Every run has an id and its outcome outlives it, + because "not downloading" otherwise means finished, never started, or + someone else's run ended while you were away. +- **Progress is measured.** `total` is Content-Length, or Content-Range's + last field on a resumed request, and absent when the server says + nothing — never an estimate. +- **Resume is guarded by identity, not by hope.** A partial carries the + ETag it was written against; a mismatch discards it. `If-Range` would be + the tidy mechanism but HuggingFace's CDN ignores it (probed + 2026-08-28). The published sha256 is checked before the file is renamed. +- Parameters reach a driver as an untyped `params` map on the session, so + the shared schema does not grow llama.cpp's vocabulary. + +### llama-server management + +`config.ron` lists **models** (name → GGUF path or llama-server args, per +host) and **hosts**. The manager runs at most one llama-server per +`(host, model)`, spawned on demand when a session needs it: + +- Spawn (local or `ssh host llama-server …`) on an allocated port, wait on + `/health`, hand the endpoint to the pi driver. +- Refcounted by sessions. The path out, written in the same change as the + spawn: the last session using an instance releasing it starts an idle + timer (configurable, e.g. 10 min), after which it's killed. Delete of the + last session kills it immediately. +- "Change model" on a llama session = acquire the new model's server, + `set_model` on pi, release the old one. Context carries over (it's + prompt-replayed by pi against the new endpoint). +- Remote llama-server output is only reachable from the backend host, and + binds localhost on the remote side with an SSH local port forward + (`ssh -L`) held by the manager — no LAN-exposed inference ports. + +### SSH + +- Host entries in `config.ron`: name, `user@host`, optional ssh options, + which capabilities it has (claude / pi / llama-server, with paths if not on + PATH). Key-based auth only, using the system `ssh` client via + `tokio::process` — no Rust SSH library; this inherits `~/.ssh/config`, + agents, and jump hosts for free. (Rule 23: openssh is already here and + battle-tested; a library buys nothing but a second config surface.) +- A remote session is exactly a local one with the command wrapped in + `ssh -T host …`. Process death ≙ connection death; the session shows as + `exited` and both dialects resume (`--resume` / pi session file) on respawn, + so a dropped SSH connection is an annoyance, not data loss. +- **The transport wraps the driver, not the other way round** (decided + 2026-08-28). A driver says what to run — program, arguments, working + directory — and something above it turns that into a process, locally or + through ssh. Today `ClaudeDriver::spawn` calls `ssh::command` itself, + which puts transport knowledge inside a translator whose job is a wire + format, and means every future driver has to remember to do the same. + Inverting it also removes the "Run on" lie for free: a driver that emits + no command, like the echo one, has nothing for a transport to wrap, and + the picker can say so. +- The interface that inversion needs is **not just "run a command"**, and + llama.cpp is the case that shows it: a managed `llama-server` is started + as a process but then spoken to over HTTP, so a remote one needs a + forwarded port (`ssh -L`) as well as a spawned process. A transport is + therefore "run this" plus "reach this port", and the second operation is + a no-op locally. +- Attachments need no file transfer, contrary to what this section said + before: `attachment_block` base64s an uploaded image into the + stream-json message itself, and produced images come back the same way + for the translator to write out locally. Nothing has to exist on the + remote filesystem, so there is no `scp` step to get wrong. + +### Usage limits (Claude) + +Poll `https://api.anthropic.com/api/oauth/usage` — the same endpoint behind +Claude Code's `/usage` — with the OAuth access token from Claude Code's local +credential store (`~/.claude/.credentials.json`), headers +`anthropic-beta: oauth-2025-04-20` and `User-Agent: claude-code/` +(without the User-Agent it lands in an aggressively rate-limited bucket). +Poll at ≥180 s, only while any Claude session exists or the usage screen is +open, cache the last answer. Surface: 5-hour and weekly window utilization % +and reset times. It's undocumented, so `usage.rs` treats every field as +optional and degrades rather than erroring. Structure it as one +`UsageProvider` per paid service so a second service later is a new impl, +not a parallel screen (rule 9). + +**Per machine, not per backend (decided 2026-08-29).** The credential store +that matters is the one on the machine the session runs on, because that is +the account being billed. Reading this machine's was right only while the +backend and the CLI were the same box — and in the layout this is aiming +at they are not: `ai-server` belongs on the host, the host has no `claude` +CLI, and the CLI machine is a remote. So credentials are read through the +session `Transport` (`ssh host sh -c 'cat $HOME/…'`, `$HOME` expanded by +the far shell because a path built locally is the wrong home), one snapshot +per setup that offers Claude, cached per machine. The HTTP call stays on +the backend rather than running remotely, so the far end needs nothing but +a shell. + +The snapshot says which of four things happened rather than carrying a flag +and a message: `ok`, `notLoggedIn`, `unreachable`, `failed`. The one that +matters is `notLoggedIn` — a machine nobody put an account on is working as +configured, and collapsing it into an error string made a healthy setup +read as broken. A machine with no Claude provider is not asked and gets no +row at all. + +### HTTP surface (phone ⇄ backend) + +REST for actions, one SSE stream per open session screen for events, all over +the pinned TLS listener. SSE over WebSocket because resume-by-cursor +(`Last-Event-ID` = transcript seq) is native to it and the inbound direction +is plain POSTs anyway. + +``` +GET /providers what can be spawned (name, kind, models) +GET /hosts machines a session can be run on +GET /sessions list (id, provider, host, title, model, status, last activity) +POST /sessions spawn {provider, host, model, cwd, permission_mode, title} +GET /sessions/:id/events?after=N SSE: transcript replay from N, then live +POST /sessions/:id/message {text, attachment_ids} +POST /sessions/:id/answer {question_id, answer} (questions and permissions) +POST /sessions/:id/interrupt stop the running turn; the process stays +POST /sessions/:id/stop end the process; the session and transcript stay +POST /sessions/:id/start run the process again, continuing the conversation +POST /sessions/:id/model {model} +POST /sessions/:id/compact (llama sessions) +POST /sessions/:id/attachments multipart upload → id (referenced by /message) +GET /sessions/:id/files/:ref images the session produced or was sent +DELETE /sessions/:id kill process, release llama-server, delete transcript+files +GET /usage cached usage windows +GET/PUT /hosts, /models config editing from the phone +``` + +Sessions live in `config.ron` (`$XDG_CONFIG_HOME/ai-app/`) + a per-session +directory under `$XDG_DATA_HOME/ai-app/sessions/` (transcript.jsonl, +attachments, produced images), owner-only. Deleting a session is the +complete path out of everything spawning one created. + +### Security + +- TLS with a self-signed CA, pinned in the app — same + idempotent-CA/reissued-leaf scheme as dev-updater, same one-way-door + caveat about regenerating the CA, but generated **in process on first + start** (`certs.rs`) rather than by a shell script calling openssl + (2026-08-25). One place then decides the extensions, the file modes, and + which addresses the leaf covers — every local IPv4 plus loopback and the + emulator's host alias, so nobody maintains a hardcoded IP — and there is + no setup step to forget. + - 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). Decided + 2026-08-25, and it does three things at once — the trust anchor follows + the build machine, so an APK built on the backend host pins that host + and one built in the dev VM pins the VM's throwaway CA and 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 can't quietly disagree with the server. +- **The dev VM is untrusted** (decided 2026-08-25): a machine that isn't + malicious but could become so. It matters because the repo is a + read-write virtiofs mount shared between the VM and the backend host, so + under this model everything in it — source, `server/target/` binaries, + and the shell scripts the host runs, some with sudo — is + attacker-writable. Two consequences: + - **Nothing secret lives in the repo.** Certificates are generated on + the machine that serves them and written to + `$XDG_CONFIG_HOME/ai-app/certs` (0700, keys 0600); `config.ron` and + session transcripts go to the XDG config and data directories, per + machine. A CA private key the VM could read would let it mint a leaf + the pinned app accepts, which is precisely the attack pinning exists + to stop — pinning against a CA the attacker holds is no pinning at + all. Transcripts move for a plainer reason: they are whole + conversations. As a bonus this ends the host and VM sharing one + config, which had already produced a test token live on the backend, + and takes state out of reach of `git clean -xdf`. + - **The host should not execute what the VM can write** — build and run + the backend from a host-only checkout rather than the shared mount. + Moving the keys closes the smaller door; this is the larger one. + - Development in the VM generates its own throwaway CA. Whatever is + installed on the real phone must pin only the host's. + - The CA key is not needed by the server at all (only `leaf.pem` and + `leaf-key.pem` are read), so it can move offline once the setup is + stable; reissuing a leaf is the only time it is wanted. + - Not addressed, and accepted: a compromised VM can return anything it + likes from the sessions it runs, since running an agent there is the + point. The blast radius is that session's content, not the backend. +- This server is strictly more dangerous than the updater: its API *is* + remote code execution (spawn a bypass-permissions Claude on any SSH host). + Pinning authenticates the server to the phone but not the phone to the + server, so a bearer token adds the other direction. Threat model: the token + gates LAN-reachable RCE; it does not (and cannot) defend a compromised + backend host or phone — those are inside the trust boundary, and a + compromised phone is handled by rotation. + - **Generation**: 256 bits from the OS CSPRNG on first run, base64url. A + machine credential, never typed twice, so unguessable costs nothing; at + this entropy no key stretching is needed. + - **Enrollment**: printed once as a terminal QR code (`qrcode` crate, + ANSI), encoding `aiapp://enroll?host=…&port=…&token=…`. The CA stays + embedded in the APK (`PinnedCert.kt` pattern), so the QR carries no + trust material — photographing the terminal leaks only the token + (rotatable), never a way to weaken pinning. The app registers an intent + filter for the `aiapp://enroll` scheme as a fallback, for a camera app + that redirects a scanned URI straight to `MainActivity` (2026-08-24). + That was meant to be the only path — "the app side needs no QR library + at all" — but reversed the same day: not every phone's stock camera + redirects a scanned URI to an app reliably, so the Settings screen also + scans in-app via `zxing-android-embedded`'s `ScanContract` (a ready-made + scanner Activity reached through the AndroidX Activity Result API, + fully offline, no Play Services/ML Kit model download) and feeds the + decoded URI to the same `parseEnrollmentUri` (2026-08-25). + - **Storage**: server keeps only the SHA-256 in `config.ron` (plain hash + is enough for high-entropy random input; buys that a leaked config + doesn't leak the credential). No "show token again" — lost means rotate. + Phone side: sealed with an Android Keystore AES-GCM key (a small + hand-rolled helper in `ServerConfig.kt` — Jetpack's + EncryptedSharedPreferences is deprecated with no drop-in successor, and + Google's guidance is now "use Keystore directly"; 2026-08-24). + - **Transport**: `Authorization: Bearer` header on every request including + the SSE GET. Never a query parameter (URLs leak into logs). The tracing + layer must not log the header — covered by a test so a logging change + can't silently start leaking it. + - **Verification**: one middleware wrapping the entire router in `main.rs`, + never per-route, so a new route can't forget auth. Zero unauthenticated + endpoints, `/health` included. Hash-then-constant-time-compare + (`subtle`); failures logged with peer address plus a small fixed delay — + not against brute force (infeasible at 256 bits) but so scanners show up + in the log. + - **Rotation (the path out)**: `--rotate-token` regenerates, invalidates + the old hash immediately, reprints the QR. That's the whole lost-phone + story. Config stores a *list* of `{name, hash}` (of one, today) so + per-device tokens with individual revocation are a config entry later, + not a schema migration. + - **Why not mTLS**: stronger in theory (key never leaves the Keystore, no + bearer secret to exfiltrate), but given pinning the delta is only + "someone reads the token off a device already inside the trust + boundary", and it costs Android client-cert provisioning ceremony and a + worse new-phone story than a QR scan. Revisit if this outgrows + single-user-on-LAN. +- **Off-network access: plain WireGuard** (decided 2026-08-24; no third + party). The backend binds to the WireGuard interface (`wg0`) only; the + phone runs the official WireGuard app (always-on VPN, per-app tunneling), + enrolled by scanning its config as a terminal QR + (`qrencode -t ansiutf8 < phone.conf` — same gesture as token enrollment). + The only internet-visible thing is one forwarded UDP port that is silent + to unauthenticated packets — scanners see it as closed — so the app's + pre-auth surface (rustls handshake, hyper parsing, auth middleware) is + reachable only from enrolled peers, and the token becomes defense in depth + rather than the sole gate. Addressing stays single-path: the phone reaches + the backend at its WireGuard address (e.g. `10.66.0.1`) from everywhere — + one address in the app, one SAN in the leaf cert (`SERVER_IP=`/SAN + override in the cert script), no home/away distinction. Another machine + later is one keypair + one `[Peer]` block. + - Operational needs, accepted: a public endpoint hostname. The home IP is + mostly static but not guaranteed, so the phone's endpoint is a DDNS name + (free, e.g. DuckDNS, or the router's built-in client; a curl cron on the + backend host works too) that tracks changes automatically. One WireGuard + nuance: the phone app resolves the endpoint hostname when the tunnel + comes up and does not re-resolve on its own, so on the rare IP change + the fix is toggling the tunnel off/on once DDNS has caught up (minutes). + The symptom is obvious (app can't reach the backend) and lossless — the + SSE cursor design means reconnects replay whatever was missed. Also: + at-home traffic rides NAT hairpinning on the router (verify early; most + support it, and the fallback is toggling the tunnel off at home). + - Rejected: **Tailscale** — same WireGuard underneath with easier setup + (no port forward, LAN peer discovery), but it adds a third-party + coordination service and account this setup doesn't need at two or + three devices; **Headscale** — self-hosting that coordination server is + strictly more moving parts than one wg config per peer at this scale; + **forwarding the HTTPS port directly** — puts every internet scanner + one pre-auth bug away from RCE on a machine holding SSH keys. + - The server still refuses to start without TLS — no plaintext listener + exists even inside the tunnel, so the token can't travel unencrypted by + misconfiguration, and interface binding failing closed (refuse to start + if `wg0` is absent, rather than falling back to 0.0.0.0) is part of the + same guarantee. Development gets `--bind ` as an *explicit, logged* + override (loopback for curl, a LAN address for a pre-WireGuard phone) — + a deliberate flag, never a fallback, so the fail-closed default is + untouched (2026-08-24). +- The bootstrap-over-HTTP trick from the updater is unnecessary here — the + app installs via Dev Updater. + +## App (`app/`) + +Kotlin + Compose Multiplatform, single `:androidApp` module, same versions as +dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: + +1. **Session list** — cards: kind icon, title, host, model, status + (running / awaiting answer / idle / exited), last activity. Spawn FAB; + swipe/long-press to delete (confirm). Sessions awaiting an answer sort to + the top — that's the "your turn" inbox. +2. **Spawn** — kind, host (from config), model (Claude list is static+editable; + llama list from config), working directory, permission mode (Claude), + title. +3. **Session screen** — the core: + - Transcript rendered from the event stream: markdown text, inline images, + collapsed-by-default tool cards (name + input summary, expandable to + output; a spinner while `ToolStart` has no matching `ToolEnd`). + - Question cards inline: option buttons for AskUserQuestion, allow/deny for + permissions, free-text where allowed. + - Expanding a row keeps still **the end nearest the tap**: touch a row's + upper half and its top edge holds, so it opens downwards; touch its + lower half and the bottom edge holds, as the list does by default. + Which half, rather than which control, so that everything that opens + behaves alike whether or not it has a control at each end — a group's + heading and foot bar simply fall in the halves they already occupy. The transcript is laid out from the + bottom, so a row's bottom edge is anchored for free and the top one + has to be arranged. The correction lives in the *layout* phase + (`Modifier.holdTopEdge`): the measurement that discovers the row's new + height asks the list to shift by that much, via + `requestScrollToItem`, before anything is drawn. Doing it from an + effect instead means the wrong position is drawn once first, which + reads as a flick and gets worse the faster the screen refreshes + (2026-08-30, asked for after groups opened upwards and sent their own + heading off the top of the screen). + - Input bar: text, attach (camera/gallery/file), send — **always enabled**; + mid-run sends become steering messages. + - Top bar: model chip (tap to change), stop button while running, token + count, compact button (llama), overflow → delete. (The count settled as + context held rather than tokens spent, and sits on the status row under + the transcript — see `UsageDelta` above.) +4. **Usage** — window bars for the 5-hour and weekly limits with reset times. +5. **Settings** — server address + token, hosts editor, llama model list + editor. + +Networking mirrors dev-updater's app layer (`AppsApi.kt` style thin client + +pinned transport), plus an SSE client with `after=` resume driven by +connectivity/lifecycle. The app keeps no persistent transcript store — the +backend's transcript is the source of truth; the app caches only for the +screen it's showing. + +### Notifications: two places, never both (decided 2026-08-30) + +The backend's `GET /notifications` is one SSE stream of attention-wanting +moments, and the app decides where each one is said. Three outcomes, in one +place (`NotificationService.show`): + +- **Nothing at all** if the session is the one on screen. The transcript in + front of the reader is already saying it. +- **A banner over the app** if the app is up — `SessionAlerts`, queued, one + per session replacing that session's own, dismissable by a push in either + direction, and otherwise retiring itself when the bar across its foot runs + out. Tapping one opens the session, through the same path a tapped + notification uses. +- **A row in Android's drawer** otherwise, which is what the foreground + service exists for. + +Never two of them for one moment. A notification that has already been shown +in the app is not something to also find in the shade afterwards, and a +drawer that fills up behind an app that showed you each one is a drawer +nobody reads. + +Which of the three applies is answered without a flag anybody has to keep +level: the session on screen is registered by the one composable that draws +one, and "the app is up" *is* the banner queue being collected, since it +collects only while it is on screen. + +The alternative considered and rejected was giving the app its own +connection to `/notifications` while it is in front. That is a second stream +per device saying the same thing, and it puts the "which of these two shows +it" decision in two processes' worth of code instead of one function. + +### Deferred polish + +Noticed and deliberately not fixed yet, so they are not re-found from +scratch. None is a defect; each is a decision waiting for the app to have +been used enough to say which way. + +- **The session screen's header is lopsided.** The row is `padding( + horizontal = 8.dp)`, so the status on the right sits exactly 8dp from the + edge while "Back" on the left is a `TextButton` whose touch target is + wider than its text — the same 8dp reads as more. It is the "align the + mark, not the box" case: either align the button's content or size the + button to what it draws, rather than nudging with a hardcoded offset. + +## Compaction: options explored + +Context: raw llama-server has no conversation memory management; the context +window just fills. + +1. **pi's auto-compaction** — *chosen*. When the prompt nears the model's + context limit, pi summarizes older history with the model itself and + replaces it with a structured summary; threshold configurable; manual + `compact` also exposed. Battle-tested, zero work for us. +2. **Manual compaction in a custom Rust loop** — *the later third driver*. + The design when we build it: every llama-server response reports prompt + + completion token counts; track them against `n_ctx` (from `/props`); at a + threshold (~75%), pause, run a summarization request over all but the last + few turns ("state of the task, decisions made, open items, relevant + file/tool state"), replace those turns with the summary as a system-adjacent + message, continue. Keep the full pre-compaction transcript on disk — the + phone view never loses history, only the model's view shrinks. Worth doing + eventually for control over the summarization prompt and for tool-loop + experiments pi doesn't allow. +3. **llama-server `--context-shift`** — *rejected* as the strategy. It + truncates old KV cache entries: silent forgetting, no summary, and it + corrupts the harness's view of what the model knows. Fine as a server-side + safety net; not memory management. + +## Phases + +1. **Skeleton** — *done 2026-08-24.* Repo layout, cert script, TLS + token + auth, wg0-bound listener (fail closed if the interface is missing), + config.ron, session registry with a fake `EchoDriver`, session list + + session screen in the app end-to-end over SSE. Proves the whole pipe + before any AI is involved. Verified: 10 server tests + clippy clean; + curl end-to-end over pinned TLS (auth rejection, spawn, SSE + replay/resume by cursor, question round trip, restart continuing seq + numbers, delete); the app on another checkout's emulator against the real + server (QR-style enrollment via deep link, spawn, streamed echo turn, + question answer, tool card). +2. **Claude local** — *done 2026-08-24.* ClaudeDriver: spawn, stream + text/tools, mid-run send, interrupt, permission questions, + AskUserQuestion, images both ways, delete. + *Milestone: daily-drivable Claude replacement on localhost.* + Wire-format notes live in `session/claude.rs`'s module doc (pinned + against CLI 2.1.237): permissions need the hidden + `--permission-prompt-tool stdio` flag; AskUserQuestion answers ride + `updatedInput.answers` keyed by question text; `set_model`/`interrupt` + are control requests; 2.x permission modes are acceptEdits / auto / + bypassPermissions / manual / dontAsk / plan (no more "default"). + Attachments/files were re-homed under `/sessions/:id/…` (table above) + so their lifecycle is the session directory's — delete stays the + complete path out. +3. **Usage screen** — *done 2026-08-24.* The undocumented endpoint's + `limits[]` array parsed defensively into labeled window bars; cached + behind the ≥180 s minimum with no background polling. +4. **llama.cpp** — LlamaServerManager (local), PiDriver, model change, + compaction controls. *Deferred (2026-08-24): pi/llama-server aren't set + up in this VM, so this phase isn't testable here — Claude first; the + driver seam is ready when it is.* +5. **SSH** — host config, remote spawn for both kinds, remote llama-server + with port forward, attachment shipping. *Host config and remote spawn + done 2026-08-25* (any session of any provider can name a host; the + command is the identical one wrapped in `ssh -T`, with every argument + shell-quoted). Attachment shipping turned out to be unnecessary for the + Claude driver — images ride the stdio JSONL as base64 in both + directions, so nothing needs `scp`. Still outstanding: remote + llama-server with its port forward, which comes with phase 4. + Two things learned doing it: a remote session inherits ssh's non-login + PATH, which is narrower than an interactive shell's (point `command` at + an absolute path if a CLI isn't found), and the remote command is run + with `exec` so dropping the connection takes the CLI down rather than + orphaning it. +6. **Polish** — reconnect edges, notification when a session awaits an answer + (the "your turn" push), transcript search, whatever daily use surfaces. + +Each phase ends runnable and verified against the real thing (rule 22); the +backend gets tests where logic is pure (event normalization, transcript +cursors, config persistence, refcounting) — the app is UI over the API and is +verified by running it, matching dev-updater's posture. + +## Open questions / risks + +- **Claude stream-json control protocol details** (permission requests, + set-model, interrupt wire format) are the least-documented dependency and + version-coupled to the installed CLI. Phase 2 starts by probing the + installed version and pinning what works; the `--resume` respawn fallback + covers whatever the control channel can't do. +- The **usage endpoint is undocumented** and has changed rate-limit behavior + before; treat as best-effort. +- **pi RPC schema drift** — pin a pi version; the translator is one file. +- Whether **notifications** need FCM or a foreground-service polling + connection — decide in phase 6; the SSE cursor design already supports + either. +- Claude sessions over SSH need the remote host **logged in to Claude**; usage + reporting reads only the backend host's credentials. Acceptable for now + (same account everywhere); revisit if not. + +## References + +Research behind the decisions above (verified 2026-08-24; re-check against +installed versions when each phase starts): + +- pi RPC protocol: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md + — commands (`prompt`, `steer`, `follow_up`, `abort`, `set_model`, + `compact`, `set_auto_compaction`, session ops) and the event stream. +- pi + llama-server in practice: https://medium.com/@tolgaeren/running-pi-with-local-llms-c596aa14b062 +- llama-server API (`/health`, `/props`, OpenAI-compatible endpoints, + `--context-shift`): https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md + and the offline-agentic-coding walkthrough: + https://github.com/ggml-org/llama.cpp/discussions/14758 +- Usage endpoint (`GET https://api.anthropic.com/api/oauth/usage`, bearer + token from `~/.claude/.credentials.json`, headers + `anthropic-beta: oauth-2025-04-20` + `User-Agent: claude-code/`, + ≥180 s polling; wrong User-Agent → aggressive 429 bucket): + https://github.com/anthropics/claude-code/issues/31637 and + https://github.com/Maciek-roboblog/Claude-Code-Usage-Monitor/issues/202 +- Sibling project this repo's conventions mirror: `../dev-updater` + (README.md + AGENTS.md — server/registry/routes layout, cert scheme, + testing posture, Android env notes). diff --git a/app/android-env.sh b/app/android-env.sh new file mode 100755 index 0000000..e11b2be --- /dev/null +++ b/app/android-env.sh @@ -0,0 +1,55 @@ +#!/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/androidApp/build.gradle.kts b/app/androidApp/build.gradle.kts new file mode 100644 index 0000000..236042d --- /dev/null +++ b/app/androidApp/build.gradle.kts @@ -0,0 +1,150 @@ +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" + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } + buildTypes { getByName("release") { isMinifyEnabled = false } } + 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.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.highlights) + implementation(libs.androidx.exifinterface) +} diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4ce4bd8 --- /dev/null +++ b/app/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt new file mode 100644 index 0000000..a496e19 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -0,0 +1,826 @@ +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 on failure, +// 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 + +class ApiException(message: String, 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 to read from. + * + * @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 (see EventStream.kt). + */ +fun requestFromServer( + settings: ServerSettings, + path: String, + method: String = "GET", + jsonBody: String? = null, + /** Raw request body as content-type to bytes -- the upload path. */ + binaryBody: Pair? = 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 (binaryBody != null) { + connection.doOutput = true + connection.setRequestProperty("Content-Type", binaryBody.first) + connection.outputStream.use { it.write(binaryBody.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 + } + ) + } + 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)?", + e, + ) + } catch (e: Exception) { + throw ApiException( + "Reached ${settings.baseUrl}$path but couldn't read its response " + + "(${e::class.simpleName}: ${e.message})", + e, + ) + } finally { + connection.disconnect() + } +} + +/** The response body as one JSON object. */ +private fun HttpURLConnection.jsonObject(): JSONObject = + JSONObject(inputStream.bufferedReader().readText()) + +/** The response body as a JSON array of objects, each mapped through [parse]. */ +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) } + +/** Percent-encodes a value going into a query string. */ +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 for the header's five-hour bar. + * + * It was deliberately left out until 2026-08-29, on the grounds that nothing here addressed a + * setup and holding both the id and the name invited showing the wrong one, which had already + * happened once. Something addresses one now, so the reason lapsed rather than being overruled. + * The guard that replaces it is the rule below: never show this. + */ + 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 of the conversation 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 -- this app's + * transcript holds things that record does not. + */ + val keepsOwnTranscript: Boolean, + /** How much the session asks before acting; null when it was never set. */ + val permissionMode: String?, + /** + * 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. Defaults to + * on when a backend is too old to say, which matches what that backend actually does. + */ + val notify: Boolean, + /** + * How much context this session is holding, as the server last measured it -- see + * `SessionEvent.UsageDelta`. + * + * 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 + * nearly 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: a provider that + * does not care about size should not be given a threshold this app invented. Decided by the + * server because that is where a provider's kind is known -- see `uploadPickedImage`. + */ + val maxImageEdge: Int?, + val status: String, + val lastActivity: Double, +) + +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 }, + imported = session.optBoolean("imported", false), + notify = session.optBoolean("notify", true), + contextTokens = + if (session.has("contextTokens")) session.getLong("contextTokens") else null, + maxImageEdge = session.optInt("maxImageEdge", 0).takeIf { it > 0 }, + status = session.getString("status"), + lastActivity = session.getDouble("lastActivity"), + ) + +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 switch drawn from a stale row shows a position that may have been + * changed since -- here or on another device -- and nothing on screen says which. + */ +fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = + requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } + +// 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 -- the same rule that keeps a provider's command out of this client. + */ +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. + * + * The number that predicts what continuing this session costs. 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, and a session that cannot be checked is not a session that is free. The + * server refuses an import of a "yes"; the row says so before you press it. + */ + val inUse: String, +) + +/** + * 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 that was 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, and that figure grows with every session + * anybody has. A timeout is for a server that has stopped answering, so it is set well clear of how + * long the work takes rather than just above it. + */ +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, so + // it stays null and the row simply does not claim a figure. + contextTokens = + if (session.isNull("contextTokens")) null + else session.optLong("contextTokens").takeIf { it > 0L }, + // Absent means an older backend that cannot answer, which is exactly what + // "unknown" says -- so the default is the honest one rather than "no". + inUse = session.optString("inUse", "unknown"), + named = session.optBoolean("named", false), + ) + } + } + +/** + * 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, +) + +private fun SshDetails.toJson() = + JSONObject().put("address", address).apply { + if (port != null) put("port", port) + if (!identityFile.isNullOrBlank()) put("identityFile", identityFile) + } + +/** 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, + 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 (!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(), + ) {} +} + +/** Uploads one picked image; the returned id goes into [sendMessage]. */ +fun uploadAttachment( + settings: ServerSettings, + sessionId: String, + bytes: ByteArray, + mime: String, +): String { + val boundary = "----aiapp-${System.currentTimeMillis()}" + val head = + ("--$boundary\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"image\"\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", + binaryBody = "multipart/form-data; boundary=$boundary" to (head + bytes + tail), + readTimeoutMs = 60000, + ) { connection -> + connection.jsonObject().getString("id") + } +} + +/** Fetches an image the transcript references (produced or uploaded). */ +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. + * + * How to find a particular window. 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 -- while the other two + * are faults worth chasing. 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 its own business and is decided + * on the server; nothing here joins, splits or reformats them for one. + */ +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 exactly where it is and [startSession] + * picks it back up. The server reports what it could not do -- there was nothing running, or the + * machine would not say whether there was -- 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") {} +} + +/** + * Removes a Claude Code session from the machine. + * + * The transcript *is* the session, so this ends any chance of resuming that conversation. The + * caller confirms first; see ImportScreen. + */ +fun deleteImportable(settings: ServerSettings, setup: String, sessionId: String) { + requestFromServer(settings, "/setups/$setup/importable/$sessionId", method = "DELETE") {} +} + +/** + * 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, because it was. + * + * [before] pages backwards for history somebody scrolls to; absent means the newest page. + */ +fun fetchTranscript( + settings: ServerSettings, + sessionId: String, + before: Long? = null, + limit: Int = 80, +): List { + val query = buildString { + append("?limit=").append(limit) + if (before != null) append("&before=").append(before) + } + return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> + val body = JSONArray(connection.inputStream.bufferedReader().readText()) + (0 until body.length()).map { parseSeqEvent(body.getJSONObject(it).toString()) } + } +} + +/** + * 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 and to any other agent that lists it. + */ +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") + +/** 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`. */ +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, + ) + +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 new file mode 100644 index 0000000..f2b4e09 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -0,0 +1,214 @@ +package com.example.aiapp + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +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 not here any more. They are tabs inside [MainScreen] -- four views + * of the same backend, none of them a step down from another -- and what is left in this `when` is + * only what genuinely is a step down: one session, spawning one, and settings. A session's own + * settings are not among them: they are a dialog over the session, which is where the thing they + * change is. + */ +private sealed class Screen { + data object Main : Screen() + + data class Session(val summary: SessionSummary) : 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 -- see MainActivity, which counts them. + */ +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. + */ +@Composable +fun AppRoot(settingsVersion: Int, openRequest: SessionOpenRequest?) { + 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 instead of showing a stale list. + var reloadToken by remember { mutableIntStateOf(0) } + + // A standing condition rather than a per-request failure, so it is + // stated once here instead of appended to every error that might be + // caused by it. 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, which is a + // snapshot the list already fetched, 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 -- + // see the layout note in SessionScreen. + when (val here = screen) { + is Screen.Main -> + Box(Modifier.imePadding()) { + MainScreen( + settings = current, + reloadToken = reloadToken, + onOpen = { screen = Screen.Session(it) }, + onSpawn = { screen = Screen.Spawn }, + onImported = { imported -> + reloadToken++ + screen = Screen.Session(imported) + }, + onSettings = { screen = Screen.Settings }, + ) + } + 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 event 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; every other way here passes through [Screen.Main], which disposes it anyway. + key(here.summary.id) { + SessionScreen(settings = current, summary = here.summary, onBack = goToMain) + } + 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, so it is not the + // page's job to leave room for it. Tapping one is the same act as tapping a notification, so + // it goes through the same `open`, failure dialog included. + 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 new file mode 100644 index 0000000..42ae30f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AskQuestion.kt @@ -0,0 +1,234 @@ +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.rememberScrollState +import androidx.compose.material3.CardDefaults +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.material3.TextButton +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.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +/** + * Every question one tool call is waiting on. + * + * All of it comes from the question events themselves -- what each option means, what picking it + * would produce, whether several may be picked at once. 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. + */ +@Composable +fun AskUserQuestionBody( + asks: List, + onAnswer: (questionId: String, answers: List) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + asks.forEach { ask -> + Spacer(Modifier.height(12.dp)) + AskedQuestion(ask) { answers -> onAnswer(ask.id, answers) } + } + } +} + +/** + * 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. A question is the same thing either way, and two renderings of it would be two + * places for an answer to go missing. + */ +@Composable +fun AskedQuestion(ask: TranscriptItem.QuestionCard, onAnswer: (List) -> 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)) + if (ask.answers.isNotEmpty()) { + // Joined for reading only: they arrived as a list and stay one everywhere else. + Text( + "Answered: ${ask.answers.joinToString(", ")}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return@Column + } + if (ask.multiSelect) { + MultipleChoice(ask.options, onAnswer) + } else 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, onAnswer) + } else { + ask.options.forEach { option -> + OptionCard(option, selected = false) { onAnswer(listOf(option.label)) } + } + } + OtherAnswer(onAnswer) + } +} + +/** + * Options that can be chosen together, with one button to send them. + * + * The answer goes back as the list it is. What a provider makes of several answers is decided where + * that provider is spoken to -- Claude Code's answers map holds a string, so they are joined there + * -- and nothing on this side has to know that. + */ +@Composable +private fun MultipleChoice(options: List, onAnswer: (List) -> Unit) { + var chosen by remember { mutableStateOf(setOf()) } + options.forEach { option -> + OptionCard(option, selected = option.label in chosen) { + chosen = if (option.label in chosen) chosen - option.label else chosen + option.label + } + } + Spacer(Modifier.height(4.dp)) + OutlinedButton( + // In the order they were offered rather than the order they were tapped: the reader is + // answering a list, and it should read back as that list. + onClick = { onAnswer(options.map { it.label }.filter { it in chosen }) }, + enabled = chosen.isNotEmpty(), + ) { + Text(if (chosen.size <= 1) "Send answer" else "Send ${chosen.size} answers") + } +} + +/** + * 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, which is the failure a tint step routinely produces on a dark theme. 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, and the reader + * cannot tell that it was ever open. + */ +@Composable +private fun OtherAnswer(onAnswer: (List) -> Unit) { + var text by remember { mutableStateOf("") } + Row(Modifier.fillMaxWidth().padding(top = 8.dp)) { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("Other") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { onAnswer(listOf(text.trim())) }, enabled = text.isNotBlank()) { + Text("Send") + } + } +} + +/** + * 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 does not read as a bug: it reads as those having been the only choices, which is the worst + * way for a list of choices to be wrong. + */ +@Composable +fun AnswerOptions(options: List, onAnswer: (List) -> Unit) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(), + ) { + options.forEach { option -> + OutlinedButton(onClick = { onAnswer(listOf(option.label)) }) { Text(option.label) } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt new file mode 100644 index 0000000..556aad5 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Attachments.kt @@ -0,0 +1,104 @@ +package com.example.aiapp + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +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 of this on a phone is the upload, not the decode. What the limit *is* comes from + * the server, per session -- see `DriverKind::max_image_edge` -- because that is where a provider's + * requirements are known, and a phone that carried its own copy of them would be a second place to + * update when one changes. + */ +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, bytes, mime) +} + +/** + * 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, and there is + * nothing to gain from it. 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 = + resolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw ApiException("couldn't read the picked image") + 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, with + // nothing anywhere saying so. 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/BusyItem.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt new file mode 100644 index 0000000..67786cb --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/BusyItem.kt @@ -0,0 +1,101 @@ +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 + * rather than something the reader learns once. + * + * [label] names the operation and `null` means none is running. One parameter rather than a boolean + * beside a string, which can disagree: there is no such thing as busy with nothing happening. It is + * a *word* because a spinner alone cannot say which operation this is — deleting and importing are + * different in kind, and losing a session to the wrong one is not recoverable by waiting. + * + * 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, so no caller + * had to remember — and it swallowed the drag along with the tap, which meant a list could not be + * scrolled while anything in it was busy. Consuming taps but not drags means re-deciding what a + * gesture is above the components that already decide it; disabling the click is the platform's own + * answer and leaves the scroll where it belongs. + */ +@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 what this app already used for a row on its + * way out, and it 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, + * the accent on a warning and everything else that means something by its colour stop meaning it + * for as long as the operation runs, which is exactly true: none of them is being kept up to date. + * + * Not all the way to grey. A row with no colour left is hard to find again in a list, and the + * reader is watching this one. + */ +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 new file mode 100644 index 0000000..560a411 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -0,0 +1,53 @@ +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 + +/** + * A chevron, pointing up or down. + * + * 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 both directions rather than two that differ by a minus sign -- the pair would + * drift, and the drift would be a bug in exactly one direction. + * + * It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole + * of what assistive technology has to go on, and it is also the answer to "what was that arrow for" + * six months from now. + */ +@Composable +fun Chevron( + pointingUp: Boolean, + modifier: Modifier = Modifier, + colour: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Canvas(modifier.width(20.dp).height(10.dp)) { + val inset = 2.dp.toPx() + val point = if (pointingUp) inset else size.height - inset + val ends = if (pointingUp) size.height - inset else inset + val stroke = 2.dp.toPx() + drawLine( + colour, + Offset(inset, ends), + Offset(size.width / 2, point), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + colour, + Offset(size.width / 2, point), + Offset(size.width - inset, ends), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt new file mode 100644 index 0000000..fe2a7a6 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Commands.kt @@ -0,0 +1,153 @@ +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 to whatever runs the session, because a dialect's own vocabulary is its own and grows + * without this list -- it just arrives unannounced and unexplained. + */ +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 + * and reads as having been missed. + */ +@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 new file mode 100644 index 0000000..7ed3e64 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Compaction.kt @@ -0,0 +1,68 @@ +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. It has no collapsed form -- it + * is already one line, and there is nothing behind it to open. 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, + * which is the same thing the status line says while the compaction runs. + */ +@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 -- and they are all this says, because a divider is read in passing. + * When they were not reported this says only that a compaction happened, rather than filling in a + * plausible number or explaining at length what was missing. + */ +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 of them 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, percentage or + * estimate here would be this screen's guess wearing a measurement's clothes. Knowing it has been + * going forty seconds is what a reader actually wants -- it is the difference between waiting and + * going to look at why. + * + * [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": no number is the + * honest answer, and a number counted from the moment the screen opened would be wrong in the + * direction that matters, since a compaction somebody is asking about is a long one. + */ +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 new file mode 100644 index 0000000..a174267 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/CrashLog.kt @@ -0,0 +1,66 @@ +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 -- and a + * crash report that arrives a day later, without the stack, is a guess. 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, so + * what gets cut is the part nobody reads. + */ +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. This only + * adds a witness. + */ +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 new file mode 100644 index 0000000..dcc85ea --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/DebugStats.kt @@ -0,0 +1,164 @@ +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, and a frame number taken in it says nothing about a + * 120Hz phone. 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, its own placement and its own + * recording, and this is the subtraction that was otherwise done by hand in a conversation every + * time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout, + * which grows with how many nodes are alive rather than with 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 -- a measurement happens on the frames that need one -- so these are + * shares of an average frame, not a claim about any particular one. + */ +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, and + * the whole point of it is to save one. + */ +fun debugReport( + device: String, + transcript: List, + frames: List, + accounting: List, + crash: String?, +): 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() + 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) } +} + +/** 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 new file mode 100644 index 0000000..92633c3 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Dividers.kt @@ -0,0 +1,54 @@ +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 + +/** + * 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 rather than a + * coloured phrase sitting in an unrelated grey line. + * + * Written once here rather than styled at each of them, so the two cannot drift into looking like + * different kinds of thing. + */ +@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, so there is nothing to report but the + * fact. 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) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt new file mode 100644 index 0000000..7ba2635 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Drafts.kt @@ -0,0 +1,36 @@ +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, written on every keystroke, and half a sentence surfacing + * on another device would be a surprise rather than a convenience. What has been *sent* is the + * server's, and that is the part which has to outlive this phone. + * + * Kept per session id, because the thing being typed belongs to the conversation it is aimed at: + * 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 -- so a sent message removes its own + * entry and nothing accumulates for a session in ordinary use. A session *deleted* while it held a + * draft does leave its key behind: pruning those means a pass over the live session list, which + * this file would otherwise have no reason to know about, and the residue is a few bytes per + * session ever abandoned mid-sentence. That is a trade rather than an oversight. + */ +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/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt new file mode 100644 index 0000000..20749d3 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -0,0 +1,101 @@ +package com.example.aiapp + +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +/** + * 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. + * + * 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; run() then returns instead + * of throwing, so a deliberate close doesn't surface as a connection error. The caller owns + * reconnecting (with the last seq it saw as the new cursor) -- see SessionScreen. + */ +class EventStream(private val settings: ServerSettings, private val sessionId: String) { + @Volatile private var connection: HttpURLConnection? = null + @Volatile private var closed = false + + fun close() { + closed = true + connection?.disconnect() + } + + /** + * Streams events after [after] into [onEvent] until the stream drops. + * + * [onOpen] fires once the server has accepted the connection. That is the measured moment the + * stream is live again, and the only honest thing to clear a previous failure on: an earlier + * version cleared on the first event instead, so an idle session went on displaying a + * connection error that had already been recovered from, indefinitely. + * + * [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 -- the same thing it does when the screen opens. It + * arrives before those events, so a caller that clears on it stays in order. + */ + fun run(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { + val connection = + URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection() + as HttpURLConnection + this.connection = connection + try { + connection.applyPinnedTls() + connection.connectTimeout = CONNECT_TIMEOUT_MS + // No read timeout: between events there is nothing to read for + // as long as the session 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() + // SSE framing: `data:` and `event:` lines accumulate until a + // blank line ends the frame. `id:` (the seq) is also inside the + // JSON payload, so it needs no separate handling; comment lines + // (keep-alives) start with ':' and are skipped. + val data = StringBuilder() + var name: String? = null + while (true) { + val line = reader.readLine() ?: break + when { + line.isEmpty() -> { + // A named frame carries no payload and a data frame + // has no name, so this is one or the other. + if (name == RESET_EVENT) onReset() + else if (data.isNotEmpty()) onEvent(parseSeqEvent(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})", + e, + ) + } + } finally { + connection.disconnect() + this.connection = null + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt new file mode 100644 index 0000000..3f612c5 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Events.kt @@ -0,0 +1,281 @@ +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: what an option means, + * and what picking it would produce, are the things that decide it. 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. + * + * 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 alone which message an image went with -- + * something the sender knew and could simply have said. + */ + val images: 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 or restarting the app 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, exactly as [CommandQueued] is resolved by + * [CommandSent]. + */ + data class MessageQueued(val id: String, val text: String, val images: List) : + 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) : 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, so nothing here ever draws a bubble that resolves in the same frame. + */ + 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 and by different things. 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, measured by the backend from the turn's own + * usage. 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, and on entries + * recorded before the backend sent it -- 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 + * compaction whose size nobody measured has to be able to say so, since 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 it carries nothing: what it means is entirely its + * position in the transcript. + */ + data object Cleared : SessionEvent() + + data class Error(val message: String) : SessionEvent() + + /** + * An event type this app build doesn't know -- a newer server. Kept (not 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. + * + * Absent is the ordinary case -- most messages carry no attachment, and the server omits the field + * rather than sending an empty list -- so this is the shape every caller wants. + */ +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("images"), + ) + "messageQueued" -> + SessionEvent.MessageQueued( + body.getString("id"), + body.getString("text"), + body.stringList("images"), + ) + "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")) + "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 + "error" -> SessionEvent.Error(body.getString("message")) + else -> SessionEvent.Unknown(type) + } + return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event) +} + +/** + * 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, and every event that + * moves the figure arrives here. + * + * 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 is a state each of them can reach: nothing measured yet, a + * compaction that finished without saying how much it recovered, or a clear nobody has run a turn + * since. + */ +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/FrameStats.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt new file mode 100644 index 0000000..b6dd8b3 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FrameStats.kt @@ -0,0 +1,155 @@ +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.runtime.remember +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. Guessing + * between those two is how a day gets spent rewriting the half that was already fast. + * + * The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds, + * broken down into the parts the UI thread is responsible for -- handling input, running + * animations, measuring and laying out, recording the draw -- and the parts after it. + */ +class 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() + + @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: it is 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() + } + 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)) + } + + /** 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) + + private companion object { + /** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */ + const val CAP = 20_000 + } +} + +/** + * Frame timings for as long as this screen is on 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 rememberFrameStats(): FrameStats { + val stats = remember { FrameStats() } + 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, _ -> + stats.add(metrics) + } + window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper)) + onDispose { + window.removeOnFrameMetricsAvailableListener(listener) + thread.quitSafely() + } + } + return stats +} + +/** 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/ImportScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt new file mode 100644 index 0000000..a2101fd --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ImportScreen.kt @@ -0,0 +1,602 @@ +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.foundation.lazy.items +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.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.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 it is its own state and the difference is the + * kind that matters: 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, and tapping it would import it a second time + * behind the batch already coming for it. + */ +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, which is not something a second tap can + * undo. + * + * Swallowed silently rather than shown, because anything drawn on every row a batch passes would be + * a flicker running down the list. Half a second: long enough to cover a tap already travelling + * when the row moved, short enough that it is not in the way of a deliberate one. + */ +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: it asks a setup what sessions it holds and + * shows them. 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: "importing" or + // "deleting". 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 -- + // the same arrangement the session list uses for its deletes. + var running by remember { mutableStateOf>(emptyMap()) } + // Which rows the reader has picked out. Empty means selection mode is off: there is no + // separate flag, because 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, and for the same reason: 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 and there is no timer to cancel when a + // second removal lands on top of the first. + val movedAt = remember { mutableMapOf() } + fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS + + fun loadSessions(setup: Setup) { + sessions = LoadState.Loading + selected = emptySet() + rowErrors = emptyMap() + scope.launch { + sessions = + try { + LoadState.Loaded( + withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) } + ) + } catch (err: Exception) { + LoadState.Error(err.message ?: "Couldn't list sessions") + } + } + } + + 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") + } + } + + /** + * Runs [operation] over [targets] one at a time, marking each row with [label] while its turn + * lasts and taking it off the list when it succeeds. + * + * One runner for both operations and for both the single tap and the batch, so "what a row + * looks like while something is happening to it" and "what happens when one of ten fails" are + * decided once. Sequentially, because each import starts a CLI on the machine and ten at once + * is a load nobody asked for; the reader sees the work walk down the list, which is also the + * only honest progress this screen can show. + * + * The selection is dropped the moment 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. Holding the selection until the end left the bar up over rows that could no longer be + * pressed, offering to start again something already running. + * + * A failure keeps its row and puts the server's words on it. Selecting those rows again is then + * the reader's decision rather than a state the screen carried for them — and it is the + * decision worth making deliberately, because retrying a delete that the server refused is + * usually not what somebody wants to do by pressing the same button twice. + */ + fun runOn(targets: List, label: String, operation: suspend (Importable) -> Unit) { + selected = emptySet() + running = running + targets.associate { it.id to WAITING } + scope.launch { + for (target in targets) { + running = running + (target.id to label) + rowErrors = rowErrors - target.id + try { + operation(target) + val loaded = sessions + if (loaded is LoadState.Loaded) { + // As each one lands, not all of them at the end. Holding the finished + // rows in place to keep the list still was tried and is worse: a row + // that has been imported but is still sitting there looks exactly like + // one that has not, and tapping it starts a second CLI on the same + // transcript. A row that is gone cannot be tapped at all. + // + // 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. + val now = System.currentTimeMillis() + loaded.value + .asSequence() + .dropWhile { it.id != target.id } + .drop(1) + .forEach { movedAt[it.id] = now } + sessions = LoadState.Loaded(loaded.value.filterNot { it.id == target.id }) + } + } catch (err: Exception) { + rowErrors = rowErrors + (target.id to (err.message ?: "Didn't work")) + } finally { + running = running - target.id + } + } + } + } + + val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" } + + /** + * Imports [targets], and goes to the session it made when [thenOpen]. + * + * One function for the tap and for the bar, differing in that one flag: continuing a session + * and then looking at it is what a tap on a row means, and a batch has several results and no + * reason to pick one of them to become the screen. + */ + fun importAll(targets: List, thenOpen: Boolean) { + val setup = chosen ?: return + val useProvider = provider ?: return + runOn(targets, IMPORTING) { session -> + val spawned = + withContext(Dispatchers.IO) { + spawnSession( + settings, + setup = setup.id, + provider = useProvider.name, + // Nothing to say: the server titles it from the session it is continuing. + title = "", + permissionMode = permissionMode, + import = session.id, + ) + } + if (thenOpen) onImported(spawned) + } + } + + // 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, and nothing is nudged by a number that was + // right for one font size. + 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 -> importAll(listOf(session), thenOpen = true) }, + ) + } + } + } + } + + // 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, thenOpen = false) }, + ) + } + } + + 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 + runOn(targets, DELETING) { session -> + withContext(Dispatchers.IO) { + deleteImportable(settings, setup.id, session.id) + } + } + } + ) { + // 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 rather than on the one + * somebody was thinking of. + */ +@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), + ) { + items(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 deliberately + // 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 + // than one that offers the thing it can do. + // 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 -- the reader should be able to see the + // ordering they are being given rather than infer 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, where the lines below + // it are short enough to wrap readably. 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, the + // way every other failure in this app is shown. + errors[session.id]?.let { message -> + Spacer(Modifier.height(4.dp)) + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } + } +} + +/** A byte count at the coarsest unit that still says something, so rows stay comparable. */ +private 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" + } + +/** 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. First + // of the measurements for that reason, and 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, which the model is no longer + // given, so a big file can be cheap to continue and a small one expensive. + 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. The colour is what makes it findable; the words are what make it + * actionable. + */ +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. It is just as likely another agent, or + // this app on a session it spawned. 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/LoadState.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt new file mode 100644 index 0000000..25362c0 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/LoadState.kt @@ -0,0 +1,28 @@ +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 rather than each needing its own. + */ +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 new file mode 100644 index 0000000..a0e0bd1 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainActivity.kt @@ -0,0 +1,184 @@ +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.material3.MaterialTheme +import androidx.compose.material3.Surface +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 + + // 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 then stay + * down until the app was launched again -- which is the case notifications exist to avoid. + */ + 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() + // Dark status-bar icons only over a light background, decided from the scheme rather + // than fixed. It was hardcoded to `true` -- dark icons -- which was right against the + // default light surface and became unreadable the moment the app wore Catppuccin Mocha. + // Asking the colour means a future palette change cannot reintroduce that: whatever + // `background` becomes, the icons follow it. + 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 { + MaterialTheme(colorScheme = AiAppColors) { + 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 -- every frame of the IME animation + // relays out and re-records this whole box. + 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 -- measured above as most of + // the frame budget. Each screen takes the keyboard itself + // (AppRoot wraps the ordinary ones; the session screen moves + // only its composer and transcript), so the per-frame cost is + // scoped to what actually moves. + .navigationBarsPadding() + ) { + AppRoot(settingsVersion, openRequest) + } + } + } + } + } + + // 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 `aiapp://` URI is sorted into what it means. + * + * Two things arrive this way -- an enrollment code and a notification naming a session -- and + * they are told apart by the URI's host rather than by two entry points, so a third kind is a + * branch here rather than another intent to remember to handle. + */ + private fun handleIntent(intent: Intent?) { + 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 new file mode 100644 index 0000000..0423f36 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -0,0 +1,140 @@ +package com.example.aiapp + +import androidx.activity.compose.BackHandler +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 -- the comment it replaced recorded that a fifth would have to go somewhere else. 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 of them is a step + * down from another. Settings still is a step down, which is why it stays a pushed screen and keeps + * 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, + onOpen: (SessionSummary) -> 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, or that fetched before the network came up, + // came back to "Couldn't reach the server" sitting at the top of a list the server would now + // answer for perfectly well, and nothing took it off until somebody pressed Refresh. 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 rather than a second path into each of them -- which is also what makes it cover + // all four rather than the one the report came from. + // + // Not on the first entry: the tab composing already asks, and bumping here would make every + // cold start fetch twice. + 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 -- that reads as a crash to somebody who only meant to + // get back to their sessions. Nested inside AppRoot's handler, so it wins while it is 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 -- everything below this row is one tab's business, and a control belongs + // with the thing it acts on. + // Flush against each other: a glyph button carries its own padding, so two of them + // side by side already have two rings between their marks and one ring plus this + // row's padding to the screen edge. + Row { + GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ }) + GlyphButton(SETTINGS_GLYPH, "Settings", onSettings) + } + } + // 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 to the tab below, so they are summed rather than + // tracked apart -- either one moving moves the sum, which is all a tab watches. + val token = reloadToken + refreshToken + when (tab) { + MainTab.Sessions -> + SessionListScreen( + settings = settings, + reloadToken = token, + onOpen = onOpen, + 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 new file mode 100644 index 0000000..57a3b16 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -0,0 +1,222 @@ +package com.example.aiapp + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.TextUnit +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownColor +import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.parseMarkdown +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * An assistant's reply, rendered as the markdown it is written in. + * + * The parsing is the library's. Markdown is somebody else's specification, and a hand-written + * subset of one disagrees with it at the edges -- which is where the bug reports come from, one + * case at a time. This file's whole job is the mapping onto the app's palette and type scale. + * + * Colours come from the theme rather than from the renderer's defaults, so code, links and rules + * are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. + */ +@Composable +fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modifier) { + val body = MaterialTheme.typography.bodyLarge + val parsed = parsedMarkdown(text, replies) + Markdown( + parsed, + colors = + markdownColor( + text = MaterialTheme.colorScheme.onSurface, + dividerColor = MaterialTheme.colorScheme.outlineVariant, + // The dark surface every verbatim thing in this app sits on -- see [rawSurface], + // 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 and one in a reply read as a step *up* out of the page. + codeBackground = rawSurface, + 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, + // so the table would have had a border-less grid and nothing saying where it began. + tableBackground = MaterialTheme.colorScheme.surfaceVariant, + ), + typography = + 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 and `##` at 45sp, which + // is bigger than this app's own screen titles and reads as the reply shouting. + // + // Every step is a different size, so two levels of nesting never draw the same: + // one clear step per level is the whole job of a heading. + h1 = MaterialTheme.typography.headlineSmall, + h2 = MaterialTheme.typography.titleLarge, + h3 = MaterialTheme.typography.titleMedium, + h4 = MaterialTheme.typography.titleSmall, + h5 = MaterialTheme.typography.labelMedium, + h6 = MaterialTheme.typography.labelSmall, + // Body text at the size everything else in the transcript uses. + 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, it + // is text that happens to be code, and painting all of it green said the whole + // block was one. Where a literal really does appear inside code, the thing that + // should colour it is a syntax highlighter looking at the code, which is exactly + // what a tool call's input already gets from `catppuccinSyntax`. + // + // The colour rides on the style here rather than in `markdownColor`, which + // stopped carrying `codeText`/`inlineCodeText`/`linkText` when the renderer moved + // them onto the typography. + 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() + ), + ), + modifier = modifier, + ) +} + +/** + * [text] parsed: on the composing thread the first time this row is drawn, and off it every time + * 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, every one of them blank, the whole conversation shrunk to fit a single screen; a moment + * later it was all there again. That is the "skipping up and down" this list must never do, and no + * amount of scroll anchoring can survive a row that lies about its height first. + * + * Every parse *after* the first is a different case, and it is the one that was costing: a reply + * arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured + * against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work + * in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at + * 120. Those go to a background 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. What is on screen is always a + * real prefix of the reply rather than a guess at it; it is simply one parse behind. + */ +@Composable +private fun parsedMarkdown(text: String, replies: ParsedReplies): State { + // The text each parse came from, so the first composition's is not immediately repeated. + val parsed = remember { mutableStateOf(text to replies.of(text)) } + LaunchedEffect(text) { + if (parsed.value.first == text) return@LaunchedEffect + // Not through [replies]: this is a reply still arriving, and every delta would leave + // another copy of a message that is about to be superseded. + parsed.value = + text to + withContext(Dispatchers.Default) { + DebugStats.timed("markdown reparsed while streaming") { parseMarkdown(text) } + } + } + return parsed.value.second +} + +/** + * 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 they + * appeared in. That is the lag when a block loads. + * + * Nothing here changes what a row does when it has no answer waiting: it parses inline, on the + * composing thread, because a row measured at nothing before it is measured at 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 -- [warm] runs on a background thread as each page of + * history arrives, which is seconds before anybody reaches the rows it brought. + * + * A miss is not stored, and that is what bounds this: the map holds one entry per message a page + * warmed and nothing else, so a reply still streaming cannot fill it with hundreds of copies of + * itself on the way to being finished. It is dropped with the screen, and emptied by the stream + * reset that drops the rows it describes. + */ +@Stable +class ParsedReplies { + private val parsed = ConcurrentHashMap() + + /** + * How each message divides into blocks, cached beside the parses of those blocks. + * + * Here rather than in a `remember` because the answer is wanted on two threads: by [warm], to + * know which strings to make ready, and by the row that draws them. Finding it costs a parse of + * the whole message, so doing it twice would undo what splitting is for. + */ + private val blocks = ConcurrentHashMap>() + + /** + * How each message divides into prose and memory notes, cached for the same reason as + * [blocksOf]: [transcriptUnits] asks per fold, and the regex scan behind [messageParts] is + * proportional to the message every time where a lookup is proportional to nothing. + */ + private val parts = ConcurrentHashMap>() + + fun blocksOf(text: String): List = blocks.computeIfAbsent(text) { markdownBlocks(it) } + + fun partsOf(text: String): List = parts.computeIfAbsent(text) { messageParts(it) } + + /** 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, measured 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. That showed up as 21ms of + * `waited` at the 90th percentile: the frame could not start, rather than taking too long. + */ + suspend fun warm(texts: List) { + texts.forEach { text -> + parsed.computeIfAbsent(text) { + DebugStats.timed("markdown warmed") { parseMarkdown(it) } + } + } + } + + /** Everything these described is gone; see [ParsedReplies]. */ + fun clear() { + parsed.clear() + blocks.clear() + parts.clear() + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt new file mode 100644 index 0000000..be47a74 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MemoryNote.kt @@ -0,0 +1,119 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Arrangement +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.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.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, which is worth showing, + * because "I was told this before" and "I worked this out just now" are different things and the + * reader cannot otherwise tell them apart. + * + * A tag that has not finished arriving is left alone. Streaming means the closing tag may be + * seconds away, and a half-written marker is not a marker yet. + */ +@Composable +fun AssistantMessage( + text: String, + replies: ParsedReplies, + 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) { + BlockedMarkdown(only.text, replies, modifier, live) + return + } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + parts.forEach { part -> + when (part) { + is MessagePart.Prose -> BlockedMarkdown(part.text, replies, live = live) + is MessagePart.Remembered -> MemoryNote(part, replies) + } + } + } +} + +/** + * 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 that nothing reports, + * and the row pays the parse in the frame it appears, which is the cost being removed. + * + * Public because [transcriptUnits] flattens settled replies into the same parts; go through + * [ParsedReplies.partsOf] on any path that runs per fold or per page, so the scan happens once per + * message. + */ +fun messageParts(text: String): List { + val parts = splitMemoryNotes(text) + return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts +} + +@Composable +fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) { + Card(Modifier.fillMaxWidth()) { + 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. + 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, + ) + 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 in it 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/MessageBlocks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt new file mode 100644 index 0000000..d65bb10 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MessageBlocks.kt @@ -0,0 +1,96 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.model.State +import com.mikepenz.markdown.model.parseMarkdown + +/** + * A message's top-level markdown blocks, cut where the parser says the blocks are. + * + * The point is the draw phase. A reply's display list holds every glyph of it, and it is + * re-recorded whenever drawing is invalidated -- so one long message is as expensive to draw as a + * hundred short ones, and skipping the rows around it cannot help while it is the one on screen. + * Measured on a Pixel 9 Pro XL: 97% of rows correctly skipped, and the tallest row still being + * drawn was 36,982px, about twenty-five screens in a single message. Cut into blocks, only the + * screen or two actually being read is ever recorded. + * + * Cut at the parser's own boundaries rather than at blank lines, which is the whole reason this is + * safe: a heading, a fenced code block, a table and a list are each one node whatever is inside + * them, so a loose list does not become five one-item lists and a fence is never split down the + * middle. Guessing at block boundaries with a line scanner gets all three of those wrong. + * + * It also bounds parsing, which was the other symptom: one message took **1.4 seconds** to parse as + * a single unit, and a block is a paragraph. + */ +fun markdownBlocks(text: String): List { + // A reference definition sits at the foot of a message and is used by links above it. Parsed on + // its own each block would lose the definition, and the link would draw as literal brackets -- + // so a message carrying one is kept whole. Rare enough to be worth giving up the split for. + if (REFERENCE_DEFINITION.containsMatchIn(text)) return listOf(text) + val parsed = parseMarkdown(text) as? State.Success ?: return listOf(text) + val blocks = + parsed.node.children + .map { text.substring(it.startOffset, it.endOffset) } + .filter { it.isNotBlank() } + return if (blocks.size <= 1) listOf(text) else blocks +} + +/** `[label]: https://…` at the start of a line -- see [markdownBlocks]. */ +private val REFERENCE_DEFINITION = Regex("""^ {0,3}\[[^\]]+]:\s""", RegexOption.MULTILINE) + +/** + * A reply drawn a block at a time. + * + * Each block keeps its composition and its layout whichever way it is scrolled -- that is what + * stops a message being rebuilt when somebody comes back to it. The heights come from the blocks + * themselves as they are measured, so the running total is the same arrangement the list uses one + * level up. + * + * [live] is the message currently arriving, and it is the only one that gets a layer per block. A + * layer buys one thing here: when drawing is invalidated, only the block that changed is + * re-recorded instead of the whole reply. That is worth a great deal while a reply is streaming, + * because every delta invalidates the message and a finished one can be twenty-five screens tall. + * It is worth nothing once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows + * were re-recorded 65 times in fifty seconds of reading -- 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 + * per-frame cost of the transcript scales with. + */ +@Composable +fun BlockedMarkdown( + text: String, + replies: ParsedReplies, + modifier: Modifier = Modifier, + live: Boolean = false, +) { + val blocks = remember(text) { replies.blocksOf(text) } + if (blocks.size == 1) { + MarkdownText(blocks.first(), replies, modifier) + return + } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(BLOCK_SPACING)) { + blocks.forEach { block -> + MarkdownText( + block, + replies, + Modifier.fillMaxWidth() + .then(if (live) Modifier.graphicsLayer() else Modifier) + .drawWithContent { + val started = System.nanoTime() + drawContent() + DebugStats.record("record: one block", System.nanoTime() - started) + }, + ) + } + } +} + +/** The gap between one block of a reply and the next, here and in [transcriptUnits]. */ +val BLOCK_SPACING = 6.dp diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt new file mode 100644 index 0000000..c372d2d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelName.kt @@ -0,0 +1,35 @@ +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", so choosing it is a request + * the session can act on rather than a name this app made up. + */ +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 has to hold 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. What is left is the part somebody chose -- + * `haiku-4-5` -- and anything that does not look like that is returned untouched, since a name this + * does not recognise is a name it has no business editing. + * + * A display decision, not a correction: the full name is what the session reports and what a reader + * is shown when there is room for it. + */ +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 new file mode 100644 index 0000000..43e2816 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ModelsScreen.kt @@ -0,0 +1,381 @@ +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.foundation.lazy.items +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. Slow enough not + // to matter, frequent enough that a bar moves. + // 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 up to a second and a half to see whether + // anything happened. + 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") } + items(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, + ) + } + } + items(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 -> + items(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 of what is happening. + 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 -- the server joins the running + // download rather than starting a second. + 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 new file mode 100644 index 0000000..92068c1 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -0,0 +1,219 @@ +package com.example.aiapp + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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 and whoever gets the empty box instead is never + * the person who wrote it. 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 -- eleven glyphs, 2.1 KB, subset out of the 3 MB symbols font + * and committed -- so the codepoints below are resolved by an asset in the APK and cannot come back + * as tofu. 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 of them 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, and matching them at the call site would have meant + * one hardcoded measurement per pair. [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: an icon that means "settings" in one app + * and something else in the other is the failure this is worth preventing. 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. All Material + * Design bar one, so they read as one family; the exception is noted where it is declared. + */ +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 -- the CLI is still there and still holds the conversation -- which + * is a pause, not a stop, and drawing it as a square said otherwise. + */ +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 the two buttons have to be told apart at a glance -- one glyph doing both jobs + * while looking identical 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, which is + * what makes it read as a measurement rather than as a trend. + */ +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) + +/** + * 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 and most filled a good deal less, 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 made every icon in the app step up by a fifth for no reason anybody asked + * for. This is what the largest of them already drew at. + */ +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, and a mark cannot end up further from the button beside it than from the edge of the + * screen. 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, so a pair that acts on one screen read as two unrelated marks with one falling off it. + * + * 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has + * to find. It is what the pressed-state ripple draws, too: 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 -- the rows add no vertical padding of their own for the same reason they add no gap. + */ +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, which + * is why a row of them sets no spacing. Text brings none, so the second ring has to be asked for. + * Without it the pressed-state circle, which fills the whole square, arrives at the first letter of + * the title -- and the gap a reader sees between the mark and that title is then half the one + * between the two marks at the other end of the same row. + */ +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 all assistive technology has to go on, and 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, +) { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label }, + ) { + Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline) + } +} + +/** + * 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, and every glyph in the Mono face fills that 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 wherever one is drawn without a box + // around it -- and where there is a box, that leading is what its padding is measured through. + 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 new file mode 100644 index 0000000..9970668 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Notifications.kt @@ -0,0 +1,366 @@ +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 for it 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 -- the same arrangement + * Syncthing's "hide the persistent notification" option produces. 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 then 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 -- a phone changes + * networks, the tunnel comes and goes, the backend restarts -- so it retries quietly and + * forever. Nothing is shown when it cannot connect: a notification saying "I could not tell you + * whether anything happened" on a phone in somebody's pocket is noise about a condition they + * cannot 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 for the same conversation is how a notification 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, and a sound over the top of it would be this app announcing + // what the screen is showing. + if (isOnScreen(notification.sessionId)) return + // The app is up: it says this itself, as a banner over whatever screen they are on. See + // [forTheScreen]. 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. Neither + // is reported anywhere -- the person said no, and saying it back to them through the + // channel they closed is not available anyway. + // + // 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. Before 13 the + // switch in Android's own settings, checked below, is the whole of the answer. + 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 exactly + * the case lint's InlinedApi exists to catch. Zero is what ServiceCompat wants where types do + * not apply. + */ + 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 and + * whatever sound and heads-up display the person has chosen for the app. 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 a value + * could be passed through. [showing] and [stoppedShowing] are the pair, both called from + * the one composable that shows a session. 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 then throw away the + * new screen's claim and start notifying about what is on it. + */ + @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, so there is nothing that + * could be left saying the app is up after it has gone. `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 -- and a row in the drawer for the conversation on screen is the same + // duplication this whole rule is about. + 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 + * when an intent arrives rather than two. + * + * 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 now shown in two + * places -- Android's drawer and the app's own banner -- and two mappings of one word drift. The + * banner colours the line as well, which is its own decision and stays with the drawing. + */ +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 new file mode 100644 index 0000000..7d3b277 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -0,0 +1,58 @@ +package com.example.aiapp + +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.material3.Card +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.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 -- which is exactly the question a peer message is usually the answer to. + */ +@Composable +fun PeerMessageRow( + item: TranscriptItem.PeerNote, + expanded: Boolean, + onToggle: () -> Unit, + replies: ParsedReplies, + modifier: Modifier = Modifier, +) { + Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) + if (!expanded) { + 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, + ) + } + } + if (expanded) MarkdownText(item.text, replies, Modifier.padding(top = 6.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 new file mode 100644 index 0000000..efebf56 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PendingAttachments.kt @@ -0,0 +1,125 @@ +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +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, and what + * these are attached to is the message being typed -- which is why they sit here rather than + * anywhere else on the screen. + * + * 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 -> PendingThumbnail(settings, sessionId, 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 -- and + * the label is what says so, since nothing about the picture does. + */ +@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, and the only thing saying an image was + // attached was the cross drawn on top of nothing. + .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 -> + Text( + if (failed) "!" else "…", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + 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, so making it the target + // would be a control drawn at a size nobody can hit. + // + // 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 and put its backing somewhere the + // eye reads as a second, misplaced square. + 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) + } + } +} + +private val THUMBNAIL = 64.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 new file mode 100644 index 0000000..cce7d2d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PinnedCert.kt @@ -0,0 +1,18 @@ +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 the one product-specific fact -- 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 new file mode 100644 index 0000000..201847d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/RawBlock.kt @@ -0,0 +1,38 @@ +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 of them is adjusted. + * + * The colour is [rawSurface], which is also what a code block inside a reply is given; that is the + * point of having one name for it. Markdown's blocks are painted by the renderer rather than by + * this, since it draws its own, but they are the same colour on purpose. + */ +@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 rounded rectangle behind it reads + // as a misprint rather than as nesting. + .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 new file mode 100644 index 0000000..b6252e6 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ResetCountdown.kt @@ -0,0 +1,57 @@ +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 and only the sentence around it differs, 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. */ +fun formatSpan(until: Duration): String = + when { + until.toHours() >= 24 -> "${until.toDays()}d ${until.toHours() % 24}h" + until.toHours() > 0 -> "${until.toHours()}h ${until.toMinutes() % 60}m" + else -> "${until.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 -- measured against a live response on + * 2026-08-31, where the five-hour window's reset was exactly five hours after the moment work + * resumed. A timestamp that did arrive and could not be read is the genuinely unknown case, and it + * is the only one worth those words. + * + * 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. Two rules for one missing value; this is the rule. + */ +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 new file mode 100644 index 0000000..7587858 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ScrollAnchor.kt @@ -0,0 +1,57 @@ +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 and a session that has said anything since has renumbered every position. + * 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 happened to start + * with -- so an active session renames its tool runs every time it is reopened, and an anchor + * naming one is never found. A seq is the server's own numbering, assigned once and never moved. + * + * [unit] is which unit of the row the viewport started at -- see [TranscriptUnit.ordinal] -- and + * [offset] how far that unit was scrolled past the viewport's newest edge, in pixels. A seq alone + * is not a place: a reply is one seq and can be forty blocks long, and a reader stopped halfway + * down it is put back at that block, not at the reply. + */ +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, and having one device jump because another was scrolled would be a + * surprise rather than a convenience. + */ +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 and should open at the bottom, which is also the cheap + * case. 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 new file mode 100644 index 0000000..ddb30a3 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ServerConfig.kt @@ -0,0 +1,28 @@ +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 and neither may be changed casually. 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, with no error to explain why. + */ +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 new file mode 100644 index 0000000..0843418 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionAlerts.kt @@ -0,0 +1,186 @@ +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 of them 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 -- they are already here, and what a tap on the notification would have done is what a tap + * on this does. So while these are on screen the stream is delivered here instead, which is + * arranged by the collection below and nothing else; see `NotificationService.forTheScreen`. + * + * A banner can go three ways, and each is somebody deciding something different: tapped, which + * opens the session; pushed off either side; or left alone, in which case it goes by itself when + * the bar across its foot 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, and for the same reason: 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, so the timer that was going to retire it has been + // standing still the whole time. + 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. What is drawn + * is therefore the thing that decides, which is the only arrangement where a bar that has emptied + * cannot be sitting under a banner that is still there. + */ +@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 that happens to be in the way. The + // border is the one cue, and the elevation beside it is the platform's shadow rather + // than a second tint -- Material draws no tonal overlay over a container stated here. + 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 -- the reason `progressColor` exists. 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, so + * this is a duration the reader can watch rather than one they have to learn. + */ +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 new file mode 100644 index 0000000..e44b2d5 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt @@ -0,0 +1,191 @@ +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.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.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 while + * somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport + * moved the text under the reader's eyes. Reserving the final height makes loading invisible, which + * is what it should be. + * + * Four lines of body text, so a screenshot reads as an attachment beside the conversation rather + * than as a page of its own. Full size is one tap away. + */ +@Composable +fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) { + val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref) + var full by remember(ref) { mutableStateOf(false) } + val height = thumbnailHeight() + val heightPx = with(LocalDensity.current) { height.roundToPx() } + Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) { + when (val image = bitmap) { + null -> + Text( + // 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. + if (failed) "[image $ref unavailable]" else "[loading image…]", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> + Image( + bitmap = image, + contentDescription = "Attached image, tap to view full screen", + contentScale = ContentScale.Fit, + filterQuality = enlargingFilter(image.height, heightPx), + modifier = Modifier.fillMaxSize().clickable { full = true }, + alignment = Alignment.CenterStart, + ) + } + } + if (full) bitmap?.let { image -> ImageViewer(image) { full = false } } +} + +/** + * 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 exactly when a hardcoded + * height would be 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, so this is a decision + * per image rather than a preference set once. + */ +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. + * + * A dialog rather than a screen, so the platform's back gesture returns to the transcript instead + * of leaving the app. It opens fitted -- the whole image visible, which is the thing a reader wants + * first -- and zoom is theirs from there. + */ +@Composable +private fun ImageViewer(image: ImageBitmap, onClose: () -> Unit) { + Dialog( + onDismissRequest = onClose, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offsetX by remember { mutableFloatStateOf(0f) } + var offsetY by remember { mutableFloatStateOf(0f) } + Box( + Modifier.fillMaxSize() + .background(Color.Black) + .clickable(onClick = onClose) + .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 + } + } + }, + contentAlignment = Alignment.Center, + ) { + 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().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 new file mode 100644 index 0000000..d0476c8 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -0,0 +1,378 @@ +package com.example.aiapp + +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.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.foundation.lazy.items +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.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.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, + onSpawn: () -> Unit, +) { + val scope = rememberCoroutineScope() + var listState by remember { mutableStateOf>>(LoadState.Loading) } + var confirmingDelete by remember { mutableStateOf(null) } + + // 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, where a server that has stopped + // answering leaves every row stale and is `listState`'s to report. + // + // Cleared on the next successful load below -- an entry outlives its + // session otherwise, and would reappear against whatever the phone + // fetched next. + 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 rather than to the session. + var deleting by remember { mutableStateOf>(emptySet()) } + + fun refresh() { + listState = LoadState.Loading + scope.launch { + listState = + try { + val loaded = + withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) } + deleteErrors = emptyMap() + 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 ...". It was also a guess -- + // a delete that the server itself refused had reached it + // fine. + 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 { + items(ordered, key = { it.id }) { session -> + SessionCard( + session = session, + error = deleteErrors[session.id], + deleting = session.id in deleting, + onOpen = { onOpen(session) }, + onLongPress = { confirmingDelete = session }, + ) + 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 one somebody opens this dialog for. 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, under ~/.claude/projects, whether + // this app spawned the session or imported it; echo and llama.cpp do not, and + // for those the app's transcript is the only copy there is. + // + // This used to branch on `imported`, above a comment asserting that "a session + // started here has no copy anywhere". That was simply false for every + // claude-cli session this app spawned, and the two warnings disagreed about + // sessions that were equally recoverable. 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 on the sessions where it is true. + // + // 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, + // which nothing here checked; and it names what goes either way, because this + // app's transcript holds images, peer messages and commands that 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, which + // is the whole of what this state is for. + deleting = deleting + session.id + deleteErrors = deleteErrors - session.id + scope.launch { + try { + withContext(Dispatchers.IO) { + deleteSession(settings, session.id, alsoDeleteForeign) + } + // 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 + // that was 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, and putting it back when the server refuses + * is worse than never having taken it away. + */ + deleting: Boolean, + onOpen: () -> Unit, + onLongPress: () -> 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 in this app is shown. + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +@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 (status == "running" || status == "compacting") { + // 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 new file mode 100644 index 0000000..e4cc8fa --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -0,0 +1,1978 @@ +package com.example.aiapp + +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.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.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.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.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.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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +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 java.util.concurrent.atomic.AtomicReference +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 + +private const val RECONNECT_DELAY_MS = 1500L + +/** + * How big the "still loading this conversation" spinner is. + * + * Bigger than the ones inside a tool card, which are 16dp and report on one call among many, and + * smaller than a splash: this one is standing in for the whole screen while there is nothing else + * on it, and it is the only thing to look at. + */ +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* of scroll, which is the distance the + * question is actually about: how far the reader can keep going before they run out. 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 eight rows is less than one screen: the + * reader reached the end of what was loaded on *every* swipe and waited a round trip standing + * there, which is a list running out of transcript rather than a slow frame. + * + * Six, because the two ways to be wrong are not the same size: firing early costs a page fetched + * that nobody reads, firing late is a spinner under somebody's finger for a whole round trip over + * the tunnel -- and the distance a hard fling covers while that fetch is in flight is several + * screens on its own. The estimate this multiplies is built from measured unit sizes, so a bigger + * cushion no longer amplifies a bad guess the way it would have when the guess came from whatever + * happened to be on screen. + */ +private const val HISTORY_SCREENS = 6 + +/** + * How many events a backwards page asks for, which is five times what the opening page takes. + * + * The floor is that an event is not a row, and the ratio is nothing like one to one. Measured on a + * real transcript (2,426 events, 2026-08-30): the whole conversation is *seven* assistant messages, + * and the median run of consecutive text deltas that fold into one of them is four hundred. A page + * of eighty is therefore a fifth of a single row, and reaching a screenful of fresh rows took about + * thirty sequential round trips inside one collect. Below this number a page can add no visible + * room at all, and the fetch chain degenerates into those round trips again. + * + * At the floor rather than above it, because pages are fetched in the background before the reader + * arrives -- the cushion decides how deep loading runs, and a page that was not enough is followed + * by another without anybody waiting on either. What a *smaller* page buys is hiding: it crosses + * the tunnel in half the time and lands in a smaller frame spike, so the case where the reader + * outruns an in-flight fetch is rarer and cheaper. This was 800 when the reader was the one + * standing at the boundary and each round trip had to be amortized as far as it would go. + * + * The opening page stays small: it is the one on the critical path of showing the screen at all, + * and it only has to fill a viewport. + */ +private const val HISTORY_PAGE = 400 + +/** + * 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 for that in one request rather + * than walking there a page at a time. This bounds the request anyway, because "exactly how far" is + * however far the reader had scrolled and there is no bound on that -- and a single response of + * arbitrary size is the one shape a phone on a slow tunnel handles worst. At roughly 800 bytes an + * event, measured on a real transcript, this is about three megabytes. + * + * Going past it costs another request rather than anything being missed, so the number only trades + * round trips against response size. + */ +private const val RESTORE_PAGE_MAX = 4000 + +/** + * Which row was asked to hold its top edge, and how tall it was when it last measured. + * + * Deliberately *not* snapshot state, and that is the point of the whole 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 that is already being laid + * out rather than in a later one. Nothing observes these, so nothing needs to. + * + * [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 -- which is the + * end that row should hold when it changes height; see [holdTopEdge]. + * + * One slot rather than a map, because only the touch that is about to toggle something matters: + * [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each + * *visible* row -- the lazy list is what makes that affordable, since only rows on screen have one + * and it runs on touch, not per frame. Not snapshot state: nothing composes from it. + */ +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 + * the whole reason 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 + * before the right one -- visible as a flick, and worse the faster the screen refreshes. Scrolling + * from here happens before anything is drawn, so there is no frame to see and nothing that depends + * on how quickly the correction is scheduled. + * + * [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of + * exactly that much is what 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, and the two halves changed +// for unrelated reasons while they shared this file. + +@Composable +fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) { + DebugStats.count("session screen recomposed") + val scope = rememberCoroutineScope() + val topEdgeHeld = remember { TopEdgeHold() } + var items by remember { mutableStateOf(listOf()) } + var status by remember { mutableStateOf(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. + var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } + // When the current compaction started and how long ago that is. 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. See `compactingLabel`: null is still the honest answer for a session whose + // status was never reported as compacting at all. + 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 the status 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. The server refuses the second one, but a control + // that can be pressed while its own last press is still in flight is asking to be. + 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 -- or the system reclaiming the app -- does not throw away a half-typed + // message. See `Drafts.kt` for why this one piece of state is the device's rather than the + // server's. + var input by remember(summary.id) { mutableStateOf(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, which is worth asking about first. + var pendingModel by remember { mutableStateOf(null) } + 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 that have already been drawn as a group, so the transition into one is noticed exactly + // once. See the effect below. + 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: a screen that opens + // everything it can is one nobody can scan. + var expandedNotes by remember { mutableStateOf(setOf()) } + // Uploaded-but-not-yet-sent attachment ids; sent with the next message. + var pendingAttachments by remember { mutableStateOf(listOf()) } + // What this session is set to now, seeded from the row that opened it and + // then owned here, because changing either is something this screen does. + // 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 -- through the settings below it, + // or by typing the command -- 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) } + val activeStream = remember { AtomicReference(null) } + // The oldest sequence number loaded, and whether there is more behind + // it. Paging backwards is what keeps opening a long session cheap: the + // screen starts with the end of the conversation and fetches earlier + // pages only when somebody scrolls to them. + var oldestSeq by remember { mutableLongStateOf(0L) } + // Where this session was last being read, from this device's own store. Read once, because + // it is the question "where did I leave off" and the answer stops being interesting the + // moment the list is on screen. + val savedAnchor = remember(summary.id) { loadScrollAnchor(context, summary.id) } + // Whether the saved position is still being put back -- the history it needs fetched, and the + // scroll applied. 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, and this transcript is not + // allowed to move under one. + var restoring by remember(summary.id) { mutableStateOf(savedAnchor != null) } + // Sent, but not yet read by the session -- which is when the backend + // records it and it comes back as a row. Until then it is drawn below + // the working indicator, because that is where it is in the session's + // reading of events: after everything taken in, not yet taken in + // itself. + // 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 are still here + // after leaving the session or restarting the app -- and so 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 rather than from this screen, so a rename sent from the settings + // screen -- or from another device -- is drawn waiting here too. + var waitingCommands by remember { mutableStateOf(listOf>()) } + val running = status == "running" || status == "compacting" + 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]. Per session, because + // it describes that session's rows and nothing else. + val replies = remember(summary.id) { ParsedReplies() } + // Keyed like everything else that describes one session's 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(summary.id) { LazyListState() } + // 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 (the empty + // "below" slot) makes an index ambiguous about where the viewport actually is. + // + // This is what the jump-to-newest button watches, and the gate on recording -- see [record]. + 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] for why. + var held by remember { mutableStateOf(listOf()) } + // What is actually drawn: the transcript with runs of adjacent tool + // calls folded into one row each, flattened into the list's units. + val rows = remember(items) { groupToolRuns(items) } + val units = remember(rows) { transcriptUnits(rows, replies) } + // The same list, readable from effects launched before this composition: an effect's closure + // keeps the values of the composition that launched it, and both the anchor saver and the + // restore need the units as they are *now*. + val currentUnits by rememberUpdatedState(units) + val lastTouch = remember { LastTouch() } + + /** + * 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 then waiting six seconds ended at the very + * bottom, forty lines further on than where it was left. + * + * Insertions were never the problem -- the list is keyed, so a row arriving at either end + * leaves the anchor where it is, and reading back through history while a session works has + * always been still. What cannot be allowed is a row that is already there changing height, and + * the one guarantee that covers every way that happens -- a reply streaming, a tool's output + * arriving, a queued bubble appearing above the anchor -- 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 + // The message coming back is the session saying it has + // read it, so the bubble held below the indicator becomes + // the row `foldEvent` is about to add. + // 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.images) + } + if (event is SessionEvent.UserMessage) { + 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, which is the whole reason it is a fold and + // not a running total. See `contextAfter`. + contextTokens = contextAfter(contextTokens, entry.event) + when (val event = entry.event) { + // Nothing further: what it carries was folded into the context above, and what a + // turn cost is not something the transcript draws. + 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. Timing it from the moment we + // arrived would report the wait as shorter than it was, in exactly the case + // somebody is asking about -- 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 + } + } + } + + /** + * 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, so it needs nothing: shut a group from the bar at its foot and what follows it does not + * move, which is what the reader is looking at down there. 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 fills + * the space above it, so the calls appear on the far side of the control that produced them -- + * and that one asks the row to hold its top edge instead. + * + * Which half decides it, rather than which control was pressed, so that everything that opens + * behaves the same way whether or not it happens to have a control at each end. A group has two + * and its heading and foot bar land in the halves they are already in; a single call is one + * card, and tapping low on an open one shuts it downward exactly as the bar does. + * + * The correction itself belongs to the measurement -- see [holdTopEdge]. Which half was touched + * comes from the row's own detector ([LastTouch]), written by the gesture that is about to run + * [toggle]. + */ + fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) { + if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key + toggle() + } + + /** + * Whether the row holding transcript position [seq] is loaded, with older history behind it. + * + * "Behind it" is the part that is easy to leave out. The oldest loaded row is a half-row -- + * [joinPages] welds the other half onto it 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 -- a run of calls folds + * differently when a page boundary moves, and two halves of a reply become one message -- and + * the reader's place is inside whichever row now holds that seq, not gone. + * + * Computed from `items` rather than from `rows` for the reason [loadOlderPage] gives: `rows` is + * the composition's value and does not change under a running coroutine. + */ + 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 and a second copy of this would be a second answer to "what is loaded". + * + * 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): Boolean { + // 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 + // of [HISTORY_PAGE] events is that many copies of a list growing to that length -- around + // three hundred thousand element copies for one page, run on the main thread in the + // middle of the scroll that asked for it. It was affordable at eighty events and is not + // at eight hundred, which is why the page that made scrolling back reach the top made it + // stutter to get there. + // + // `Dispatchers.IO` for both rather than a hop to `Default` between them: the two are one + // errand, and this way the page costs one context switch instead of three. Neither half + // touches anything the composition owns -- `older` and `earlier` are local, and the + // `items` read below happens back on the caller's thread, where the write does too. + val page = + withContext(Dispatchers.IO) { + val older = fetchTranscript(settings, summary.id, before = oldestSeq, limit = limit) + 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, and it is a single pass over the two + // lists against the page's quadratic fold. + 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 -- which showed up as a single 22ms parse surviving every page. + 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" -- the reader lost what they were + // looking at because something else happened. + // + // Considered once per run, at the moment it first becomes a group, and never again: after + // that the group's own toggle owns it, and re-deriving this every time would re-open a group + // the reader had just shut while one of its calls was still expanded. + 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 says 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 -- the same + // comparison `relativeTime` already makes for a session's last activity. 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 -- so a flaky link (or a backend restart) costs + // nothing but the gap's latency. + // The newest page first, in one request, before the stream opens. The + // stream then starts from where that page ended, so it carries live + // events only -- which is what it is good at. + LaunchedEffect(summary.id) { + try { + val page = withContext(Dispatchers.IO) { fetchTranscript(settings, summary.id) } + // Warmed before the fold lands rather than after: flattening the rows into units + // splits every settled reply ([transcriptUnits]), and the flatten runs in the + // composition that first sees the rows. Folded into a scratch list off this thread + // to find out what needs warming; the real fold below also maintains the queue and + // the cursor, so it cannot be reused here. + 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) } + // 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 -- + // and the cost was already paid on the way down there. + 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: `joinPages` welds the other half onto it when + // the page behind it arrives, and it grows -- so anchoring into one puts the + // reader where they were only until the next page lands, which landed a screen + // and a half out. Any row that is not the oldest is final. + // + // This terminates because `oldestSeq` walks strictly backwards and the anchor is + // a seq: once the window reaches past it, some loaded row starts at or before it + // and [indexOfSeq] answers. 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 an active session was reopened. + 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 is larger than the count, which overshoots into + // older history rather than stopping short. [HISTORY_PAGE] on top is the + // cushion that keeps the anchor's row off the oldest edge, where it would + // still grow. + // + // 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, and a spinner for all of them. + // The bytes are the same either way, since every row between the anchor and + // the newest end has to be there for the list to be able to count to it. + val span = oldestSeq - anchor.seq + HISTORY_PAGE + if (!loadOlderPage(span.coerceIn(1L, RESTORE_PAGE_MAX.toLong()).toInt())) 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 -- a run of calls folds + // differently when a page boundary moves, two halves of a reply become one + // message. Null is a row that is no longer in the transcript at all -- a reset + // stream, or a session cleared from elsewhere -- and means there is nothing to + // put back: the list is already at the newest end, which is where it opens. + 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 row is in `items` and the units are a pure function + // of it. Nothing is drawn during the wait: [restoring] gates drawing, and + // the scroll is applied before it is lifted, so there is no frame showing + // anywhere else. 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) { + // Not fatal: the stream below still replays from zero, which is + // slow but complete. Saying so beats silently showing nothing. + streamError = e.message + } + // Whatever happened above, including a page that never arrived: 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. The same reasoning that keeps the + // opening page off the critical path puts the first full page right behind it, while + // the screen is already up. A restore skips this: it has just paged as deep as the + // anchor needed. + if (savedAnchor == null && moreHistory && !loadingHistory) { + loadingHistory = true + try { + loadOlderPage() + } catch (_: ApiException) { + // The next scroll asks again. + } finally { + loadingHistory = false + } + } + } + + // 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 (SocketTimeoutException)" + // waiting at the top on their return. Switching apps is a choice + // somebody made, not a fault to report, and reconnecting on a phone + // that has been backgrounded is work nobody is watching. Stopping the + // stream deliberately makes the drop a close rather than an error (see + // EventStream.close), and resuming reconnects from the same cursor. + LaunchedEffect(summary.id, ready, lifecycleOwner) { + if (!ready) return@LaunchedEffect + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + try { + while (true) { + val stream = EventStream(settings, summary.id) + activeStream.set(stream) + try { + withContext(Dispatchers.IO) { + stream.run( + after = lastSeq.get(), + // Connected, measured rather than inferred: this is what + // takes a failure off the screen, and nothing else does. + // 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 + // is what makes this the same as opening the + // screen -- `apply` refills them, and scrolling + // up pages the rest back in as it always does. + items = listOf() + replies.clear() + held = listOf() + oldestSeq = 0L + moreHistory = true + }, + ) { entry -> + apply(entry) + } + } + } catch (e: ApiException) { + streamError = e.message + } finally { + stream.close() + } + delay(RECONNECT_DELAY_MS) + } + } finally { + // Cancellation -- going below STARTED, or leaving the screen -- + // cannot interrupt a blocking socket read. Closing is what + // unblocks it, and what marks the drop deliberate. + activeStream.getAndSet(null)?.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. + DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } } + + // Nothing gets announced about the session somebody is reading; see NotificationService. + // RESUMED rather than STARTED because "looking at it" means the foreground -- a session left + // on this screen behind another app is one whose notifications are still wanted, and STARTED + // covers that case too. + 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, and drip-feeding it would only make that following last longer. + 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 -- so the reader pressed the control that means + // "take me to the end", left, came back, and was put back where they had been. + // + // 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 (the transcript is fetched newest-first), + // and the key does not either (a tool run is renamed when the newest page starts somewhere + // new); 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, + summary.id, + // Nothing to restore at the newest end, which is where a session with no + // anchor opens anyway -- so the ordinary case costs a `remove` and no + // page-back on the way in. One *before* the index, because item zero is + // the "below" slot; a viewport starting inside it is at the newest end. + 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, and the units on screen are 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 -- so the reader met the spinner + // at every boundary, which is exactly what the cushion exists to prevent. + // + // 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, + // and a page of history lands past every visible index and moves nothing. + val unitSizes = remember(summary.id) { 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 -- starting the walk at `last().index` + // begins one unit past the last visible 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, so a page that folds into + // almost no new units is followed by another because the room genuinely + // did not grow. + loadOlderPage() + } catch (_: ApiException) { + // Leave `moreHistory` alone: the next scroll asks again. + } finally { + loadingHistory = false + } + } + } + + 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, and the session is unaffected. + emptyList() + } + } + + 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, + // or the refusal is what disables the control permanently. + onDone() + } + } + } + + fun send() { + val text = input.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 when the answer arrives. + if (text.startsWith("/") && attachments.isEmpty()) { + input = "" + 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 = "" + 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 or the + // session left, and then the screen showed nothing pending while the queue was full. + act { sendMessage(settings, summary.id, text, attachments) } + } + + // The system photo picker; the image uploads as soon as it's chosen, + // so Send only has ids to reference. + val pickImage = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + scope.launch { + try { + val id = + withContext(Dispatchers.IO) { + // 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 -- see `uploadPickedImage`. + uploadPickedImage( + context, + settings, + summary.id, + uri, + summary.maxImageEdge, + ) + } + pendingAttachments = pendingAttachments + id + actionError = null + } catch (e: ApiException) { + actionError = e.message + } + } + } + } + + // One poll for this machine's limits, read by the two things that show them: the bar under + // the header, and the colour of the button that opens the dialog. + val usage = rememberSessionUsage(settings, summary.setup) + val frames = rememberFrameStats() + 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 -- measured on the + // emulator at ~7.6ms of main-thread work per frame across ~34 frames per open, and on the + // Pixel as 82% late frames while the transcript itself cost 0.25ms. Scoped this way, a + // keyboard frame costs one layer transform for the composer and one re-measure of the + // transcript box, whose children skip measurement (width unchanged) and whose rows are + // already layers. + var composerHeight by remember { mutableIntStateOf(0) } + val imeInsets = WindowInsets.ime + val navInsets = WindowInsets.navigationBars + 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 -- + // the pair of glyph buttons at the far end of this row get theirs from each other. + Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) + Column(Modifier.weight(1f)) { + 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 as two + // sentences with different grammar. The "on" that used to sit in the middle + // made it a phrase, which only works in one order and stops working the moment + // the pair is shown anywhere else. + // + // 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, since one follows the request and the other the + // session's own answer. + Text( + "${summary.setupName} · ${summary.provider}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + // 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; until they do, + // the session is the only place the provider is already named, so it is the only + // place the button can sit without inventing a scope for itself. What it shows is + // the paid service's own numbers, so 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. Blue at every ordinary level and only + // yellow or red near a limit -- and 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. + Row { + // Left of the numbers about the *conversation*, because it is the same kind of + // thing about the *app*: what this session is costing to draw. It copies rather + // than opens, because what it produces is for somewhere else -- a message to + // whoever is looking at the code -- and a screenful of timings read on the + // phone + // is a screenful nobody can act on. + GlyphButton( + SPEED_GLYPH, + "Copy render timings", + onClick = { + val report = + debugReport( + device = + "device: ${Build.MODEL} (${Build.MANUFACTURER})," + + " Android ${Build.VERSION.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", + " ${expandedTools.size} tool calls and" + + " ${expandedGroups.size} groups open", + ), + frames = frames.lines(context.refreshHz()), + accounting = + frames.drawPhase().let { (nanos, count) -> + drawAccounting(nanos, count) + }, + crash = lastCrash(context), + ) + 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, and a counter nobody can check from here is a counter that + // only + // gets checked by asking Iris to press a button and paste. + 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 rather than one and then the same one again. + frames.reset() + DebugStats.reset() + Toast.makeText(context, "Copied render report", Toast.LENGTH_SHORT) + .show() + }, + ) + GlyphButton( + USAGE_GLYPH, + "Usage", + { usageOpen = true }, + colour = usageGlyphColour(usage), + ) + // What it opens is about this session, so it sits at the end of the session's + // own row. The name is the whole of what it holds today, which is why it is a + // cog + // and not a word: 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 -- which was a screen away from where that gets decided. + 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, and following new content is + // where the list is rather than a correction it makes; 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, and there is no frame in which the transcript is somewhere other + // than where it was left. + 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 -- and then the keyboard's, per frame of its animation. 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() }) + .imePadding() + ) { + Box(Modifier.fillMaxSize()) { + TranscriptList( + units = units, + state = listState, + moreHistory = moreHistory, + 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, + images = waiting.images, + pending = true, + ) + } + } + } + }, + ) { unit -> + when (unit) { + is TranscriptUnit.Block -> MarkdownText(unit.text, replies) + is TranscriptUnit.Memory -> MemoryNote(unit.part, replies) + 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 + // ("performMeasureAndLayout called during measure"). + // 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 of every + // fling. + 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 exactly as it would have; 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 = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptRow.Single -> + when (val item = row.item) { + is TranscriptItem.UserMsg -> + UserBubble( + settings = settings, + sessionId = summary.id, + text = item.text, + images = item.images, + ) + is TranscriptItem.AssistantMsg -> + // A whole assistant row is only ever the reply + // still + // arriving -- every settled reply is flattened + // into + // block units instead; see [transcriptUnits]. + // Live + // is + // what earns its blocks a layer each while + // deltas + // land. + AssistantMessage( + item.text, + replies, + 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 = { questionId, answers -> + act { + answerQuestion( + settings, + summary.id, + questionId, + answers, + ) + } + }, + image = { ref -> + SessionImage(settings, summary.id, ref) + }, + ) + is TranscriptItem.QuestionCard -> + QuestionRow(item) { answers -> + act { + answerQuestion( + settings, + summary.id, + item.id, + answers, + ) + } + } + is TranscriptItem.ErrorMsg -> + Text( + item.message, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + is TranscriptItem.ImageItem -> + SessionImage(settings, summary.id, item.ref) + 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.PeerNote -> + PeerMessageRow( + item = item, + expanded = item.seq in expandedNotes, + replies = replies, + onToggle = { + toggleAnchored(row) { + expandedNotes = + if (item.seq in expandedNotes) + expandedNotes - item.seq + else expandedNotes + item.seq + } + }, + ) + } + } + } + } + } + } + } + + // 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 rather than + // sharing one with the empty answer. + // + // In the middle of the transcript rather than at either end, because it is not + // reporting on the newest message or the oldest; it is standing in for all of them. + // `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 measures them putting the position back. They are the two + // halves + // of the same wait and the transcript is not drawn for either. + 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, so this waits to be wanted. + // + // Down, and the same chevron a tool group collapses with: the + // list is built upside down internally, but nobody reading it + // knows that -- on screen the newest message is at the bottom, + // which is where this goes. The name is carried in the + // description, since an arrow alone says nothing to a screen + // reader and nothing to whoever finds this in six months. + 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 now. The newest end is where the + // content hangs from, so being at it is the whole of following it, and + // there + // is no separate flag to set -- which 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( + pointingUp = false, + 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 -- no + // measure, no recomposition, no re-recording of anything. Its height is reported to the + // transcript box above, which reserves that much room; the opaque background covers the + // one frame between this growing (a suggestion row, a second draft line) and that + // reservation catching up. + Column( + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .onSizeChanged { composerHeight = it.height } + .graphicsLayer { + translationY = + -(imeInsets.getBottom(this) - navInsets.getBottom(this)) + .coerceAtLeast(0) + .toFloat() + } + .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) } + }, + ) + } + + SessionStatusRow( + status = status, + compactingFor = compactingFor, + contextTokens = contextTokens, + ) + + // 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( + commands = suggestedCommands(input), + onPick = { command -> input = 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 thing 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) + }, + modifier = Modifier.fillMaxWidth(), + // 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(), + ) { + TextButton( + onClick = { + pickImage.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + } + ) { + // Just "+" now. The count was standing in for showing them. + Text("+") + } + // 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 + // exactly the moment the app 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 when it starts. + // Asked about first, unless there is nothing to lose by it -- + // see [ModelSwitchWarning]. + onPick = { chosen -> + if ( + modelLabel(chosen) == modelLabel(model) || items.isEmpty() + ) { + 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 of them 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 its absence could not + // say + // whether there was nothing to do; a button that is 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, which has nothing else to read. + // + // 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, and the only feedback was the ripple. Disabled and + // not hidden, for the reason the button beside it is always here. + Button( + onClick = { send() }, + enabled = input.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) }, + ) + } + } + } + } + } + + if (usageOpen) { + UsageDialog(settings = settings, onDismiss = { usageOpen = false }) + } + if (settingsOpen) { + SessionSettingsDialog( + settings = settings, + sessionId = summary.id, + title = title, + // 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. The list behind this refetches on the way out of the session anyway. + onRenamed = { + title = it + settingsOpen = false + }, + onDismiss = { settingsOpen = false }, + ) + } +} + +/** 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 -- these three each have to cover every + * case, and the compiler says so. + */ +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) + } + +/** + * 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. + */ +@Composable +private fun UserBubble( + settings: ServerSettings, + sessionId: String, + text: String, + images: List = emptyList(), + pending: Boolean = false, +) { + 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; where it sits + // is what says the rest. + colors = + CardDefaults.cardColors( + containerColor = + if (pending) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp), + ) { + 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, whether or not there is an image in it. + images.forEachIndexed { index, ref -> + if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp)) + SessionImage(settings, sessionId, ref) + } + } + } + } +} + +/** A message the server has accepted and the session has not read yet. */ +private data class QueuedMessage(val id: String, val text: String, val images: List) + +/** + * 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 from the + * beginning 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. On a long conversation that is the whole of it, again. + * + * No number is offered here, deliberately. What it will cost depends on how long *this* + * conversation is, and this screen does not know that -- the running total beside it counts what + * has been spent, which is a different quantity. A figure worked out from it would be a guess in a + * measurement's clothes, and the reader could not tell which times it was right. + * + * 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, which is what makes the one that matters stop + * working. + */ +@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 of these 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. Here they are always in the same place, and the thing they report on -- the session + * you are about to type at -- is directly below. + * + * 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, and + * would make its own presence the signal for a state it never names. + * + * The states are the session's own status words plus the total, and each looks different from the + * others: `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, +) { + 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 says nothing at all until it has finished -- measured + // against 2.1.237 again on 2026-08-29, 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. Elapsed time is the only honest number here, and + // [compactingLabel] is where it is worded. + "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, which reads as the app having lost the session + // rather than as the stop having worked. 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" -> "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 that had just been 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. It is the same reason the + // status word beside it names the quiet state instead of leaving the row blank. + 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) -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + // The same body the questions on a tool call get: one question is the same + // thing whether or not something else asked it. + AskedQuestion(question, onAnswer) + } + } +} + +/** + * 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 start swallowing a deliberate + * reopen, and a press held that long to close a menu is not worth protecting at that price. + */ +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 +private 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), which means the press that dismisses it is also delivered to the + // window underneath -- and what it lands on there 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, and + // the menu could only be put away by tapping somewhere else. So the moment is remembered, and + // a click that follows it within one tap is read as the second half of that tap rather than + // as a new one. + var closedAt by remember { mutableLongStateOf(0L) } + Box { + TextButton( + 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 a departure from what you + // were typing. + // + // 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, that left the menu's foot 142px -- the status bar's height, exactly -- + // clear of the button that opened it. Turning clipping off makes both questions about the + // same window. What it gives up is that the keyboard stops counting as an edge: with the + // IME up the menu opens downwards over it rather than upwards over the transcript. That + // is the lesser fault -- it is still attached to the button that opened it, which is the + // whole complaint -- and correcting it would mean supplying a position provider, which + // this menu takes no parameter for. + DropdownMenu( + expanded = open, + onDismissRequest = { + open = false + closedAt = SystemClock.uptimeMillis() + }, + properties = PopupProperties(focusable = false, clippingEnabled = false), + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + open = false + if (option != current) onPick(option) + }, + ) + } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt new file mode 100644 index 0000000..7816ba0 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionSettingsDialog.kt @@ -0,0 +1,189 @@ +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.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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 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" -- and a control belongs + * with the thing it acts on. + * + * Nothing here is captioned. Each control is a labelled noun with a switch or a field beside it, + * and a paragraph under every one of them made the dialog longer than the conversation it covers. + * Failures still get their words: those are what the reader cannot work out by looking. + */ +@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, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var name by remember(sessionId) { mutableStateOf(title) } + 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 -- from here or from another device -- with + // nothing to say so. Until the answer arrives the switch is disabled and a spinner sits beside + // it, which is what not knowing looks like: distinguishable from off, and from a refusal. + var notify by remember(sessionId) { mutableStateOf(null) } + var notifyError by remember { mutableStateOf(null) } + + LaunchedEffect(sessionId) { + notify = + try { + withContext(Dispatchers.IO) { fetchSession(settings, sessionId).notify } + } 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 + null + } + } + + // 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 + } + } + } + + // 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 -- the session behind it shows nothing about it. + error = e.message + saving = false + } + } + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Session settings") }, + text = { + Column { + 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, + ) + } + error?.let { + Spacer(Modifier.height(8.dp)) + Text( + it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + // 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") } }, + ) +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt new file mode 100644 index 0000000..ada615f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionUsageBar.kt @@ -0,0 +1,224 @@ +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.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 whole 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, so it returns no + * snapshot for it -- and reading that silence as "couldn't find out" is exactly the mistake of + * answering with the nearest available word. Drawn as nothing, because there is nothing. + */ + 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" are the pair that must + * never share an appearance: a bar sitting at zero because a machine is unreachable reads as + * plenty of headroom, which is the opposite of the truth. + */ + 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 machine's rate limits, polled. + * + * Hoisted out of [SessionUsageBar] because two things on a session's screen show this same answer + * -- the bar, and the colour of the button that opens the usage dialog. Fetching it twice would + * cost two round trips to say one thing, and the two copies would disagree for up to a minute at a + * time, which is the interface contradicting itself about a number somebody is deciding on. + */ +@Composable +fun rememberSessionUsage(settings: ServerSettings, setup: String): SessionUsage { + var usage by remember(setup) { mutableStateOf(SessionUsage.Waiting) } + LaunchedEffect(setup) { + while (true) { + usage = + try { + usageFor(withContext(Dispatchers.IO) { fetchUsage(settings) }, setup) + } catch (e: ApiException) { + SessionUsage.Unavailable(e.message ?: "couldn't reach the backend") + } + delay(REFRESH_MS) + } + } + return usage +} + +/** + * 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 + * deliberately passes windows it does not recognise straight through, so a fourth one is a thing + * that happens rather than a thing to notice later. + * + * 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. The dialog behind + * the button is where those say, in words, which one they are. + */ +@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, fetched from the machine that holds the + * account. It is never derived from what this app has watched go past: the transcript's token + * counts are a different quantity, measured differently, and a bar shaped like a quota gauge 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 + // happened to move 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 machine that meters nothing: a row saying "unknown" there would + // report a problem about a setup somebody chose, on every screen, forever. + if (usage is SessionUsage.NotMetered) { + 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) { + SessionUsage.NotMetered -> Unit + is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}") + SessionUsage.Waiting -> UsageNote("5-hour usage: checking") + 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 and they are worded differently on purpose; see + * [WindowEnd]. A window that is not running gets the percentage and nothing else, because there is + * no countdown to report and inventing one would be the same fault as inventing the number. + */ +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 machine's snapshot, out of every machine's. + * + * Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it: + * a machine nobody logged into, one that could not be reached, a snapshot that came back empty. + * 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): SessionUsage { + // No snapshot at all means the backend never asked, which it only does for a machine with + // nothing metered on it. That is a different answer from having asked and failed. + val mine = snapshots.firstOrNull { it.setup == setup } ?: 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 new file mode 100644 index 0000000..bb9c216 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SettingsScreen.kt @@ -0,0 +1,202 @@ +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, so nothing to report. + 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. Nothing is wrong with the camera, so nothing should + // say there is. + 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. + * Which way round it renders is the terminal's business, not something this app should depend on. + * The mixed decoder alternates normal and inverted frames, costing half the frame rate at each + * polarity and nothing else. + */ +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 new file mode 100644 index 0000000..c85da27 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SetupsScreen.kt @@ -0,0 +1,395 @@ +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.foundation.lazy.items +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()) { + items(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". The + // line has to say something the name cannot also be. + 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 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 }, + ) + } + + 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 and + // made this field taller than the two beside it for no information. + label = { Text("user@host[:port]") }, + singleLine = true, + ) + OutlinedTextField( + value = identity, + onValueChange = { identity = it }, + label = { Text("Key path on the backend") }, + 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, and writing 22 here would + * put a second answer to that question in a second place. + * + * 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, because an address with several is an address, not an + * address and a port. 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/SpawnScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt new file mode 100644 index 0000000..699c81f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SpawnScreen.kt @@ -0,0 +1,358 @@ +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 -- and because they are independent, any provider can be sent to any host. + */ +@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 -- see LoadState. + 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. Manual stays one tap away for a + // session that warrants it. + var permissionMode by remember { mutableStateOf("auto") } + 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) } + // Downloaded models, for a llama provider to choose between. Fetched + // beside the setups but kept separate: a Claude session needs none, so + // failing to list them must not stop the screen rendering. + var models by remember { mutableStateOf>(emptyList()) } + var modelKey by remember { mutableStateOf(null) } + var contextSize by remember { mutableStateOf("") } + var temperature by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + 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) + } + models = + runCatching { withContext(Dispatchers.IO) { fetchModels(settings).local } } + .getOrDefault(emptyList()) + } + + 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 } + 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 this backend has + // downloaded, so the choice is that list rather than free + // text -- there is nothing sensible to type here, and a name + // that is not on disk is a session that cannot start. + if (models.isEmpty()) { + Text( + "No models downloaded yet. Get one from the Models screen first.", + 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(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) { + spawnSession( + settings, + // The id, not the label: labels are + // editable and the server resolves by + // id. + // Non-null here: `chosen` came from + // `setup`'s own provider list, so + // reaching this point proves there was + // a setup to take it from. + 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 }, + // 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/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt new file mode 100644 index 0000000..9a27a7e --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -0,0 +1,282 @@ +package com.example.aiapp + +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 +import androidx.compose.ui.graphics.toArgb +import dev.snipme.highlights.model.SyntaxTheme + +/** + * 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 Text = Color(0xFFCDD6F4) + 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, and the moment one wants a different accent the shared version + * becomes a thing to fight rather than a thing to use. + * + * The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle, + * Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, 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 rather + * than the near-white the roles default to. + */ +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 -- an amber, a green and a red picked off Material's defaults -- so the same state + * was a slightly different colour depending which screen you looked at, and none of them belonged + * to this palette at all. A colour that carries meaning is part of the scheme, not a value typed + * where it happened to be 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 -- nothing here is a button. + */ +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 -- rewriting what it + * remembers, taking a new name -- 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 the thing that 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. It is the same red as [failedColor] and [stopColor] + * for a third reason, which is worth naming rather than collapsing: this is neither a fault nor a + * button, it is the mark left where something was taken away. The reader never has to tell the + * three apart, because no two of them 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, or a + * compaction, has no limit to be near: it finishes. Only a bar measuring a *quota* escalates, and + * 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 whole point of + * colouring by consequence is that the reader learns the step once -- two bars showing the same 80% + * in different colours teaches nothing except that the colour cannot be trusted. It reads as a + * difference in degree, which is all colour can carry: the states that differ in *kind* from this + * -- a window nobody could read, a machine that meters nothing -- are said in words elsewhere, + * because a reader has no way to tell those from an ordinary low number by colour alone. + * + * [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent + * without each converting it first and 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 whole 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, where a reply is drawn, and on a card, where a tool call + * is; a tint chosen upwards has to be picked twice and still collides with the card it lands on. + * The renderer's default code background was `surfaceVariant`, which is exactly a card's own fill + * -- so a code block inside a tool call had no background at all. + * + * One colour for all three, so "this is verbatim" is learnable once. + */ +val rawSurface: Color + @Composable get() = Mocha.Crust + +/** + * Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input. + * + * Here with the rest of the palette rather than beside the code that highlights: a library's own + * theme would otherwise be the one surface in the app whose colours came from somewhere else, and + * the accents below are the same ones every other coloured thing already uses. + */ +fun catppuccinSyntax(): SyntaxTheme = + SyntaxTheme( + key = "catppuccin-mocha", + code = Mocha.Text.toArgb(), + keyword = Mocha.Mauve.toArgb(), + string = Mocha.Green.toArgb(), + literal = Mocha.Peach.toArgb(), + comment = Mocha.Overlay0.toArgb(), + metadata = Mocha.Yellow.toArgb(), + multilineComment = Mocha.Overlay0.toArgb(), + punctuation = Mocha.Subtext0.toArgb(), + mark = Mocha.Sky.toArgb(), + ) + +/** + * 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 + +/** 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 and worth + * naming rather than collapsing: [runningColor] is green because a session is working, + * [failedColor] is red because one fell over, [awaitingColor] is the same orange because a session + * is waiting on somebody -- 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, and the next message starts a turn as though nothing had + * happened. 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. Crust is what + * every accent on this palette takes, which is the same reason `onPrimary` is Crust above. + */ +@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 new file mode 100644 index 0000000..e402a83 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolInput.kt @@ -0,0 +1,188 @@ +package com.example.aiapp + +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.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.snipme.highlights.Highlights +import dev.snipme.highlights.model.BoldHighlight +import dev.snipme.highlights.model.ColorHighlight +import dev.snipme.highlights.model.SyntaxLanguage +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 -- the command a shell will run, what it is for, how long it may take -- + * 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: SyntaxLanguage?, + /** The tool's own one-line summary, when it wrote one. */ + val description: String?, + /** + * How long the call may take, as the tool expressed it. 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", which is what the card always did. + */ +private val SUBJECTS: Map> = + mapOf( + "Bash" to ("command" to SyntaxLanguage.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() } + 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 -- see [RawBlock]. 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, and it is the shape a tool with no input actually has. + * + * 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. + */ +@Composable +fun ToolInputView(tool: String, input: String, modifier: Modifier = Modifier) { + val parsed = remember(tool, input) { parseToolInput(tool, input) } + if (parsed.subject == null && parsed.rest.isEmpty()) return + RawBlock(modifier) { + parsed.subject?.let { subject -> + // Not wrapped: a wrapped command hides where its arguments end, + // and the long one is the one being read closely. + Text( + highlighted(subject, parsed.language), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + softWrap = false, + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) + } + parsed.rest.forEach { + Text( + it, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** + * [code] with its keywords and strings coloured, or plain if there is no language for it. + * + * The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] -- + * a library's default theme would be the one place in the app whose palette came from somewhere + * else. + */ +@Composable +private fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString { + val theme = catppuccinSyntax() + val plain = MaterialTheme.colorScheme.onSurface + return remember(code, language, theme, plain) { + if (language == null) return@remember AnnotatedString(code) + val marks = + Highlights.Builder(code = code, language = language, theme = theme) + .build() + .getHighlights() + buildAnnotatedString { + append(code) + marks.forEach { mark -> + when (mark) { + is ColorHighlight -> + addStyle( + SpanStyle( + color = + androidx.compose.ui.graphics.Color( + mark.rgb or 0xFF000000.toInt() + ) + ), + mark.location.start, + mark.location.end, + ) + is BoldHighlight -> + addStyle( + SpanStyle(fontWeight = FontWeight.Bold), + mark.location.start, + mark.location.end, + ) + } + } + } + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt new file mode 100644 index 0000000..55b6f57 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -0,0 +1,440 @@ +package com.example.aiapp + +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.remember +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 a value: it 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 every composable taking one + * recomposed whenever anything above it did. 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, + * and that is what a page landing costs on top of the fetch itself. + * + * 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. That makes it the load-bearing value on this + * screen: 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. A lone call + * that gains a neighbour becomes a group without changing identity, which is the case a + * seq-based key got wrong: the row the reader was looking at was replaced rather than updated. + * Everything else keys on the seq of the event behind it, which never moves. + */ + 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 tool row is named after its run, and a run takes its name from whichever call + * was first when it was folded, which changes as pages arrive. A seq is the server's own + * numbering: it is assigned once, never moves, and means 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 is the one -- points with this, and anything that has to identify a row + * within one composition uses [key]. + */ + abstract val startSeq: Long + + data class Single(val item: TranscriptItem) : TranscriptRow() { + override val key: Any + get() = (item as? TranscriptItem.ToolRun)?.runId ?: item.seq + + 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*, and a group named after its first member is a different + // group every time that happens. + 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: they are the same rows + * they would be on their own, and a rounded corner drawn hard against a rounded corner reads as a + * notch. + * + * 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; [GROUP_GAP] keeps the parts legible without + * separating them. 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, not hunting for the top. The + * bar at the foot is the same height as the heading at the top, so the surface the calls sit on is + * as thick below them as above. + */ +@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, so the row records the touch + * itself and this just says that one happened. + */ + onToggle: () -> Unit, + isToolExpanded: (String) -> Boolean, + onToolToggle: (String) -> Unit, + onAnswer: (questionId: String, answers: List) -> Unit, + image: @Composable (String) -> 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, + 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 either the style or + * the density changes. Taking the line height also means the heading cannot be clipped by it. + */ +@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. See [groupBarHeight]. + */ +@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(pointingUp = true, 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 the case a hand-written first/last pair gets wrong + * when a run turns out to have three calls in it. + */ +@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 + +/** + * 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 -- and the + * name plus the intent is what somebody scanning the transcript is reading for. + * + * 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, and it is worth + * seeing beside the command it constrains rather than buried in the fields below it. + * + * 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: (questionId: String, answers: List) -> Unit, + image: @Composable (String) -> 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, in the colour this + // app uses everywhere for that. + 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)) + } + if (tool.output.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Output", style = MaterialTheme.typography.labelSmall) + // 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 -- a directory listing, a diff, a table of numbers -- and a + // proportional font silently destroys the alignment that carried the meaning. + RawBlock(Modifier.padding(top = 2.dp)) { + Text( + tool.output, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } + } + } + // 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 -- unlike a command, which + // is what the closed line already summarises. + 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) { answers -> onAnswer(ask.id, answers) } + } + } + } + } + } +} + +/** + * 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) -> Unit) { + Spacer(Modifier.height(8.dp)) + Text( + ask.prompt.substringBefore('\n'), + style = MaterialTheme.typography.bodyMedium, + color = awaitingColor, + ) + if (ask.answers.isNotEmpty()) { + Text( + "Answered: ${ask.answers.joinToString(", ")}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + AnswerOptions(ask.options, onAnswer) + } +} + +/** + * 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/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt new file mode 100644 index 0000000..54b5b2d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -0,0 +1,439 @@ +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]). The + * stream is the only data source -- opening a session screen replays from seq 0, and a reconnect + * resumes from the last seq seen, so there is no separate history fetch to drift from it. + */ +@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 seq is the right identity because it is what the transcript itself is ordered by, it never + * changes, and it is already carried by every event. A row built from several events -- a + * streaming message, a tool call and its result -- keeps the seq of the first, so it holds + * still while the rest of it arrives. + */ + abstract val seq: Long + + data class UserMsg( + override val seq: Long, + val text: String, + /** Refs of what was attached, drawn inside the bubble. */ + val images: List = emptyList(), + ) : TranscriptItem() + + data class AssistantMsg(override val seq: Long, val text: String) : 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 -- a new call + * arriving beside it, or a page of history arriving in front of it -- 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, instead of renaming the run they joined. + */ + 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 and had to work + * out that it was one event. 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 they are one decision to make + * -- a permission is the case of exactly one, not 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 the thing a page boundary + * breaks -- a screenshot loaded on one page and its call on the next read as unrelated. + */ + val images: List = emptyList(), + ) : TranscriptItem() + + 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) : + TranscriptItem() + + /** + * 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: it is the explanation for a gap in the + * conversation, and for a minute or two in which the session was busy with nothing to show. + * + * The wire also says what triggered it, and this deliberately does not carry that: the row says + * the two sizes and nothing else (see [compactionSummary]), so keeping the trigger here would + * be a field nothing can read. + */ + data class CompactedNote( + override val seq: Long, + val preTokens: Long?, + val postTokens: Long?, + ) : 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 of it + * 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 from being alone: it is + * always visible, since a run of one is drawn as itself rather than as a group; 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. It ends the run + * before it as well as starting a fresh one after -- the moment somebody was asked is a boundary in + * the work, not a gap in the middle of one run. + */ +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, and both + * have to be one thing again -- a reply drawn as two messages is the same defect as a call drawn + * twice, arriving from the same cause. + * + * 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, once as a proper card and once as a + * nameless placeholder. Visible as a run of four calls reporting "Called 5 tools", and worse than + * the miscount -- the extra row is at the join, so it also moves everything the reader was looking + * at. + * + * 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 (the tool's name, its input) and the + * newer on what an end knows (the output, and whether it finished), which is the only way round + * that loses nothing. + */ +fun joinPages(earlier: List, later: List): List { + val (older, newer) = healSplitMessage(earlier, later) + val startedEarlier = + older.filterIsInstance().mapTo(mutableSetOf()) { it.id } + if (startedEarlier.isEmpty()) return older + newer + val endedLater = + newer + .filterIsInstance() + .associateBy { it.id } + .filterKeys { it in startedEarlier } + if (endedLater.isEmpty()) return older + newer + 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 -- deltas + * accumulate into the message before them -- 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 is the row already on + * screen, and renaming that is how the list loses its anchor. 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, away from the row the list anchors to. + */ +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 the + * wrong one: 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 and steps under them. So the arriving + * calls take the name of the ones already there, and nothing visible changes identity. + */ +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 -- see `runIdFor`. 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) } +} + +fun foldEvent(items: List, entry: SeqEvent): List = + when (val event = entry.event) { + is SessionEvent.UserMessage -> + items + TranscriptItem.UserMsg(entry.seq, event.text, event.images) + 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) { + items.dropLast(1) + last.copy(text = last.text + event.delta) + } 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. The name is unknown from an end alone; 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; loading the page before this + // replaces the row with the real thing, which is when it splits out. + 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, which is what every question was before this. + 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 -> + items + TranscriptItem.PeerNote(entry.seq, event.from, event.text) + 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 + is SessionEvent.Settings -> items + is SessionEvent.Status -> items + 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.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 + } + +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, which is the frame failing to + * *start* rather than taking too long once it had. + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +private val parsingThreads = Dispatchers.Default.limitedParallelism(2) + +/** + * Parses the replies 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. See + * [ParsedReplies]. + * + * What is warmed mirrors what the rows draw, unit by unit -- prose split into its blocks, a memory + * note whole -- 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] and [ParsedReplies.blocksOf] caches the flatten does, so a message is + * scanned once however many pages hand it back through here, while the whole loaded transcript + * crosses this on every page. + */ +suspend fun warm(replies: ParsedReplies, rows: List) { + withContext(parsingThreads) { + val texts = + rows + .filterIsInstance() + .flatMap { replies.partsOf(it.text) } + .flatMap { part -> + when (part) { + is MessagePart.Prose -> replies.blocksOf(part.text) + // Drawn as one MarkdownText, so its whole text is the key looked up. + is MessagePart.Remembered -> listOf(part.text) + } + } + if (texts.isNotEmpty()) replies.warm(texts) + } +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt new file mode 100644 index 0000000..bb6f460 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptList.kt @@ -0,0 +1,104 @@ +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.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. A + * conversation shorter than the screen stacks from the bottom, hanging from the composer. + * + * 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. An item the framework composes is drawn the same frame it is + * placed, and an item off screen is not a node at all. + * + * 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 -- so entering + * composition costs laying out one paragraph, not parsing a message. + */ +@Composable +fun TranscriptList( + units: List, + state: LazyListState, + moreHistory: Boolean, + modifier: Modifier = Modifier, + below: @Composable () -> Unit, + unit: @Composable (TranscriptUnit) -> Unit, +) { + 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; + // see [drawAccounting]. 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. + 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) + ) + } + } + } + } +} + +/** 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/TranscriptUnits.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt new file mode 100644 index 0000000..396b15d --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptUnits.kt @@ -0,0 +1,135 @@ +package com.example.aiapp + +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; the + * block splitting existed only inside the row, where the list could not see it. + * + * 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 markdown block of a settled reply. */ + data class Block( + override val seq: Long, + override val ordinal: Int, + val text: String, + override val gap: Dp, + ) : TranscriptUnit() { + override val key: Any + get() = "b$seq:$ordinal" + } + + /** 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" + } +} + +/** + * 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 blocks ([markdownBlocks], via the caches on [replies] so a + * message is only ever split once). The reply still arriving -- the last row -- stays whole: its + * text changes with every delta, and splitting it here would parse the whole message per delta on + * whichever thread is composing. [AssistantMessage]'s own streaming path already parses deltas off + * the main thread and gives the live message a layer per block. + * + * 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.blocksOf] are lookups for any text [warm] has + * seen, and a miss -- the one message that just finished streaming -- costs its split exactly once. + */ +fun transcriptUnits(rows: List, replies: ParsedReplies): List { + val units = ArrayList(rows.size) + rows.forEachIndexed { index, row -> + val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING + val item = (row as? TranscriptRow.Single)?.item + if (item is TranscriptItem.AssistantMsg && index != rows.lastIndex) { + var ordinal = 0 + fun gap() = if (ordinal == 0) rowGap else BLOCK_SPACING + replies.partsOf(item.text).forEach { part -> + when (part) { + is MessagePart.Prose -> + replies.blocksOf(part.text).forEach { block -> + units += TranscriptUnit.Block(row.startSeq, ordinal, block, gap()) + ordinal++ + } + is MessagePart.Remembered -> { + units += TranscriptUnit.Memory(row.startSeq, ordinal, part, gap()) + ordinal++ + } + } + } + } else { + units += TranscriptUnit.Whole(row, rowGap) + } + } + units.reverse() + return units +} + +/** + * 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/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt new file mode 100644 index 0000000..39ecf07 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -0,0 +1,233 @@ +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.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 androidx.compose.ui.window.Dialog +import java.time.OffsetDateTime +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * 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: there is nothing here to open, so the only thing its Back could ever have meant was "put + * this away", which is what dismissing does. The system back gesture dismisses it, since a `Dialog` + * handles that itself. + */ +@Composable +fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + var state by remember { mutableStateOf>>(LoadState.Loading) } + + fun refresh() { + state = LoadState.Loading + scope.launch { + state = + try { + withContext(Dispatchers.IO) { LoadState.Loaded(fetchUsage(settings)) } + } catch (e: ApiException) { + LoadState.failed(e) + } + } + } + LaunchedEffect(Unit) { refresh() } + + // A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the + // gaps between its title, its content and its 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. Everything else here is what AlertDialog would have + // drawn -- the same container colour, the same corner -- so nothing about it looks foreign. + 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, reported by whichever + // paid service answered there -- naming the session's provider here made an + // echo session's screen read "echo" above a line reading "claude", which is a + // claim about echo that nothing measured. Each machine names itself and the + // service it came from, which is the true scope. + Text( + "Usage", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f), + ) + GlyphButton(REFRESH_GLYPH, "Refresh usage", { 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 instead of stretching to the window. + Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) { + UsageBody(state) + } + 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 of padding on every side. What separates one machine from the + // next is the line naming it, which is enough for a list this short. + 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 rather than as the label they are. + // 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, and would dilute the marks that do mean + * something. 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, which is the case this row has always drawn as nothing and + * is right to: there is no end to report. What it used to get wrong is the other missing case, a + * timestamp that arrived and could not be read: that was printed raw, so a parse failure appeared + * as an ISO string in the middle of a sentence written for a person. Both cases 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 new file mode 100644 index 0000000000000000000000000000000000000000..172bdc4e77dcfbd30c40b92a17c2d0f70cb59374 GIT binary patch literal 2400 zcmb7FYiv~45uUU6vAcfkdc7}#lI8lbcO9Sv*86Zav{{>nOzarr5JTuAHS1mcTralU z5@EFwwG9}gQc$5qPE=J@MWt4P#wCdX#XpSIRux8x8?1)3RSQ-n2&!lznbdo?-#Kdv zmHMamYR;UQGc#wtxpU4XMnpyQI!P4Y{?aqSmtP+KJ&}4G+OH>i+cK=3w*CEUM+6FSY96ZUo8?t3z&ugjI|M2r1=7^ybbtK!`%h%6-0sb!NA{|iJ zI+c5nJ0Mqf^bQ`d#+U}V5%QBgeTg=8!aM`tMK1TY9mvoJg@+-}L)Ox5y~!uutlohA z=ZM(eOy9s@PNsd>zlN`qiO5*6kg`BddKwlEm#4Zc=y9nPW#xL<5sCyoR;HFe!!YQf zD2oJFF$^9TBYmYB4>dN19!w2iZtQGO_ZZ~w@-G-?{ap<@oAP(-x7bhh?gl0G`Fkt% zhR%k`5v3Lm#(o(3ZyV!!W0#-3r|Y-b&-^d+U~}Y>vXGi03j}u=Gh3OLS?Ixi)AYq9 zlIdc0qiG?R4PTTY{5l&(_9u;$ISmgFtpa9uSvcwlMI+I0C{i8tJjFa-kK4gqX0@U` zpoh)r2bD9Uhi~+^wDix99x+zd#9CTnH6_X9lxf@TiHW$@)ZgEv)xI@xJNveAu;tlU z?Aeyb^D2Z8u!_8_EQ-{wE#bW;1;ro*At+~Nm!Ozk0=p({Sek`^JWn9c4Dyu7j2LB( zGEPdBsXqJ8zdHEHkuM71bQ89fu*J7=I@nFMUQI0m@MHDiUrG13JJ-s@JoMsV*-E>U9EW)bH^+MUzIt&PY`8z~31GW|>ZR;$dq$ zySh3}DMgvLNm9YA>?(ETUfHSpN{ximr|*0v2ZcOaAW63QM>Y;O<#x5|J|}yl)Ti%l zpq$&WY?bZmW>wu?T|LGhd%BqXeB<-yY^8QLOV})CYjTUmik&M~IE{a5Tas3@#b&(W zwwKz@2{VV?ZH&XLpnX%Bai5zp+v-?*0nAvt+wQOtzX^=HsNr9znATA|U!O2SQ0Sv8mp(ps`Ue9~ty%k|&v)$7rP*s!msv$a(-M7f<;qcCtkt*w%;|Gy zPR)JxScd`^@9=z2^WGLnPvdk)aMP?I+(=KbV&>o{v^p4#l!iklG_*VT5aq*87PW|Y z9ZEFdkkU&hO*0!WzWuk!H^(O@$Hy;^W-@hkb({M3k9>E3x-JfYnM$VD-&7;nUDH>y zyV$yq$BuvPtE}eTNf!Icm#4YG$Z)2=wyv(WKQla%$;9KQjG?PTC)sm6dnzt$rD1U5 zrwSm1acKAMoY1HL9z zg7|zhByLp+ULa(dG;n?=L(e1TM|5{S8b?g@V=?kSCv5VBbvwN%h^K0&vx4}3cGfD0 z$F)nh1OX)3)vY{zwo}A=w=XK|CMve@2Ww z9GT4PRR_w?8LfqspPR@=>E*dZH8iw5mmjlH&@B_odX-%CDQ%)Y%Ft`nPo4PhcF-WI zRYeK>Z8ZwgdYps^{55KUO&j8Cs7QjgVKykdlJpXIFYQH~9vZ-U6D1L?9kZ>_aXaqA zXMKp0#4g`}WgBe%A5V>{(dS{%25J>M3?O43RYR6M&vk;Yr|14h20X7v{{Ovi6Y_jf WR8+J;juCo#xi^I7KLhU_)4u_EGz~@o literal 0 HcmV?d00001 diff --git a/app/build-apk.sh b/app/build-apk.sh new file mode 100755 index 0000000..eae66a9 --- /dev/null +++ b/app/build-apk.sh @@ -0,0 +1,102 @@ +#!/bin/sh +# Builds the app's APK, ready to install on a phone through Dev Updater. +# +# ./build-apk.sh +# +# 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. +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +cd "$SCRIPT_DIR" + +# 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 +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 + +# 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 -- compileDebugKotlin 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:assembleDebug --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:assembleDebug --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:assembleDebug --console=plain >/dev/null +else + ./gradlew :androidApp:assembleDebug +fi + +APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.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 "Then start the backend and scan the enrollment QR it prints:" +echo " ./server/target/release/ai-server --rotate-token" diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh new file mode 100755 index 0000000..8785455 --- /dev/null +++ b/app/build-icon-font.sh @@ -0,0 +1,78 @@ +#!/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+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 new file mode 100644 index 0000000..a1a0efc --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,6 @@ +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 new file mode 100755 index 0000000..40f86c9 --- /dev/null +++ b/app/debug-transcript.sh @@ -0,0 +1,149 @@ +#!/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 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 '} + 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 new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/app/gradlew.bat @@ -0,0 +1,82 @@ +@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/run-android.sh b/app/run-android.sh new file mode 100755 index 0000000..3b65dae --- /dev/null +++ b/app/run-android.sh @@ -0,0 +1,48 @@ +#!/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/settings.gradle.kts b/app/settings.gradle.kts new file mode 100644 index 0000000..18ed8d1 --- /dev/null +++ b/app/settings.gradle.kts @@ -0,0 +1,25 @@ +rootProject.name = "AiApp" + +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +include(":androidApp") + +// 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/trace-draw.sh b/app/trace-draw.sh new file mode 100755 index 0000000..248b347 --- /dev/null +++ b/app/trace-draw.sh @@ -0,0 +1,129 @@ +#!/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/ui-sandbox.sh b/app/ui-sandbox.sh new file mode 100755 index 0000000..85ae287 --- /dev/null +++ b/app/ui-sandbox.sh @@ -0,0 +1,175 @@ +#!/bin/sh +# An ai-server with invented sessions in it, for driving the phone UI. +# +# The import screen lists whatever Claude Code has on the machine, and in +# this VM that is real agent transcripts -- so exercising *delete* against +# the ordinary server means deleting somebody's conversation, and exercising +# *import* means starting a real `claude --resume` on the owner's account. Both +# are the wrong price for looking at a list. +# +# So this starts a second server that can see neither. `$HOME` is pointed at +# a sandbox directory, which is the only thing the importer's own script +# consults (`$HOME/.claude/projects/*/*.jsonl`), and the config and session +# data live there too. What it lists is invented here, and deleting all of +# it costs nothing. +# +# Three things are deliberately shared with the real server, because the +# installed APK is built against them: the TLS certificates (the app pins +# that CA and would refuse a fresh one) and the port. Run it while the real +# server is down. +# +# Usage: +# ./ui-sandbox.sh start it, print the enrolment command +# ./ui-sandbox.sh stop stop it +# +# Environment: AI_SANDBOX_ROOT, AI_SANDBOX_TOKEN, AI_SANDBOX_PORT, and +# AI_SANDBOX_DELAY -- the last being the server's own `--delay`, which is +# what makes a spinner visible at all. On loopback every request is back in +# under a millisecond, so a busy state that is correct is still a busy state +# nobody can see. +set -eu + +ROOT=${AI_SANDBOX_ROOT:-${XDG_RUNTIME_DIR:-/tmp}/ai-app-sandbox} +TOKEN=${AI_SANDBOX_TOKEN:-sandbox} +PORT=${AI_SANDBOX_PORT:-8443} +DELAY=${AI_SANDBOX_DELAY:-1200} +CERTS=${AI_SANDBOX_CERTS:-${XDG_CONFIG_HOME:-$HOME/.config}/ai-app/certs} + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +SERVER_DIR=$SCRIPT_DIR/../server +PIDFILE=$ROOT/server.pid +LOG=$ROOT/server.log + +# By pid rather than by pattern: a `pkill -f` for something as generic as +# "ai-server" also matches the shell running this script, which kills the +# script mid-flight and leaves the restart never having happened. +stop_server() { + [ -f "$PIDFILE" ] || return 0 + pid=$(cat "$PIDFILE") + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + echo "sandbox: stopped server $pid" + fi + rm -f "$PIDFILE" +} + +if [ "${1:-start}" = stop ]; then + stop_server + exit 0 +fi + +stop_server +rm -rf "$ROOT/home" "$ROOT/sessions" "$ROOT/config.ron" +PROJECTS=$ROOT/home/.claude/projects/-home-bob-repos-sandbox +mkdir -p "$PROJECTS" "$ROOT/sessions" + +# Eight of them, because the point of the screen is a list long enough that +# picking rows one at a time is the annoyance being fixed. Ids are the same +# shape the CLI writes (a uuid, and the file name *is* the session id), and +# each carries a `cwd` and a few user turns so the row has a title, a path +# and a line count to show. +i=1 +while [ "$i" -le 8 ]; do + id="0000000${i}-5eed-4a11-9c0d-000000000${i}00" + file=$PROJECTS/$id.jsonl + cwd="/home/bob/repos/sandbox/project-$i" + : >"$file" + turn=1 + while [ "$turn" -le $((i + 2)) ]; do + printf '{"type":"user","cwd":"%s","message":{"role":"user","content":[{"type":"text","text":"sandbox session %s, turn %s"}]}}\n' \ + "$cwd" "$i" "$turn" >>"$file" + turn=$((turn + 1)) + done + # A usage record on the last line, which is where the importer reads the + # context figure from. Left off two of them on purpose: "no turn has + # recorded any" is a state the row has to be able to show, and a list + # where every row has a number never exercises it. + if [ "$i" -ne 3 ] && [ "$i" -ne 6 ]; then + printf '{"type":"assistant","message":{"role":"assistant","usage":{"input_tokens":%s,"output_tokens":128}}}\n' \ + "$((i * 9000))" >>"$file" + fi + i=$((i + 1)) +done + +# A CLI that does nothing, so importing one of these is free and safe. +# Everything the spawn path cares about is here: it holds the fifo open, +# records a real pid, writes nothing, and dies on a signal. A real +# `claude --resume` against an invented session id would either fail in a +# way that tests nothing or start a turn on somebody's account. +cat >"$ROOT/fake-claude" <<'FAKE' +#!/bin/sh +cat > /dev/null +FAKE +chmod +x "$ROOT/fake-claude" + +hash=$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) +cat >"$ROOT/config.ron" <"$LOG" 2>&1 & +pid=$! +disown -h "$pid" 2>/dev/null || true +echo "$pid" >"$PIDFILE" + +# Waited for rather than assumed: the enrolment below fails silently against +# a server that has not bound yet, and the app then shows a network error +# that has nothing to do with what is being tested. +tries=0 +while [ "$tries" -lt 50 ]; do + if grep -q "listening\|Listening" "$LOG" 2>/dev/null; then break; fi + kill -0 "$pid" 2>/dev/null || { echo "sandbox: server exited; see $LOG" >&2; tail -5 "$LOG" >&2; exit 1; } + tries=$((tries + 1)) + sleep 0.2 +done + +cat <>, + request: Request, + next: Next, +) -> Response { + let presented = request + .headers() + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")); + if let Some(token) = presented { + let hashes: Vec = manager + .tokens() + .into_iter() + .map(|entry| entry.sha256) + .collect(); + if token_matches(token, &hashes) { + return next.run(request).await; + } + } + + // Peer address only -- never the header value. Absent when there is no + // real socket (tests driving the router directly). + let peer = request + .extensions() + .get::>() + .map(|ConnectInfo(addr)| addr.to_string()) + .unwrap_or_else(|| "unknown peer".to_string()); + tracing::warn!("rejected request from {peer}: missing or invalid bearer token"); + tokio::time::sleep(REJECT_DELAY).await; + (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + use axum::Router; + use axum::body::Body; + use axum::routing::get; + use tower::ServiceExt; + + use wg_app_link::enroll::{generate_token, token_hash_hex}; + + use crate::config::TokenEntry; + + fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc { + let manager = Arc::new( + SessionManager::new( + dir.join("config.ron"), + dir.join("sessions"), + dir.join("models"), + ) + .expect("manager"), + ); + manager + .set_tokens(vec![TokenEntry { + name: "phone".to_string(), + sha256: token_hash_hex(token), + }]) + .expect("set token"); + manager + } + + fn guarded_router(manager: Arc) -> Router { + Router::new() + .route("/probe", get(|| async { "ok" })) + .fallback(|| async { StatusCode::NOT_FOUND }) + .layer(axum::middleware::from_fn_with_state(manager, require_token)) + } + + fn request(path: &str, auth: Option<&str>) -> Request { + let mut builder = axum::http::Request::builder().uri(path); + if let Some(auth) = auth { + builder = builder.header(header::AUTHORIZATION, auth); + } + builder.body(Body::empty()).expect("request") + } + + /// One test rather than separate gating and logging tests, + /// deliberately: tracing caches callsite interest process-wide, so a + /// test that hits the rejection path with no subscriber installed can + /// poison the interest cache for the one that captures logs. Keeping + /// every exercise of the middleware under the capturing subscriber + /// makes the log assertions deterministic. + #[tokio::test] + async fn gates_every_route_and_never_logs_the_token() { + #[derive(Clone, Default)] + struct Capture(Arc>>); + impl std::io::Write for Capture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture { + type Writer = Capture; + fn make_writer(&'a self) -> Capture { + self.clone() + } + } + + let capture = Capture::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::TRACE) + .with_writer(capture.clone()) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let dir = tempfile::tempdir().expect("tempdir"); + let token = generate_token(); + let router = guarded_router(manager_with_token(dir.path(), &token)); + + // No header, wrong token, wrong scheme: 401 everywhere, including + // paths that don't exist -- a scanner learns nothing. + for (path, auth) in [ + ("/probe", None), + ("/probe", Some("Bearer wrong".to_string())), + ("/probe", Some(format!("Basic {token}"))), + ("/no-such-route", None), + ] { + let response = router + .clone() + .oneshot(request(path, auth.as_deref())) + .await + .expect("response"); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "{path} {auth:?}" + ); + } + + let ok = router + .clone() + .oneshot(request("/probe", Some(&format!("Bearer {token}")))) + .await + .expect("response"); + assert_eq!(ok.status(), StatusCode::OK); + + // The tripwire that keeps a future logging change (e.g. logging + // request headers) from silently leaking credentials. + let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned(); + assert!( + !logged.contains(&token), + "the bearer token leaked into the logs: {logged}" + ); + // The rejections themselves do get logged (that's the point). + assert!(logged.contains("missing or invalid bearer token")); + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..d78f1d7 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,545 @@ +//! The server's persistent state: the enrolled token hashes and the +//! sessions that exist. +//! +//! Written whole and atomically (temp file + rename) rather than appended +//! to: it is small, and a half-written config would take the server down on +//! next start with no obvious way to recover from a phone. Every mutation +//! funnels through `SessionManager` (the registry pattern), so in-memory +//! and on-disk state can't come apart. +//! +//! The file is RON, in the shape [`wg_app_link::format`] describes -- the +//! same format, and the same two house rules, as the sibling dev-updater +//! project's config, because both are written and read by hand, and both +//! now read and write them through the one module. +//! +//! Transcripts do NOT live here -- each session's events are an append-only +//! JSONL file in its own directory (see `session::transcript`); this file +//! holds only the metadata needed to list and respawn sessions. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use wg_app_link::format; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct Config { + /// Enrolled device tokens, hashes only -- a leaked config doesn't leak + /// the credential. A list (of one, today) so per-device tokens with + /// individual revocation are a config entry later, not a migration. + pub tokens: Vec, + /// Every machine this server can run something on, and what each of + /// them can run. See [`SetupConfig`]. + pub setups: Vec, + pub sessions: Vec, +} + +/// A machine, and the things it can run. +/// +/// This is the unit a session is spawned against: pick a setup, then one +/// of its providers. Grouping them this way is what stops the spawn +/// screen offering combinations that cannot work -- a provider only +/// exists on a machine where that program is installed, and the previous +/// model, which let any provider be paired with any host, offered the +/// whole cross-product including the impossible parts of it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupConfig { + /// Stable identifier, minted when the setup is added and never + /// changed. Sessions reference this rather than the label, so + /// renaming a machine on the phone does not orphan its sessions -- + /// which is the whole reason the two are separate fields. + pub id: String, + /// The label a person reads and may edit. + pub name: String, + /// How to reach it, absent for this machine. A setup with no `ssh` is + /// where the server itself runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh: Option, + /// What can be spawned here. Names are unique within a setup, and only + /// within it: two machines may each have a `claude-cli`, which is the + /// point. + #[serde(default)] + pub providers: Vec, +} + +impl SetupConfig { + pub fn provider(&self, name: &str) -> Option<&ProviderConfig> { + self.providers.iter().find(|provider| provider.name == name) + } +} + +/// One thing a setup can run: which driver, and how to invoke it. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConfig { + /// Shown on the spawn screen and stored by sessions that use it. + pub name: String, + pub kind: DriverKind, + /// Override for the executable, for an install that isn't on PATH. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + /// Models offered on the spawn screen. Free text is always allowed + /// too; this is a shortcut list, not a restriction. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, +} + +/// How to reach a setup that isn't this machine, with the system `ssh` +/// client -- so `~/.ssh/config`, agents, and jump hosts all keep working, +/// and there is one place to configure connections (PLAN.md, rule 23). +/// +/// A remote session is the identical command with `ssh host …` in front, +/// and nothing downstream of the spawn knows the difference. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshConfig { + /// `user@host`, or a `Host` alias from `~/.ssh/config`. + pub address: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_file: Option, + /// Extra `-o` settings, each written as `Key=value`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} + +/// Which translator runs a session. A new one is a new driver behind the +/// same trait -- never a branch in shared code. +/// +/// Snake case, which is both Rust's and RON's: this is written into a +/// config a person edits by hand, and a hyphen is not a RON identifier, so +/// kebab case cost the file a `kind: r#claude-cli` escape to say a name +/// nobody would type that way. The same string is what the phone compares +/// against (`SpawnScreen.kt`), so the two move together. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DriverKind { + /// The phase-1 fake: echoes messages back as streamed events. Proves + /// the pipe (spawn, SSE, transcript cursors, questions) with no AI + /// involved, and stays useful as a connectivity check that costs no + /// tokens. Always available as a built-in provider. + Echo, + /// A GGUF model served by llama.cpp's `llama-server` (see + /// `session::llama`). The model itself is one this machine has + /// downloaded; the provider's command is the server binary. + LlamaCpp, + /// The Claude Code CLI over stream-json (see `session::claude`). + /// Named for the CLI specifically: bare "claude" would suggest the + /// credit-billed API, which this is not. + ClaudeCli, +} + +impl DriverKind { + /// The longest edge, in pixels, an image should have when it reaches + /// this kind of session -- `None` where nothing here has a limit worth + /// enforcing. + /// + /// Reported to the phone rather than applied here, so the bytes are made + /// small before they cross the tunnel instead of after: a modern phone + /// photo is several megabytes and twelve megapixels, and every one of + /// those bytes was being uploaded over WireGuard only to be rejected at + /// the other end. What decides the number is the provider, which is why + /// it lives beside the kind rather than in the app -- a phone that knew + /// each provider's limits would be a second place to update when one + /// changes. + /// + /// 1568 for the Claude CLI because that is the longest edge the API + /// itself resizes to; anything larger is charged the same and spends the + /// upload for nothing, and far larger is refused outright, which is what + /// "sending an image is broken" turned out to be. The others take images + /// through no path that cares, so they get no limit rather than a made-up + /// one. + pub fn max_image_edge(self) -> Option { + match self { + DriverKind::ClaudeCli => Some(1568), + DriverKind::Echo | DriverKind::LlamaCpp => None, + } + } + + /// Whether the conversation exists outside this app, so that deleting + /// the session here does not end it. + /// + /// The Claude Code CLI owns its own transcript under + /// `~/.claude/projects/` and is resumable from it whatever started + /// it -- so a session this app spawned is every bit as recoverable as + /// one it imported, and the difference between those two is only how + /// it got here. Echo has nothing to keep, and a llama session's + /// conversation is folded out of *this* app's transcript, so for both + /// of those a delete is the end of it. + /// + /// Asked before warning somebody that a deletion cannot be undone, + /// which is the one sentence that has to be true: said of a session + /// that can in fact be brought back, it spends the credibility the + /// warning needs on the sessions where it is real. + pub fn keeps_own_transcript(self) -> bool { + match self { + Self::ClaudeCli => true, + Self::Echo | Self::LlamaCpp => false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenEntry { + /// Which device this token belongs to, for the human rotating it. + pub name: String, + /// Hex SHA-256 of the token. A plain hash is enough: the token is 256 + /// bits from the OS CSPRNG, so there is nothing to dictionary-attack + /// and no stretching needed. + pub sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfig { + /// Stable identifier; names the session's directory and its routes. + pub id: String, + /// Id of the [`SetupConfig`] this session runs on -- the id, not the + /// label, so the machine can be renamed without losing its sessions. + pub setup: String, + /// Name of the provider within that setup. Both stored by name rather + /// than resolved, so an edited setup (a new command path, another + /// model) takes effect on the next relaunch; a session whose setup or + /// provider is gone reports as exited and can still be deleted. + pub provider: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Working directory the session's process runs in. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Claude permission mode chosen at spawn. Meaningless for other + /// kinds, and kept as a string because it is passed straight to the + /// CLI's `--permission-mode` rather than interpreted here -- so the + /// CLI stays the one authority on which modes exist, and a new one + /// needs no change on this side. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + /// Settings the driver interprets, chosen at spawn. + /// + /// Deliberately untyped here: what a temperature or a context size + /// means is the driver's business, and giving this schema a field per + /// driver is how a shared model starts carrying one dialect's + /// vocabulary. `permission_mode` above predates this and should fold + /// into it. A map rather than a list so the phone can send exactly + /// what a person changed, and BTreeMap so the file's order is stable + /// across writes. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub params: BTreeMap, + /// Whether a phone should be told when this session wants attention. + /// + /// Stored here rather than on the phone because it is a fact about the + /// session: one that runs unattended overnight should be quiet on + /// every device, and answering that question again on each new phone + /// is how two devices come to disagree about which sessions matter. + /// + /// Defaults to on, and on for a config written before this field + /// existed. The alternative -- silent unless asked -- makes the + /// feature invisible to anyone who does not go looking for it, and a + /// notification nobody wanted is turned off in one tap where one that + /// never arrived is not diagnosable at all. + #[serde(default = "notify_default")] + pub notify: bool, + /// Whether this session's process is stopped when the server exits, + /// instead of being left running for the next start to adopt. + /// + /// A fact about the session rather than about the run that spawned it, + /// which is why it is persisted: whichever server is running when the + /// time comes is the one that has to act on it, and a session nobody + /// meant to keep should not depend on the same server still being up + /// to clean it away. + /// + /// Written by a server started with `--throwaway-sessions`, which is + /// the default in a debug build. A session spawned while testing is + /// one nobody means to keep, and under the ordinary rule its `claude` + /// outlives every server that ever knew about it -- twelve of them + /// accumulated on this machine in a day, each holding a conversation + /// open. + /// + /// Absent means false: every session written before this existed, and + /// every one spawned by a release build. + #[serde(default, skip_serializing_if = "not_set")] + pub throwaway: bool, + /// Epoch seconds when the session was spawned. + pub created: f64, +} + +fn notify_default() -> bool { + true +} + +/// Keeps the ordinary case out of the file entirely -- see +/// [`SessionConfig::throwaway`], which is false for every session a +/// production build writes. +fn not_set(flag: &bool) -> bool { + !*flag +} + +/// The name of the echo provider, and of the setup this machine gets on +/// first run. +/// +/// Echo is seeded into the config rather than conjured at read time the +/// way it used to be. An implicit provider is one a person cannot see in +/// the file or edit from the phone, and the point of this app is that +/// configuration is visible and editable; if somebody deletes it, that was +/// a choice. +pub const ECHO_PROVIDER: &str = "echo"; +pub const LOCAL_SETUP: &str = "this machine"; +/// The id of the setup a fresh install seeds. Fixed rather than random so +/// a hand-written config can name it without looking one up. +pub const LOCAL_SETUP_ID: &str = "local"; + +impl Config { + pub fn setup(&self, id: &str) -> Option<&SetupConfig> { + self.setups.iter().find(|setup| setup.id == id) + } + + /// A setup by the label a person sees, for messages and for the one + /// place a name still arrives from outside: nothing else should look + /// one up this way, since labels are editable and ids are not. + pub fn setup_named(&self, name: &str) -> Option<&SetupConfig> { + self.setups.iter().find(|setup| setup.name == name) + } + + /// This machine, offering whatever was found on it. + /// + /// The providers are passed in rather than written here because they + /// have to be *discovered*: a hardcoded list is a claim about what is + /// installed, and this one was wrong -- every fresh install asserted a + /// `claude-cli` provider whether or not `claude` existed, which on a + /// machine without it is a spawn option that cannot work and a + /// statement the server never checked. Providers are discovered by + /// asking the machine, here exactly as for any other setup. + pub fn seed(providers: Vec) -> SetupConfig { + SetupConfig { + id: LOCAL_SETUP_ID.to_string(), + name: LOCAL_SETUP.to_string(), + ssh: None, + providers, + } + } + + /// The one provider that needs no discovery, and the floor to fall + /// back to when discovery itself fails. + /// + /// Echo runs in-process, so it exists exactly where this server does + /// and nowhere else -- there is nothing to probe for, and offering it + /// on a remote machine would be a choice that changes nothing. + pub fn echo_provider() -> ProviderConfig { + ProviderConfig { + name: ECHO_PROVIDER.to_string(), + kind: DriverKind::Echo, + command: None, + models: Vec::new(), + } + } + + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => format::parse(&text) + .with_context(|| format!("{} is not valid config RON", path.display())), + // A first run has no config -- the normal starting state; a + // token is generated and saved on that first start. + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + warn_about_a_config_left_behind(path); + Ok(Self::default()) + } + Err(err) => Err(err).with_context(|| format!("read {}", path.display())), + } + } + + /// Writes the config, owner-readable only. + /// + /// The token hashes here are verifiers, not secrets -- a 256-bit + /// random token can't be recovered from its SHA-256 -- but the file + /// also names every host this backend can reach and every session it + /// is running, which is nobody else's business on a shared machine. + /// The mode is set on the temporary file *before* the rename, so the + /// config is never briefly world-readable at its real path. + pub fn save(&self, path: &Path) -> Result<()> { + format::write(path, self) + } +} + +/// Says so when the only config here is one this server no longer reads. +/// +/// The format moved from JSON to RON and the switch is outright -- there is +/// no reader for the old file. Everywhere else that is invisible, but this +/// file holds the enrolled token hashes: starting empty leaves the phone +/// unable to talk to this server, and looks from the phone like the config +/// having been lost rather than renamed. The old file is named and left +/// alone rather than read or deleted, since it is the only record of what +/// was configured. +fn warn_about_a_config_left_behind(path: &Path) { + let old = path.with_extension("json"); + if old.is_file() { + tracing::warn!( + "{} is from an older version and is not read: the config is RON now, at {}. \ + Re-enroll the phone with the enrollment QR this start prints, move anything \ + else across by hand, then delete it.", + old.display(), + path.display(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_the_config_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("config.ron"); + + // A missing file is the ordinary first-run state, not an error. + // Nothing is conjured to fill it: the seed setup is written by the + // manager, so the file always says what there is. + let first_run = Config::load(&path).expect("load"); + assert!(first_run.tokens.is_empty()); + assert!(first_run.setups.is_empty()); + assert!(first_run.sessions.is_empty()); + + let config = Config { + tokens: vec![TokenEntry { + name: "phone".to_string(), + sha256: "ab".repeat(32), + }], + setups: vec![ + Config::seed(vec![ + Config::echo_provider(), + ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: Some("/usr/bin/claude".to_string()), + models: Vec::new(), + }, + ]), + SetupConfig { + id: "vm".to_string(), + name: "the vm".to_string(), + ssh: Some(SshConfig { + address: "bob@10.0.2.15".to_string(), + port: Some(2222), + identity_file: None, + options: Vec::new(), + }), + providers: vec![ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: None, + models: vec!["haiku".to_string()], + }], + }, + ], + sessions: vec![SessionConfig { + id: "abc123".to_string(), + setup: "vm".to_string(), + provider: "claude-cli".to_string(), + title: "test".to_string(), + model: None, + cwd: None, + permission_mode: None, + params: BTreeMap::new(), + notify: true, + throwaway: false, + created: 1234.5, + }], + }; + config.save(&path).expect("save"); + + let loaded = Config::load(&path).expect("reload"); + assert_eq!(loaded.tokens[0].name, "phone"); + assert_eq!(loaded.sessions[0].setup, "vm"); + // The label and the id are separate, and the session holds the id. + assert_eq!(loaded.setup("vm").expect("setup").name, "the vm"); + assert_eq!(loaded.sessions[0].provider, "claude-cli"); + assert_eq!( + loaded + .setup("vm") + .expect("setup") + .ssh + .as_ref() + .expect("ssh") + .port, + Some(2222), + ); + // The same provider name on two machines is the point, not a + // collision: names are unique within a setup and only within one. + assert!( + loaded + .setup(LOCAL_SETUP_ID) + .expect("local") + .provider("claude-cli") + .is_some() + ); + assert!(loaded.setup(LOCAL_SETUP_ID).expect("local").ssh.is_none()); + + // The house rule both halves of `format` depend on: what is written + // is the *body* of the struct, with no outer parentheses and + // nothing indented for them. Asserted rather than trusted because + // `render` strips what `parse` adds back -- if only one of the two + // ever changed, every file on disk would still load and only look + // wrong. The absent `Some(...)` is the other half of the same + // bargain: implicit_some is what lets a person write `port: 2222`, + // and only `skip_serializing_if` keeps this from writing it back. + let text = std::fs::read_to_string(&path).expect("read back"); + assert!(!text.trim_start().starts_with('('), "outer parens: {text}"); + assert!( + text.starts_with("tokens: ["), + "top level should sit at column 0: {text}" + ); + assert!( + text.contains("port: 2222"), + "optional written long-hand: {text}" + ); + } + + #[test] + /// The seed is this machine and nothing more: a name, no ssh, and + /// exactly the providers it was handed. + /// + /// It used to assert a `claude-cli` provider here, which is what made + /// the bug look correct -- the test agreed with the code that every + /// machine has `claude`, because both were written from the same + /// assumption. What a machine has is discovered, so the only thing + /// this can check is that the seed does not invent anything. + fn the_seed_is_this_machine_and_claims_only_what_it_was_given() { + let seed = Config::seed(vec![Config::echo_provider()]); + assert_eq!(seed.name, LOCAL_SETUP); + assert!(seed.ssh.is_none()); + assert_eq!( + seed.provider(ECHO_PROVIDER).expect("echo").kind, + DriverKind::Echo + ); + assert!( + seed.provider("claude-cli").is_none(), + "the seed must not assert a provider nobody looked for", + ); + + // And it carries through whatever discovery did find. + let discovered = Config::seed(vec![ + Config::echo_provider(), + ProviderConfig { + name: "claude-cli".to_string(), + kind: DriverKind::ClaudeCli, + command: Some("/usr/bin/claude".to_string()), + models: Vec::new(), + }, + ]); + assert_eq!( + discovered + .provider("claude-cli") + .expect("found") + .command + .as_deref(), + Some("/usr/bin/claude"), + ); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..4f37d9c --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,310 @@ +//! A phone interface to AI coding sessions -- the backend. See PLAN.md for +//! the whole picture; this is the entry point: config + session registry, +//! token bootstrap, and the one TLS listener. +//! +//! The listener binds the WireGuard interface's address only, and fails +//! closed -- if `wg0` is down the server refuses to start rather than +//! falling back to `0.0.0.0`, because this API *is* remote code execution +//! and the tunnel is what keeps its pre-auth surface (TLS handshake, HTTP +//! parsing, auth middleware) off the open internet. `--bind` overrides +//! explicitly for development; that is a deliberate, logged choice, never a +//! fallback. +//! +//! There is no plaintext listener at all, so the bearer token can't travel +//! unencrypted by misconfiguration -- even inside the tunnel. + +mod auth; +mod config; +mod media; +mod models; +mod routes; +mod session; +mod setups; +mod ssh; +mod usage; + +use std::net::{IpAddr, SocketAddr}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use axum::middleware::Next; +use clap::Parser; +use tokio::signal::unix::{SignalKind, signal}; + +use wg_app_link::enroll; +use wg_app_link::netif::{self, WG_INTERFACE}; +use wg_app_link::xdg::{config_home, data_home}; + +use config::TokenEntry; +use session::SessionManager; + +const DEFAULT_PORT: u16 = 8443; + +/// Serves AI coding sessions (Claude Code, llama.cpp) to the phone app. +#[derive(Parser)] +struct Args { + /// TLS port for the whole API surface. + #[arg(long, default_value_t = DEFAULT_PORT)] + port: u16, + + /// Address to bind instead of the wg0 interface's -- a development + /// override (e.g. 127.0.0.1 for curl, or a LAN address for a phone + /// before the tunnel exists). Production runs without it and fails + /// closed when wg0 is absent. + #[arg(long)] + bind: Option, + + /// Where the token hashes, providers, hosts, and session list live. + /// Defaults to `$XDG_CONFIG_HOME/ai-app/config.ron`. + #[arg(long)] + config: Option, + + /// Directory for per-session data (transcripts, attachments, images). + /// Defaults to `$XDG_DATA_HOME/ai-app/sessions`. + #[arg(long)] + data_dir: Option, + + /// Directory for downloaded GGUF models. Defaults to + /// `$XDG_DATA_HOME/ai-app/models`. + #[arg(long)] + models_dir: Option, + + /// Directory holding the TLS certificates, generated here on first + /// start. Defaults to `$XDG_CONFIG_HOME/ai-app/certs`. + #[arg(long)] + certs: Option, + + /// Invalidate every enrolled token, generate a fresh one, and print + /// its enrollment QR -- the whole lost-phone story. + #[arg(long)] + rotate_token: bool, + + /// Hold every response back by this many milliseconds. + /// + /// A development aid, and a specific one: 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 -- + /// a page of history landing mid-fling, a screen drawn before its + /// first answer arrives. On a loopback server every response is back + /// within a millisecond or two, so those windows close before + /// anything can be observed and the bug looks like it is not there. + /// This reopens them on demand rather than by unplugging something. + #[arg(long, default_value_t = 0, value_name = "MS")] + delay: u64, + + /// Mark every session spawned here as throwaway: its process is + /// stopped when this server exits, instead of being left running for + /// the next start to adopt. On by default in a debug build. + /// + /// Sessions outlive the backend on purpose, which is right for the + /// ones somebody is using and wrong for the ones a test made: a + /// session spawned to check something leaves a `claude` behind that + /// every later server adopts, and they accumulate silently -- twelve + /// of them on this machine in a day, each holding a conversation open. + /// So a development build cleans up after itself unless told not to + /// (`--throwaway-sessions=false`), and a release build never does + /// unless asked. + /// + /// The flag decides only what *new* sessions are marked as. What + /// happens on the way out is decided by the mark, which is written + /// into the session and outlives the server that made it -- so + /// sessions spawned without it keep running, whichever server is up + /// when one exits. + #[arg( + long, + default_value_t = cfg!(debug_assertions), + action = clap::ArgAction::Set, + num_args = 0..=1, + default_missing_value = "true", + value_name = "BOOL", + )] + throwaway_sessions: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Both rustls crypto providers are in the dependency graph (ureq + // brings ring, axum-server brings aws-lc-rs), so rustls refuses to + // pick one itself; choose before anything touches TLS. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("no other TLS crypto provider is installed before main"); + + // `info` unless RUST_LOG says otherwise. Written as a *fallback* rather than as the filter, + // because `with_env_filter("info")` is a fixed directive that never reads the environment -- + // so the per-request diagnostics that AGENTS.md tells you to turn on with + // `RUST_LOG=ai_server=debug` printed nothing, and the switch looked like the code it was + // meant to instrument being wrong. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + let args = Args::parse(); + + let config_path = args + .config + .unwrap_or_else(|| config_home("ai-app").join("config.ron")); + let data_dir = args + .data_dir + .unwrap_or_else(|| data_home("ai-app").join("sessions")); + // Beside the session data rather than under it: models outlive every + // session and are shared by all of them, so deleting a session must + // never take a multi-gigabyte download with it. + let models_dir = args + .models_dir + .unwrap_or_else(|| data_home("ai-app").join("models")); + let models = Arc::new(models::ModelStore::new(models_dir.clone())); + let manager = Arc::new( + SessionManager::new(config_path.clone(), data_dir, models_dir.clone()) + .with_context(|| format!("failed to load {}", config_path.display()))? + .marking_new_sessions_throwaway(args.throwaway_sessions), + ); + if args.throwaway_sessions { + tracing::warn!( + "sessions spawned here are marked throwaway -- their processes are stopped when this \ + server exits rather than left running (--throwaway-sessions=false to keep them)" + ); + } + // After construction rather than inside it: seeding asks this machine + // what it has, which is I/O, and a constructor that quietly runs a + // subprocess is a surprise to every caller including the tests. + manager.seed_setup().await?; + + tracing::info!("config: {}", config_path.display()); + tracing::info!("models: {}", models_dir.display()); + for setup in manager.setups() { + match &setup.ssh { + Some(ssh) => tracing::info!(" setup \"{}\" -> {}", setup.name, ssh.address), + // No parenthetical naming the local machine: the default + // setup is *called* "this machine", and the line read + // "setup this machine (this machine)". + None => tracing::info!(" setup \"{}\" runs here", setup.name), + } + for provider in &setup.providers { + tracing::info!(" provider {} ({:?})", provider.name, provider.kind); + } + } + for info in manager.sessions() { + tracing::info!( + " session {} ({}, {:?})", + info.id, + info.provider, + info.status + ); + } + + // Before the interface check below, deliberately: the certificates are + // also what the phone app embeds at build time, so they need to be + // obtainable on a machine whose tunnel isn't up yet. The leaf is + // reissued on every start, so once wg0 exists the next start covers it. + let certs_dir = args + .certs + .unwrap_or_else(|| config_home("ai-app").join("certs")); + let certificates = wg_app_link::certs::ensure("ai-app", &certs_dir, &netif::local_addresses()) + .with_context(|| format!("failed to prepare certificates in {}", certs_dir.display()))?; + if certificates.ca_is_new { + tracing::warn!( + "a new CA was generated in {} -- any installed app pins the previous one and can no \ + longer reach this server. Rebuild it with app/build-apk.sh, which embeds this CA, \ + and reinstall through Dev Updater.", + certs_dir.display(), + ); + } + + let bind_ip = match args.bind { + Some(ip) => { + tracing::warn!( + "binding {ip} by explicit --bind override -- production binds {WG_INTERFACE} only" + ); + ip + } + None => netif::wg_address("ai-server")?, + }; + + // Token bootstrap: first run generates one; --rotate-token replaces + // whatever exists. Either way the plaintext appears exactly once, in + // the QR printed here. + if args.rotate_token || manager.tokens().is_empty() { + let rotating = args.rotate_token && !manager.tokens().is_empty(); + let token = enroll::generate_token(); + manager.set_tokens(vec![TokenEntry { + name: "phone".to_string(), + sha256: enroll::token_hash_hex(&token), + }])?; + if rotating { + tracing::info!("rotated the enrolled token; the previous one is now invalid"); + } + enroll::print_enrollment("aiapp", bind_ip, args.port, &token)?; + } + + let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( + &certificates.leaf_cert, + &certificates.leaf_key, + ) + .await + .context("failed to load TLS cert/key")?; + + // No providers listed here any more: which machines can be asked, and + // about what, comes from the setups at the moment the screen is opened + // -- so a machine added from the phone reports its limits without a + // restart, and the backend's own account stops standing in for every + // machine's. + let monitor = Arc::new(usage::UsageMonitor::new()); + + // The bearer-token middleware wraps the entire router -- routes and + // fallback alike -- here and only here, so a new route can't forget + // auth. Zero unauthenticated endpoints. + let app = routes::router(Arc::clone(&manager)) + .merge(routes::usage_router(monitor, Arc::clone(&manager))) + .merge(routes::models_router(Arc::clone(&models))) + .layer(axum::middleware::from_fn_with_state( + Arc::clone(&manager), + auth::require_token, + )); + + // Outside the auth layer, so an unauthenticated request is refused at + // the speed it always was: this is here to slow the app down, not to + // widen the window on anything guessing at tokens. + let app = match args.delay { + 0 => app, + ms => { + tracing::warn!("delaying every response by {ms}ms -- development override"); + app.layer(axum::middleware::from_fn( + move |request, next: Next| async move { + tokio::time::sleep(Duration::from_millis(ms)).await; + next.run(request).await + }, + )) + } + }; + + let addr = SocketAddr::new(bind_ip, args.port); + tracing::info!("serving https://{addr}"); + + // Let go of the sessions on the way out rather than stopping them: + // their processes are meant to outlive this one, so restarting the + // backend does not end a turn somebody is waiting on. Each is recorded + // in its session directory and adopted again on the way back up (see + // `session::process`). The exception is the sessions marked throwaway, + // which are stopped first -- see `--throwaway-sessions`. Both signals, + // because systemd and OpenRC send TERM while a terminal sends INT. + let serving = axum_server::bind_rustls(addr, tls_config) + .serve(app.into_make_service_with_connect_info::()); + let mut terminate = signal(SignalKind::terminate()).context("listening for SIGTERM")?; + tokio::select! { + served = serving => served.context("TLS listener failed")?, + _ = terminate.recv() => tracing::info!("SIGTERM -- letting go of sessions"), + _ = tokio::signal::ctrl_c() => tracing::info!("interrupted -- letting go of sessions"), + } + // Stopped before the rest are let go of, and on every way out of the + // select above: a throwaway session is one nobody meant to keep, and + // the whole point is that nothing has to remember to clean it up. + manager.stop_throwaway_sessions(); + manager.detach_all(); + + Ok(()) +} diff --git a/server/src/media.rs b/server/src/media.rs new file mode 100644 index 0000000..fe9d58a --- /dev/null +++ b/server/src/media.rs @@ -0,0 +1,62 @@ +//! The image types that travel between the phone, the session +//! directories, and a driver's dialect. +//! +//! Media type and file extension have to agree in four places -- storing +//! an upload, serving it back, handing it to a CLI as a content block, and +//! saving one a tool produced -- so the table lives here once. The +//! *default* for an unrecognized type is deliberately not here: it differs +//! by direction (a phone upload is a photo, a produced image is a +//! screenshot), so each caller states its own. + +/// Media type to extension. Only the types Claude's API accepts as image +/// content blocks -- anything else has nowhere to go. +const IMAGE_TYPES: [(&str, &str); 4] = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), +]; + +/// The extension to store `media_type` under, or `None` if it isn't an +/// image type this server handles. +pub fn extension_for(media_type: &str) -> Option<&'static str> { + IMAGE_TYPES + .iter() + .find(|(known, _)| *known == media_type) + .map(|(_, extension)| *extension) +} + +/// The media type of a stored file, from its extension. Names are +/// server-generated (`.`, always lowercase), so no case +/// folding is needed; `None` for anything else. +pub fn media_type_for(name: &str) -> Option<&'static str> { + let (_, extension) = name.rsplit_once('.')?; + IMAGE_TYPES + .iter() + .find(|(_, known)| *known == extension) + .map(|(media_type, _)| *media_type) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_two_directions_agree() { + for (media_type, extension) in IMAGE_TYPES { + assert_eq!(extension_for(media_type), Some(extension)); + assert_eq!( + media_type_for(&format!("abc123.{extension}")), + Some(media_type) + ); + } + } + + #[test] + fn unknown_types_are_the_callers_problem() { + assert_eq!(extension_for("application/pdf"), None); + assert_eq!(media_type_for("abc123.pdf"), None); + // No extension at all -- not "the whole name is the extension". + assert_eq!(media_type_for("abc123"), None); + } +} diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..6f3ae7a --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,677 @@ +//! GGUF models on this machine, and the downloads that produce them. +//! +//! The registry pattern again (see `session`): one owner, one lock, so what +//! is on disk and what this server believes cannot come apart. +//! +//! Three things shape the design, all of them consequences of a model file +//! being gigabytes rather than kilobytes: +//! +//! **A download belongs to the model, not to whoever asked for it.** It is +//! keyed by the model it produces and lives here, so any device can watch +//! it -- including one that did not start it, and one that opened the app +//! after it finished. State in a per-connection channel would not survive +//! the phone locking its screen, which for an hour-long download is the +//! normal case rather than an edge one. +//! +//! **Every run has an id, and its outcome outlives it.** Without those, +//! "not downloading" is three different answers at once -- it finished, +//! it never started, or a different run finished while you were away -- +//! and over an hour that ambiguity is certain to be hit. A device compares +//! the run it was watching against the run reported now. +//! +//! **Progress is measured, never estimated.** `total` is whatever +//! `Content-Length` said and nothing else; when the server does not send +//! one it stays `None` and the phone shows that it does not know, rather +//! than a bar drawn from how long the last download took. + +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result, bail}; +use serde::Serialize; + +use wg_app_link::private; + +/// Identifies this client to HuggingFace. They ask for one, and a request +/// without it is more likely to be rate-limited. +const USER_AGENT: &str = concat!("ai-server/", env!("CARGO_PKG_VERSION")); + +/// Read size per loop iteration. Big enough that the syscall overhead is +/// nothing against a multi-gigabyte file, small enough that a cancel is +/// noticed promptly -- the flag is only checked between chunks. +const CHUNK: usize = 256 * 1024; + +/// A model file sitting on this machine, ready to run. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalModel { + /// `owner/repo/file.gguf` -- the HuggingFace coordinates, which are + /// already unique, so nothing has to invent an id. + pub key: String, + pub repo: String, + pub file: String, + pub bytes: u64, +} + +/// What a run is doing, or did. +/// +/// Flat rather than a tagged enum carrying its message, because the phone +/// switches on this and a string it can compare is easier to render than a +/// variant it has to destructure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DownloadState { + Running, + /// Reading the finished file back to check it against the hash + /// HuggingFace publishes. Its own state because it takes real time on + /// a multi-gigabyte file and "still working" is the honest thing to + /// show, rather than a bar sitting at 100% for half a minute. + Verifying, + Finished, + Failed, + Cancelled, +} + +/// One download run, as the phone sees it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadStatus { + pub key: String, + /// Distinguishes this run from any earlier one for the same model. + /// A device that was watching run 3 can tell that what it is looking + /// at now is run 4 rather than assuming its own run ended. + pub run: u64, + pub repo: String, + pub file: String, + pub state: DownloadState, + /// Bytes on disk, including any carried over from a resumed attempt. + pub done: u64, + /// What `Content-Length` said, or absent when the server did not say. + /// Absent means "unknown", never "zero" -- see this module's doc. + #[serde(skip_serializing_if = "Option::is_none")] + pub total: Option, + /// Present only when [`DownloadState::Failed`], and it is the reason. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub started: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished: Option, +} + +/// The mutable half of a run, behind one lock. +#[derive(Debug)] +struct Progress { + state: DownloadState, + done: u64, + total: Option, + error: Option, + started: f64, + finished: Option, +} + +/// A run, shared between the thread doing the work and everyone watching. +struct Run { + id: u64, + key: String, + repo: String, + file: String, + progress: Mutex, + /// Set by [`ModelStore::cancel`]; the download loop checks it between + /// chunks and stops, leaving the partial file for a later resume. + cancel: AtomicBool, +} + +impl Run { + fn status(&self) -> DownloadStatus { + let p = self.progress.lock().unwrap(); + DownloadStatus { + key: self.key.clone(), + run: self.id, + repo: self.repo.clone(), + file: self.file.clone(), + state: p.state, + done: p.done, + total: p.total, + error: p.error.clone(), + started: p.started, + finished: p.finished, + } + } + + fn finish(&self, state: DownloadState, error: Option) { + let mut p = self.progress.lock().unwrap(); + p.state = state; + p.error = error; + p.finished = Some(crate::session::now()); + } +} + +/// Every model this machine has, and every download in flight or finished. +pub struct ModelStore { + dir: PathBuf, + /// Keyed by model key: one run per model at a time, and the last run + /// for a model stays here after it ends so its outcome can still be + /// read. Bounded by how many distinct models have been asked for. + runs: Mutex>>, + next_run: AtomicU64, +} + +impl ModelStore { + pub fn new(dir: PathBuf) -> Self { + Self { + dir, + runs: Mutex::new(HashMap::new()), + next_run: AtomicU64::new(1), + } + } + + /// Where a model's file lives, refusing anything that would escape the + /// models directory. + /// + /// The repo and file come from a phone, and this server runs as the + /// user who started it, so they are treated as hostile: every + /// component must be an ordinary name. Rejecting is deliberate rather + /// than sanitising, since a silently rewritten path would download the + /// right bytes to the wrong place. + fn path_for(&self, repo: &str, file: &str) -> Result { + let mut path = self.dir.clone(); + for part in repo.split('/').chain(file.split('/')) { + if part.is_empty() || part == "." || part == ".." || part.contains('\\') { + bail!("\"{repo}/{file}\" is not a name this can store: \"{part}\""); + } + path.push(part); + } + Ok(path) + } + + pub fn key_for(repo: &str, file: &str) -> String { + format!("{repo}/{file}") + } + + /// Every `.gguf` found under the models directory, newest first. + /// + /// Read from disk on each call rather than cached: a file deleted by + /// hand should stop being offered, and the directory is small enough + /// that walking it costs nothing next to loading a model. + pub fn list(&self) -> Vec { + let mut found = Vec::new(); + collect(&self.dir, &self.dir, &mut found); + found.sort_by(|a, b| a.key.cmp(&b.key)); + found + } + + /// The status of every run this server remembers. + pub fn downloads(&self) -> Vec { + let runs = self.runs.lock().unwrap(); + let mut all: Vec<_> = runs.values().map(|run| run.status()).collect(); + all.sort_by_key(|status| std::cmp::Reverse(status.run)); + all + } + + /// Starts fetching `file` from `repo`, or returns the run already + /// doing so. + /// + /// Idempotent on purpose: a phone that lost its connection and came + /// back will press the button again, and that must join the existing + /// run rather than start a second one writing the same file. + pub fn start(self: &Arc, repo: &str, file: &str) -> Result { + let key = Self::key_for(repo, file); + let target = self.path_for(repo, file)?; + if target.is_file() { + bail!("{key} is already downloaded"); + } + + let mut runs = self.runs.lock().unwrap(); + if let Some(existing) = runs.get(&key) + && existing.progress.lock().unwrap().state == DownloadState::Running + { + return Ok(existing.status()); + } + + let run = Arc::new(Run { + id: self.next_run.fetch_add(1, Ordering::Relaxed), + key: key.clone(), + repo: repo.to_string(), + file: file.to_string(), + progress: Mutex::new(Progress { + state: DownloadState::Running, + done: 0, + total: None, + error: None, + started: crate::session::now(), + finished: None, + }), + cancel: AtomicBool::new(false), + }); + runs.insert(key, Arc::clone(&run)); + let status = run.status(); + drop(runs); + + // A dedicated thread rather than the blocking pool: this holds its + // thread for as long as the download takes, which is minutes to + // hours, and the pool exists for short work. + let store = Arc::clone(self); + std::thread::spawn(move || { + let outcome = store.fetch(&run, &target); + match outcome { + Ok(()) if run.cancel.load(Ordering::Relaxed) => { + run.finish(DownloadState::Cancelled, None); + tracing::info!("download {} cancelled", run.key); + } + Ok(()) => { + run.finish(DownloadState::Finished, None); + tracing::info!("download {} finished", run.key); + } + Err(err) => { + let message = format!("{err:#}"); + tracing::warn!("download {} failed: {message}", run.key); + run.finish(DownloadState::Failed, Some(message)); + } + } + }); + Ok(status) + } + + /// Asks a running download to stop. The partial file stays, so + /// starting again resumes rather than refetching. + pub fn cancel(&self, key: &str) -> Result { + let runs = self.runs.lock().unwrap(); + let Some(run) = runs.get(key) else { + bail!("no download for {key}"); + }; + run.cancel.store(true, Ordering::Relaxed); + Ok(run.status()) + } + + /// Removes a downloaded model, and any partial file for it. + pub fn delete(&self, key: &str) -> Result<()> { + let (repo, file) = key.rsplit_once('/').context("a key is repo/file")?; + let target = self.path_for(repo, file)?; + let partial = partial_of(&target); + if !target.is_file() && !partial.is_file() { + bail!("{key} is not downloaded"); + } + for path in [&target, &partial] { + if path.is_file() { + std::fs::remove_file(path).with_context(|| format!("remove {}", path.display()))?; + } + } + self.runs.lock().unwrap().remove(key); + Ok(()) + } + + /// The download loop: resume where a partial left off, write, report. + fn fetch(&self, run: &Run, target: &Path) -> Result<()> { + let partial = partial_of(target); + let identity = identity_of(target); + if let Some(parent) = target.parent() { + private::create_dir(parent)?; + } + + // What we have, and what it was part of. A partial with no + // recorded identity is not resumable -- it could be a fragment of + // any revision -- so it is refetched rather than guessed at. + let known = std::fs::read_to_string(&identity) + .ok() + .map(|s| s.trim().to_string()); + let have = match known { + Some(_) => partial.metadata().map(|m| m.len()).unwrap_or(0), + None => 0, + }; + + let url = format!( + "https://huggingface.co/{}/resolve/main/{}", + run.repo, + run.file.replace(' ', "%20") + ); + let (mut response, mut resumed) = request(&url, have)?; + let mut etag = etag_of(&response); + + // HuggingFace's CDN ignores `If-Range` -- probed 2026-08-28: a + // deliberately stale validator still answers 206 with the ranged + // bytes rather than 200 with the whole file. So the header cannot + // be relied on to restart us, and the check is done here instead: + // if what arrived is not the revision our partial belongs to, + // resuming would splice two files into something of exactly the + // right length and the wrong contents. Throw the partial away and + // ask again from zero. + if resumed && etag.is_some() && etag != known { + tracing::info!( + "{} changed upstream since the partial was written -- starting again", + run.key, + ); + let (fresh, fresh_resumed) = request(&url, 0)?; + response = fresh; + resumed = fresh_resumed; + etag = etag_of(&response); + } + + // On a 206, Content-Length is the length of the *range*, not of + // the file -- it answers a different question than the one a + // progress bar asks, and taken at face value it would fill the bar + // at 72 MB of a 234 MB model. The whole size is the last field of + // Content-Range (`bytes 162000000-234074815/234074816`), which has + // the further merit of not depending on where the range began. + let total: Option = if resumed { + response + .headers() + .get("content-range") + .and_then(|v| v.to_str().ok()) + .and_then(|v| { + v.rsplit_once('/') + .map(|(_, whole)| whole.trim().to_string()) + }) + .and_then(|whole| whole.parse().ok()) + } else { + response + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()?.parse().ok()) + }; + let mut done = if resumed { have } else { 0 }; + { + let mut p = run.progress.lock().unwrap(); + p.done = done; + p.total = total; + } + + // `truncate(false)` is the whole resume story: the file is opened + // to be seeked into and appended to, and truncating here would + // throw away exactly the bytes the Range request just asked the + // server not to send again. Stated rather than left to the + // default, because the default is what a reader would have to + // remember. + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&partial) + .with_context(|| format!("open {}", partial.display()))?; + if resumed { + file.seek(SeekFrom::Start(have)) + .context("seek to resume point")?; + } else { + file.set_len(0) + .context("truncate a partial we cannot resume onto")?; + } + // Written before the body, so an interrupted download leaves a + // partial that can still say which revision it belongs to. That is + // what makes it safe to keep one across a restart of this server. + if let Some(etag) = &etag { + std::fs::write(&identity, etag).ok(); + } + + let mut reader = response.body_mut().as_reader(); + let mut buffer = vec![0u8; CHUNK]; + loop { + if run.cancel.load(Ordering::Relaxed) { + file.flush().ok(); + return Ok(()); + } + let read = reader + .read(&mut buffer) + .context("reading from HuggingFace")?; + if read == 0 { + break; + } + file.write_all(&buffer[..read]) + .context("writing the model file")?; + done += read as u64; + run.progress.lock().unwrap().done = done; + } + file.flush().context("flushing the model file")?; + drop(file); + + // Checked before the rename, so a file that fails never gets the + // real name and `list` never offers it. With the identity check + // above this should not fire; it is here because a download of + // this size has too many ways to go subtly wrong to take on + // trust, and because a wrong model is the kind of failure that + // surfaces as bad output rather than as an error. + if let Some(expected) = published_sha256(&run.repo, &run.file) { + run.progress.lock().unwrap().state = DownloadState::Verifying; + let actual = sha256_of(&partial)?; + if actual != expected { + std::fs::remove_file(&partial).ok(); + std::fs::remove_file(&identity).ok(); + bail!( + "{} arrived corrupted -- HuggingFace publishes sha256 {expected}, what \ + arrived hashes to {actual}. It has been deleted; downloading again \ + starts clean.", + run.key, + ); + } + } + + // Renamed only once complete, so a file at its real name is always + // a whole model -- `list` needs no other way to tell. + std::fs::rename(&partial, target) + .with_context(|| format!("finish {}", target.display()))?; + std::fs::remove_file(&identity).ok(); + Ok(()) + } +} + +/// The sha256 of a file, read in chunks -- these are gigabytes, and +/// reading one into memory to hash it would be the largest allocation this +/// server ever makes. +fn sha256_of(path: &Path) -> Result { + use sha2::{Digest, Sha256}; + let mut file = + std::fs::File::open(path).with_context(|| format!("reopen {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; CHUNK]; + loop { + let read = file.read(&mut buffer).context("reading back to verify")?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + // Hex by hand, as wg_app_link::enroll::token_hash_hex also has to, + // since this sha2 version's output type does not implement LowerHex. + Ok(hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +/// One GET, ranged when there is something to resume onto. +fn request(url: &str, from: u64) -> Result<(ureq::http::Response, bool)> { + let mut get = ureq::get(url).header("User-Agent", USER_AGENT); + if from > 0 { + get = get.header("Range", &format!("bytes={from}-")); + } + let response = get.call().with_context(|| format!("GET {url}"))?; + // Trust the status, not the request: a server that ignores Range + // answers 200 with the whole file, and appending to that would + // corrupt it. + let resumed = response.status() == 206; + Ok((response, resumed)) +} + +fn etag_of(response: &ureq::http::Response) -> Option { + Some( + response + .headers() + .get("etag")? + .to_str() + .ok()? + .trim() + .to_string(), + ) +} + +/// `x.gguf` -> `x.gguf.part.etag`, holding which revision the partial +/// beside it is a piece of. +fn identity_of(target: &Path) -> PathBuf { + let mut name = target.as_os_str().to_os_string(); + name.push(".part.etag"); + PathBuf::from(name) +} + +/// `x.gguf` -> `x.gguf.part`, the in-progress name. +fn partial_of(target: &Path) -> PathBuf { + let mut name = target.as_os_str().to_os_string(); + name.push(".part"); + PathBuf::from(name) +} + +/// Walks `dir` collecting `.gguf` files, keyed by their path under `root`. +fn collect(root: &Path, dir: &Path, found: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect(root, &path, found); + continue; + } + if path.extension().is_none_or(|e| e != "gguf") { + continue; + } + let Ok(relative) = path.strip_prefix(root) else { + continue; + }; + let key = relative.to_string_lossy().replace('\\', "/"); + let Some((repo, file)) = key.rsplit_once('/') else { + continue; + }; + found.push(LocalModel { + key: key.clone(), + repo: repo.to_string(), + file: file.to_string(), + bytes: entry.metadata().map(|m| m.len()).unwrap_or(0), + }); + } +} + +/// A model repository on HuggingFace, as the browse screen shows it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteRepo { + /// `owner/name`, which is what everything else here is keyed by. + pub id: String, + pub downloads: u64, + pub likes: u64, +} + +/// One downloadable file inside a repository. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteFile { + pub path: String, + pub bytes: u64, + /// Already on this machine, so the phone can say so rather than + /// offering to fetch it again. + pub have: bool, +} + +/// Searches HuggingFace for GGUF repositories matching `query`. +/// +/// Proxied through this server rather than called from the phone, for two +/// reasons that both matter: the app trusts exactly one certificate -- +/// this server's -- and has no general internet trust to spend on +/// huggingface.co, and the machine that has to do the downloading is this +/// one, so it is also the one whose view of what exists is relevant. +pub fn search(query: &str) -> Result> { + let url = format!( + "https://huggingface.co/api/models?search={}&filter=gguf&limit=25&sort=downloads&direction=-1", + urlencode(query) + ); + let body = get_json(&url)?; + let list = body + .as_array() + .context("HuggingFace returned something that is not a list")?; + Ok(list + .iter() + .filter_map(|m| { + Some(RemoteRepo { + id: m.get("id")?.as_str()?.to_string(), + downloads: m + .get("downloads") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + likes: m + .get("likes") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + }) + }) + .collect()) +} + +/// The sha256 HuggingFace publishes for one file, if it publishes one. +/// +/// It is the LFS object id, which for these repositories is the sha256 of +/// the content -- so it is a free integrity check on a download rather +/// than something we would have to compute a second source of truth for. +fn published_sha256(repo: &str, file: &str) -> Option { + let url = format!("https://huggingface.co/api/models/{repo}/tree/main?expand=true"); + let body = get_json(&url).ok()?; + body.as_array()?.iter().find_map(|f| { + (f.get("path")?.as_str()? == file) + .then(|| f.get("lfs")?.get("oid")?.as_str().map(str::to_string))? + }) +} + +/// The GGUF files in one repository, largest last, with the ones already +/// downloaded marked. +pub fn files(repo: &str, store: &ModelStore) -> Result> { + let url = format!("https://huggingface.co/api/models/{repo}/tree/main"); + let body = get_json(&url)?; + let list = body + .as_array() + .context("HuggingFace returned something that is not a list")?; + let have: std::collections::HashSet = store.list().into_iter().map(|m| m.key).collect(); + let mut files: Vec = list + .iter() + .filter_map(|f| { + let path = f.get("path")?.as_str()?.to_string(); + if !path.ends_with(".gguf") { + return None; + } + Some(RemoteFile { + bytes: f + .get("size") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0), + have: have.contains(&ModelStore::key_for(repo, &path)), + path, + }) + }) + .collect(); + files.sort_by_key(|f| f.bytes); + Ok(files) +} + +fn get_json(url: &str) -> Result { + let text = ureq::get(url) + .header("User-Agent", USER_AGENT) + .call() + .and_then(|mut r| r.body_mut().read_to_string()) + .with_context(|| format!("GET {url}"))?; + serde_json::from_str(&text).with_context(|| format!("{url} did not return JSON")) +} + +/// Percent-encodes a query string. Deliberately minimal -- this escapes +/// what a model search actually contains rather than implementing the +/// whole rule set, and anything unexpected becomes `%XX` rather than +/// being passed through. +fn urlencode(value: &str) -> String { + value + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (b as char).to_string() + } + b' ' => "+".to_string(), + other => format!("%{other:02X}"), + }) + .collect() +} diff --git a/server/src/routes.rs b/server/src/routes.rs new file mode 100644 index 0000000..e328836 --- /dev/null +++ b/server/src/routes.rs @@ -0,0 +1,1265 @@ +//! The HTTP surface -- REST for actions, one SSE stream per open session +//! screen for events, all behind the bearer-token middleware `main.rs` +//! wraps the whole router in. +//! +//! ```text +//! GET /setups machines, each with what it can run +//! POST /setups add {name, ssh?} -- providers are discovered +//! POST /setups/probe dry run {ssh?}: what would be found there +//! GET /setups/{id} one machine, for refetching after a change +//! PUT /setups/{id} rename {name?} and/or re-probe {rediscover?} +//! DELETE /setups/{id} remove, refused while sessions use it +//! GET /sessions list (id, provider, title, model, status, last activity) +//! GET /sessions/{id} one session, for refetching after a change +//! POST /sessions spawn {setup, provider, title?, model?, cwd?, params?} +//! GET /sessions/{id}/events?after=N SSE: backlog after N, then live +//! (a backlog past CATCH_UP_LIMIT arrives as a +//! `reset` frame plus the newest window) +//! POST /sessions/{id}/message {text, attachmentIds?} +//! (starts the process first if it has exited) +//! POST /sessions/{id}/answer {questionId, answers} (questions and permissions) +//! POST /sessions/{id}/interrupt stop the running turn; the process stays +//! POST /sessions/{id}/stop end the process; the session and transcript stay +//! POST /sessions/{id}/start run the process again, continuing the conversation +//! POST /sessions/{id}/title {title} +//! POST /sessions/{id}/model {model} +//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own +//! (starts the process first if it has exited) +//! POST /sessions/{id}/compact +//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message +//! GET /sessions/{id}/files/{name} images the session produced or was sent +//! DELETE /sessions/{id} kill process, delete transcript + files +//! (?deleteForeign=true removes the machine's own copy too) +//! POST /sessions/{id}/notify {notify} -- announce this one or not +//! GET /notifications SSE: every session's attention-wanting +//! moments, live only (see `notifications`) +//! GET /usage cached usage windows per provider +//! ``` +//! +//! Later phases add: `GET|PUT /hosts` and `/models` -- see PLAN.md's table. +//! +//! Everything here works purely in the common event model; nothing may +//! branch on the session kind (that's what drivers are for). +//! +//! **Every request body in this module refuses fields it does not know** +//! (`serde(deny_unknown_fields)`), and a new one is expected to do the +//! same. Silently ignoring a field is the worst available answer: a caller +//! that misspells `permissionMode` got a 200 and a session running in the +//! default permission mode, which is indistinguishable from success at the +//! place they are looking. It cost an hour here, chasing a "startup race" +//! that was a snake_case key serde had dropped on the floor. Query strings +//! are deliberately left permissive -- a stale link carrying an extra +//! parameter is not a mistake worth failing a request over. + +use std::convert::Infallible; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::Context; + +use axum::Router; +use axum::extract::{Path as UrlPath, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::sse::{Event as SseEvent, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use serde::Deserialize; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; + +use crate::session::driver::SessionCommand; +use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; +use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; + +pub fn router(manager: Arc) -> Router { + Router::new() + .route("/setups", get(list_setups).post(add_setup)) + .route("/setups/probe", post(probe_setup)) + .route("/setups/{id}/importable", get(list_importable)) + .route( + "/setups/{id}/importable/{session}", + delete(delete_importable), + ) + .route( + "/setups/{id}", + get(read_setup).put(update_setup).delete(delete_setup), + ) + .route("/sessions", get(list_sessions).post(spawn_session)) + .route("/sessions/{id}", get(read_session).delete(delete_session)) + .route("/sessions/{id}/events", get(events)) + .route("/sessions/{id}/transcript", get(transcript)) + .route("/sessions/{id}/message", post(message)) + .route("/sessions/{id}/answer", post(answer)) + .route("/sessions/{id}/interrupt", post(interrupt)) + .route("/sessions/{id}/stop", post(stop)) + .route("/sessions/{id}/start", post(start)) + .route("/sessions/{id}/title", post(rename)) + .route("/sessions/{id}/model", post(set_model)) + .route("/sessions/{id}/permission-mode", post(set_permission_mode)) + .route("/sessions/{id}/notify", post(set_notify)) + .route("/notifications", get(notifications)) + .route("/sessions/{id}/compact", post(compact)) + .route("/sessions/{id}/command", post(command)) + .route("/sessions/{id}/attachments", post(upload_attachment)) + .route("/sessions/{id}/files/{name}", get(serve_file)) + // Phone photos overflow axum's 2 MB default body cap. + .layer(axum::extract::DefaultBodyLimit::max(32 * 1024 * 1024)) + // An explicit fallback so the auth middleware (layered around the + // whole router in main.rs) also covers unknown paths -- a scanner + // gets the same 401 everywhere, never a route map. + .fallback(|| async { ApiError::UnknownRoute }) + .with_state(manager) +} + +#[derive(Debug, thiserror::Error)] +enum ApiError { + #[error("{0}")] + NotFound(String), + #[error("no such route")] + UnknownRoute, + #[error("{0}")] + BadRequest(String), + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let status = match self { + Self::NotFound(_) | Self::UnknownRoute => StatusCode::NOT_FOUND, + Self::BadRequest(_) => StatusCode::BAD_REQUEST, + Self::Internal(err) => { + // The only variant whose real cause isn't safe to hand + // back verbatim, and the only one worth a log line. + tracing::error!("{err:#}"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + (status, self.to_string()).into_response() + } +} + +/// An `anyhow` error from a session mutation is a message written *for* +/// the phone ("no session abc123") -- not an internal fault, so it comes +/// back as a 400 with that message rather than a 500 and a log line. +fn bad_request(err: anyhow::Error) -> ApiError { + ApiError::BadRequest(format!("{err:#}")) +} + +fn lookup(manager: &SessionManager, id: &str) -> Result, ApiError> { + manager + .session(id) + .ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) +} + +async fn list_sessions(State(manager): State>) -> axum::Json> { + axum::Json(manager.sessions()) +} + +/// One session's row, for a screen that has to show what is true now. +/// +/// The list is a snapshot taken when somebody last looked at it, and a +/// screen opened from a row carries that snapshot with it. That is fine for +/// what a row *says* and wrong for what a control is *set to*: a switch +/// drawn from a stale row shows the position it had when the list was +/// fetched, which may be minutes and another device ago, and the person +/// reading it cannot tell. Same reason `GET /setups/{id}` exists. +async fn read_session( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result, ApiError> { + manager + .sessions() + .into_iter() + .find(|session| session.id == id) + .map(axum::Json) + .ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) +} + +/// What the spawn screen needs to render itself, so the phone holds no +/// hardcoded list: a setup added to `config.ron` shows up with no app +/// rebuild. +/// +/// One list rather than two, because the choice is a pair and the halves +/// are not independent. A provider only exists on a machine that has it +/// installed, so listing providers and machines separately offered their +/// whole cross-product -- including "the Claude CLI on the box that hasn't +/// got it". +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SetupInfo { + /// Stable; what a session stores and what these routes address. + id: String, + /// The editable label. + name: String, + /// Where it runs, for telling two setups apart. Absent for the one + /// that is this machine. + #[serde(skip_serializing_if = "Option::is_none")] + address: Option, + providers: Vec, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ProviderInfo { + name: String, + kind: crate::config::DriverKind, + models: Vec, +} + +async fn list_setups(State(manager): State>) -> axum::Json> { + axum::Json(manager.setups().into_iter().map(info_for).collect()) +} + +fn info_for(setup: crate::config::SetupConfig) -> SetupInfo { + SetupInfo { + id: setup.id, + name: setup.name, + address: setup.ssh.map(|ssh| ssh.address), + providers: setup + .providers + .into_iter() + .map(|provider| ProviderInfo { + name: provider.name, + kind: provider.kind, + models: provider.models, + }) + .collect(), + } +} + +/// How to reach a machine, as the phone describes it. +/// +/// Note what is absent: nothing here names a program. Providers are found +/// by asking the machine (`crate::setups`), never sent, so the enrolled +/// token cannot introduce something to run. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct SshRequest { + address: String, + #[serde(default)] + port: Option, + /// A path on the *backend*, not a key itself: private keys do not + /// travel, so this names one that must already be there. + #[serde(default)] + identity_file: Option, + #[serde(default)] + options: Vec, +} + +impl SshRequest { + /// Tidied at the boundary rather than stored as typed -- this came + /// from a phone keyboard, so it may have a stray space or a `~`. + fn into_config(self) -> Result { + let address = crate::setups::tidy(&self.address) + .ok_or_else(|| ApiError::BadRequest("a machine needs an address".to_string()))?; + Ok(crate::config::SshConfig { + address, + port: self.port, + identity_file: self + .identity_file + .as_deref() + .and_then(crate::setups::tidy) + .map(std::path::PathBuf::from), + options: self + .options + .iter() + .filter_map(|o| crate::setups::tidy(o)) + .collect(), + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct AddSetupRequest { + name: String, + /// Absent means this machine. + #[serde(default)] + ssh: Option, +} + +/// What a machine turned out to have, without saving anything. +/// +/// The point of trying before committing: a wrong address or an +/// unauthorised key is caught while the person is still looking at the +/// form that caused it, rather than at the first spawn. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct ProbeRequest { + #[serde(default)] + ssh: Option, +} + +async fn probe_setup( + axum::Json(body): axum::Json, +) -> Result>, ApiError> { + let ssh = body.ssh.map(SshRequest::into_config).transpose()?; + let providers = probe(ssh, "this setup").await?; + Ok(axum::Json( + providers + .into_iter() + .map(|provider| ProviderInfo { + name: provider.name, + kind: provider.kind, + models: provider.models, + }) + .collect(), + )) +} + +/// Asks the machine an `ssh` block describes -- or this one -- what it has. +/// +/// `label` only ever appears in a failure message, so a probe of an +/// unsaved form can still say which machine would not answer. +async fn probe( + ssh: Option, + label: &str, +) -> Result, ApiError> { + let transport = match ssh { + Some(ssh) => crate::session::transport::Transport::Ssh { + name: label.to_string(), + ssh, + }, + None => crate::session::transport::Transport::Here, + }; + crate::setups::discover(&transport) + .await + .map_err(bad_request) +} + +async fn add_setup( + State(manager): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let ssh = body.ssh.map(SshRequest::into_config).transpose()?; + // Ask the machine being added what it has, before writing anything -- + // so a bad address fails here rather than leaving a setup that can + // never spawn. + let providers = probe(ssh.clone(), &body.name).await?; + let setup = manager + .add_setup(&body.name, ssh, providers) + .map_err(bad_request)?; + Ok(axum::Json(info_for(setup))) +} + +/// One setup by id, or the 404 that says so. +/// +/// Three handlers ask this same question; the answer, and the wording of +/// the refusal, belong in one place. +fn setup_by_id( + manager: &Arc, + id: &str, +) -> Result { + manager + .setups() + .into_iter() + .find(|setup| setup.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no setup {id}"))) +} + +async fn read_setup( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result, ApiError> { + setup_by_id(&manager, &id).map(|setup| axum::Json(info_for(setup))) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct UpdateSetupRequest { + #[serde(default)] + name: Option, + /// Ask the machine again what it has -- after installing something + /// there, or when a binary moved. + #[serde(default)] + rediscover: bool, +} + +async fn update_setup( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let providers = if body.rediscover { + let existing = manager + .setups() + .into_iter() + .find(|setup| setup.id == id) + .ok_or_else(|| ApiError::NotFound(format!("no setup {id}")))?; + let transport = crate::session::transport::Transport::for_setup(&existing); + Some( + crate::setups::discover(&transport) + .await + .map_err(bad_request)?, + ) + } else { + None + }; + let setup = manager + .update_setup(&id, body.name.as_deref(), providers) + .map_err(bad_request)?; + Ok(axum::Json(info_for(setup))) +} + +async fn delete_setup( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + manager.delete_setup(&id).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct SpawnRequest { + /// Which machine, and which of the things it offers. + setup: String, + provider: String, + #[serde(default)] + title: Option, + #[serde(default)] + model: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + permission_mode: Option, + /// Whatever the chosen driver understands -- llama.cpp's context size + /// and sampling, for instance. Opaque here on purpose: see + /// `SessionConfig::params`. + #[serde(default)] + params: std::collections::BTreeMap, + /// Continue a Claude Code session the machine already has, named by + /// the id `GET /setups/{id}/importable` reported. + /// + /// An id and not a path, deliberately. The server looks the path up + /// again among the sessions it enumerated, so an enrolled token cannot + /// turn this field into "read me an arbitrary file" -- the same rule + /// that keeps a provider's command out of `POST /setups`. + #[serde(default)] + import: Option, +} + +/// What a machine already has that could be continued. +async fn list_importable( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result>, ApiError> { + let setup = setup_by_id(&manager, &id)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + let mut found = crate::session::import::list(&transport) + .await + .map_err(bad_request)?; + // Anything this app is already continuing is not offered again. Left + // out rather than shown-and-disabled, because it has not disappeared: + // it is in the session list, which is where it now belongs. Absence + // here means "already somewhere you can reach it", not "gone". + // + // Joined here because the importer knows about files and the manager + // knows about sessions, and putting the two together is the route's + // job rather than either one's. + found.retain(|candidate| manager.session_driving(&candidate.id).is_none()); + Ok(axum::Json(found)) +} + +/// Removes a Claude Code session from a machine. +/// +/// The transcript *is* the session, so this ends any chance of resuming +/// that conversation -- including from an ai-app session already importing +/// it. The phone confirms before calling this; the server does not +/// second-guess a decision somebody was shown the cost of. +async fn delete_importable( + State(manager): State>, + UrlPath((id, session)): UrlPath<(String, String)>, +) -> Result { + let setup = setup_by_id(&manager, &id)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + crate::session::import::delete(&transport, &session) + .await + .map_err(bad_request)?; + tracing::info!("deleted Claude Code session {session} from setup {id}"); + Ok(StatusCode::NO_CONTENT) +} + +async fn spawn_session( + State(manager): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + // Resolved before the spawn because both halves of it are the + // machine's answer, not the phone's: which file that id names, and + // what is in it. + let seed = match &body.import { + Some(want) => { + let setup = setup_by_id(&manager, &body.setup)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + let found = crate::session::import::list(&transport) + .await + .map_err(bad_request)?; + let chosen = found + .into_iter() + .find(|candidate| &candidate.id == want) + .ok_or_else(|| { + ApiError::NotFound(format!( + "setup \"{}\" has no Claude Code session {want} to import", + body.setup + )) + })?; + if let Some(existing) = manager.session_driving(want) { + return Err(ApiError::BadRequest(format!( + "session {existing} is already continuing that one -- delete it first if you \ + want a fresh copy. Deleting it here does not touch the conversation itself, \ + only this app's view of it." + ))); + } + // Refused rather than warned about, because there is nothing + // useful on the other side of it. Importing an open session + // puts a second `--resume` on a file the first one is still + // writing: the conversation gets duplicated into it, each copy + // replays the other's writes as work done elsewhere, and the + // adopted one is billed for re-reading the whole thing. On + // 2026-08-29 that was 65 MB and 154 screenshots. + if chosen.in_use == crate::session::import::InUse::Yes { + return Err(ApiError::BadRequest(format!( + "{want} is open in a terminal right now. Importing it would put a second \ + Claude Code on the same conversation, which duplicates it and re-reads the \ + whole thing. Close it there first, then import it here." + ))); + } + let records = crate::session::import::read_tail(&transport, &chosen.path) + .await + .map_err(bad_request)?; + // The recorded directory can outlive itself; resuming into one + // that is gone fails at `cd` before the CLI starts. Starting + // somewhere real keeps the conversation, which is the point of + // importing, and the log says which one was dropped. + let mut chosen = chosen; + if !crate::session::import::directory_exists(&transport, &chosen.cwd).await { + tracing::warn!( + "imported session {} recorded {} as its directory, which is not there any \ + more -- starting in the default one instead", + chosen.id, + chosen.cwd, + ); + chosen.cwd = String::new(); + } + Some((chosen, records)) + } + None => None, + }; + + let spec = SpawnSpec { + setup: body.setup, + provider: body.provider, + // An imported session is recognised by what it was about, so its + // opening message is the title unless one was typed. + // Blank normalised to absent here rather than trusted as a + // choice. A client with nothing to say sends `""`, which is + // `Some` and so satisfied `or_else` -- the imported session's real + // title was computed, then discarded in favour of the " + // session" fallback, so every import arrived called "claude-cli + // session". Absent and empty mean the same thing to a person and + // have to mean the same thing here. + title: body + .title + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + seed.as_ref() + .map(|(chosen, _)| chosen.title.clone()) + .filter(|title| !title.trim().is_empty()) + }), + model: body.model, + // Resumed where it was working, so the CLI picks up the same tree. + cwd: body.cwd.or_else(|| { + seed.as_ref() + .map(|(chosen, _)| PathBuf::from(&chosen.cwd)) + .filter(|cwd| cwd.as_os_str() != "") + }), + permission_mode: body.permission_mode, + params: body.params, + }; + + let info = match seed { + Some((chosen, records)) => { + tracing::info!( + "importing Claude Code session {} ({} lines replayed)", + chosen.id, + records.lines().count() + ); + manager.spawn_imported( + spec, + crate::session::Seed { + resume: chosen.id.clone(), + // Where it came from and how far it has been shown, so + // the session keeps itself level with the file a + // terminal is also writing to. + cursor: crate::session::import::Cursor { + path: chosen.path, + lines: chosen.lines, + }, + records, + }, + ) + } + None => manager.spawn_session(spec), + } + .map_err(bad_request)?; + tracing::info!( + "spawned {} session {} ({})", + info.provider, + info.id, + info.title + ); + Ok(axum::Json(info)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteSessionQuery { + /// Also remove the machine's own transcript of this conversation -- + /// the file Claude Code keeps under `~/.claude/projects`, which this + /// server's delete does not otherwise touch. + /// + /// Off by default, because the two deletes differ in what they cost: + /// leaving the machine's copy behind is recoverable and removing it is + /// not, and a default is the one choice nobody is shown. + #[serde(default)] + delete_foreign: bool, +} + +async fn delete_session( + State(manager): State>, + UrlPath(id): UrlPath, + Query(query): Query, +) -> Result { + // Before the session goes, because only the session record says which + // file on which machine this conversation is. + let foreign = query + .delete_foreign + .then(|| manager.foreign_transcript(&id)) + .flatten(); + // And *deleted* before it too, so a machine that cannot be reached + // leaves everything as it was rather than a deleted session and a + // transcript the phone has already promised is gone. The phone can + // then retry, or turn the toggle off. + if let Some((setup, session)) = &foreign { + let setup = setup_by_id(&manager, setup)?; + let transport = crate::session::transport::Transport::for_setup(&setup); + crate::session::import::delete(&transport, session) + .await + .map_err(bad_request)?; + tracing::info!("deleted Claude Code session {session} with ai-app session {id}"); + } + manager.delete_session(&id).map_err(bad_request)?; + tracing::info!("deleted session {id}"); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct MessageRequest { + text: String, + /// Ids from `POST /attachments`, uploaded before the message that + /// references them. + #[serde(default)] + attachment_ids: Vec, +} + +async fn message( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + // For the 404 a session that is not here has always answered with; the + // send itself goes through the manager, which may have to start a + // process before there is anything to send to. + lookup(&manager, &id)?; + if body.text.trim().is_empty() && body.attachment_ids.is_empty() { + return Err(ApiError::BadRequest("message is empty".to_string())); + } + manager + .send_message(&id, body.text, body.attachment_ids) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct AnswerRequest { + question_id: String, + /// Everything chosen, in the order it was offered. A question that + /// takes one answer sends a list of one, so there is one shape here + /// rather than a single-answer route and a multi-answer route beside + /// it. + answers: Vec, +} + +async fn answer( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + if body.answers.is_empty() { + return Err(bad_request(anyhow::anyhow!( + "an answer needs at least one choice" + ))); + } + lookup(&manager, &id)?.answer_question(&body.question_id, &body.answers); + Ok(StatusCode::NO_CONTENT) +} + +async fn interrupt( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + lookup(&manager, &id)?.interrupt(); + Ok(StatusCode::NO_CONTENT) +} + +/// Ends the session's process. The session stays, and `start` brings it +/// back -- see [`SessionManager::stop_session`]. +/// +/// Not `lookup`ed: a session that failed to relaunch has no live entry and +/// may still have a process running, which is exactly one worth being able +/// to stop. +async fn stop( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + manager.stop_session(&id).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Starts a process for a session that has none, continuing the same +/// conversation -- see [`SessionManager::start_session`], which refuses +/// unless the session is known to have exited. +async fn start( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + manager.start_session(&id).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +/// The usage screen needs two things that live in different places: the +/// cache, and the current list of machines to ask. Carried together rather +/// than the monitor holding the manager, which would point the dependency +/// upward -- `usage` sits below the session layer and should not reach +/// into it. +#[derive(Clone)] +pub struct UsageState { + monitor: Arc, + manager: Arc, +} + +/// Separate router because its state is the usage monitor, not the +/// session manager; merged (and auth-wrapped) with the rest in `main`. +pub fn usage_router( + monitor: Arc, + manager: Arc, +) -> Router { + Router::new() + .route("/usage", get(usage)) + .with_state(UsageState { monitor, manager }) +} + +async fn usage( + State(state): State, +) -> Result>, ApiError> { + // Read here rather than inside the fetch, so the list of machines is + // the one that existed when the request arrived and cannot change + // under a fetch that takes an ssh round trip per machine. + let setups = state.manager.setups(); + // The fetch is blocking by design (see `usage`); off the workers. + let snapshots = tokio::task::spawn_blocking(move || state.monitor.snapshots(&setups)) + .await + .context("usage fetch panicked")?; + Ok(axum::Json(snapshots)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct TitleRequest { + title: String, +} + +async fn rename( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .rename_session(&id, &body.title) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ModelRequest { + model: String, +} + +async fn set_model( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_session_model(&id, &body.model) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +struct PermissionModeRequest { + mode: String, +} + +async fn set_permission_mode( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_session_permission_mode(&id, &body.mode) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NotifyRequest { + notify: bool, +} + +async fn set_notify( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + manager + .set_session_notify(&id, body.notify) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CommandRequest { + text: String, +} + +/// Runs one of the session's own commands, now or at the next boundary. +/// +/// The two this server understands are turned into the operations it has +/// -- a compaction, a rename, which is also how the settings screen asks +/// -- and everything else is passed to the session verbatim, because a +/// dialect's vocabulary is its own and grows without this file. +async fn command( + State(manager): State>, + UrlPath(id): UrlPath, + axum::Json(body): axum::Json, +) -> Result { + let text = body.text.trim(); + let (name, rest) = match text.split_once(char::is_whitespace) { + Some((name, rest)) => (name, rest.trim()), + None => (text, ""), + }; + // All of these start the session's process first if it has exited: a + // command is something somebody asked the session to do, and answering + // that its process is gone hands back the work of starting one. + // + // A rename still goes through `rename_session` rather than being a + // command like the rest, because the name is persisted and listed as + // well as forwarded, and that is one operation. It starts a process + // too, and for a sharper reason than the others -- see there. + let command = match (name, rest) { + ("/compact", _) => SessionCommand::Compact, + ("/clear", _) => SessionCommand::Clear, + ("/rename", "") => return Err(bad_request(anyhow::anyhow!("a session needs a name"))), + ("/rename", title) => { + manager.rename_session(&id, title).map_err(bad_request)?; + return Ok(StatusCode::NO_CONTENT); + } + _ => SessionCommand::Raw(text.to_string()), + }; + lookup(&manager, &id)?; + manager.run_command(&id, command).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn compact( + State(manager): State>, + UrlPath(id): UrlPath, +) -> Result { + lookup(&manager, &id)?; + manager + .run_command(&id, SessionCommand::Compact) + .map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} + +/// Accepts one image (any multipart field) and stores it under the +/// session; the returned id goes into a later `/message`'s attachmentIds. +async fn upload_attachment( + State(manager): State>, + UrlPath(id): UrlPath, + mut multipart: axum::extract::Multipart, +) -> Result, ApiError> { + let session = lookup(&manager, &id)?; + let field = multipart + .next_field() + .await + .map_err(|err| ApiError::BadRequest(format!("bad upload: {err}")))? + .ok_or_else(|| ApiError::BadRequest("no file in the upload".to_string()))?; + let content_type = field.content_type().unwrap_or("image/jpeg").to_string(); + let bytes = field + .bytes() + .await + .map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?; + let name = session + .save_attachment(&bytes, &content_type) + .map_err(bad_request)?; + Ok(axum::Json(serde_json::json!({ "id": name }))) +} + +/// Serves a session's stored images -- both `files/` (produced by tools) +/// and `attachments/` (uploaded from the phone), by the id events and +/// uploads reference. +async fn serve_file( + State(manager): State>, + UrlPath((id, name)): UrlPath<(String, String)>, +) -> Result { + // Ids are server-generated hex + extension; anything else (and any + // path separator in particular) is refused, not resolved. + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.') || name.contains("..") { + return Err(ApiError::BadRequest("invalid file id".to_string())); + } + let session = lookup(&manager, &id)?; + let candidates = [ + session.dir().join("files").join(&name), + session.dir().join("attachments").join(&name), + ]; + let Some(path) = candidates.iter().find(|path| path.is_file()) else { + return Err(ApiError::NotFound(format!( + "no file {name} in session {id}" + ))); + }; + // A file that is there but unreadable is this server's fault, not the + // request's -- Internal logs it and says nothing more to the caller. + let bytes = std::fs::read(path) + .with_context(|| format!("read {}", path.display())) + .map_err(ApiError::Internal)?; + // Names are server-generated, so an unrecognized extension can only + // mean a file this server didn't write. + let content_type = crate::media::media_type_for(&name).unwrap_or("image/jpeg"); + Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response()) +} + +#[derive(Deserialize)] +struct EventsQuery { + #[serde(default)] + after: u64, +} + +/// The session screen's one data source: replay everything after the +/// cursor from the transcript, then live events as they happen. An SSE +/// auto-reconnect sends the last event id it saw as `Last-Event-ID`, which +/// takes precedence over `after` -- same cursor, native mechanism. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TranscriptQuery { + /// Page backwards from this sequence number; absent means the newest. + #[serde(default)] + before: Option, + #[serde(default = "default_window")] + limit: usize, +} + +fn default_window() -> usize { + 80 +} + +/// A page of a session's transcript, newest first to open with. +/// +/// One request rather than one stream frame per event. The SSE stream +/// stays as it is and remains the right shape for *live* events, which +/// arrive one at a time by nature; it is only the backlog that has to +/// stop pretending to be live. +async fn transcript( + State(manager): State>, + UrlPath(id): UrlPath, + Query(query): Query, +) -> Result>, ApiError> { + let session = lookup(&manager, &id)?; + let events = crate::session::transcript::read_window( + session.transcript_path(), + query.before, + query.limit, + ) + .map_err(bad_request)?; + // How far back a phone has paged, and how much each page cost it to get + // there, which is the one question this route raises and nothing else + // can answer: the app asks for events and draws rows, and the ratio + // between them is a property of the conversation. `RUST_LOG=ai_server=debug`. + tracing::debug!( + session = %id, + before = ?query.before, + limit = query.limit, + got = events.len(), + oldest = ?events.first().map(|entry| entry.seq), + "transcript page" + ); + Ok(axum::Json(events)) +} + +async fn events( + State(manager): State>, + UrlPath(id): UrlPath, + Query(query): Query, + headers: HeaderMap, +) -> Result>>, ApiError> { + let session = lookup(&manager, &id)?; + let cursor = headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse().ok()) + .unwrap_or(query.after); + + // Subscribe before reading the file so nothing can land in the gap + // between replay and live; overlap is deduplicated by seq. + let live = session.subscribe(); + let (tx, stream) = mpsc::channel(64); + tokio::spawn(stream_session( + session.transcript_path().to_path_buf(), + cursor, + live, + tx, + )); + Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())) +} + +/// Every session's attention-wanting moments, on one stream. +/// +/// **Live only, with no cursor**, which is the one place this server does not +/// offer to catch a client up. A notification is a claim about now: replaying +/// "your turn" from an hour ago tells somebody to go and look at a session +/// that may have been answered from another device since, and a notification +/// that is wrong is worse than one that never came -- it costs the reader the +/// trip *and* teaches them to distrust the next one. What was missed while +/// disconnected is still on the session list, which is the surface that +/// answers "what is waiting" without claiming to be news. +async fn notifications( + State(manager): State>, +) -> Sse>> { + let live = manager.subscribe_notifications(); + let stream = BroadcastStream::new(live).filter_map(|item| { + // A lagged subscriber has lost the oldest notifications, and there is + // nothing useful to say about that: the ones it still gets are the + // recent ones, which are the ones worth acting on. + let notification = item.ok()?; + Some(Ok(SseEvent::default().json_data(¬ification).ok()?)) + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +/// Feeds one SSE subscriber: transcript replay after the cursor, then live +/// events, catching back up from the file whenever the broadcast channel +/// laps us. Ends when the client disconnects (send fails) or the session +/// is deleted (channel closed). +async fn stream_session( + transcript: PathBuf, + mut last: u64, + mut live: broadcast::Receiver, + tx: mpsc::Sender, +) { + if !send_backlog(&transcript, &mut last, &tx).await { + return; + } + loop { + match live.recv().await { + Ok(entry) => { + if entry.seq <= last { + continue; + } + last = entry.seq; + if send_event(&tx, &entry).await.is_err() { + return; + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + if !send_backlog(&transcript, &mut last, &tx).await { + return; + } + } + Err(broadcast::error::RecvError::Closed) => return, + } + } +} + +/// Sends everything after `last`, advancing it, and answers whether the +/// subscriber is still there. +/// +/// A [`CatchUp::Restart`] is preceded by the `reset` frame that tells the +/// client to drop what it holds. Without it the window would be spliced +/// onto rows that are no longer adjacent to it, which reads as ordinary +/// output rather than as a gap -- which is why a bounded backlog cannot +/// simply be "the newest events". +/// +/// Both ways into a backlog come through here -- the first replay and the +/// recovery from a lapped broadcast -- because either can be arbitrarily +/// far behind and owes the client the same answer. +/// +/// Synchronous file reads from an async task: transcript lines are small +/// and local; revisit if daily use produces transcripts where this shows +/// (phase 6 territory). +async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender) -> bool { + let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) { + Ok(CatchUp::Continue(entries)) => entries, + Ok(CatchUp::Restart(entries)) => { + if tx.send(SseEvent::default().event("reset")).await.is_err() { + return false; + } + entries + } + Err(err) => { + tracing::error!("transcript replay failed: {err:#}"); + return false; + } + }; + for entry in entries { + *last = entry.seq; + if send_event(tx, &entry).await.is_err() { + return false; + } + } + true +} + +async fn send_event( + tx: &mpsc::Sender, + entry: &SeqEvent, +) -> Result<(), mpsc::error::SendError> { + let data = serde_json::to_string(entry).expect("events always serialize"); + tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)) + .await +} + +/// Separate router because its state is the model store, like `usage`'s. +/// +/// Keys are `owner/repo/file.gguf` and so contain slashes, which is why +/// nothing here puts one in the path: a key travels in the body or a query +/// string, and the routes stay addressable without escaping rules nobody +/// would get right from a phone. +pub fn models_router(store: Arc) -> Router { + Router::new() + .route("/models", get(list_models)) + .route("/models/search", get(search_models)) + .route("/models/files", get(repo_files)) + .route("/models/download", post(start_download)) + .route("/models/cancel", post(cancel_download)) + .route("/models/delete", post(delete_model)) + .with_state(store) +} + +/// What this machine has and what it is fetching, in one answer. +/// +/// Both together deliberately: a phone showing the model list needs both +/// to draw one screen, and two routes would let it render a model as +/// absent while its download sits at 99%. +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ModelsResponse { + local: Vec, + downloads: Vec, +} + +async fn list_models( + State(store): State>, +) -> Result, ApiError> { + let listing = tokio::task::spawn_blocking(move || ModelsResponse { + local: store.list(), + downloads: store.downloads(), + }) + .await + .context("listing models panicked")?; + Ok(axum::Json(listing)) +} + +#[derive(Deserialize)] +struct SearchQuery { + q: String, +} + +async fn search_models( + Query(query): Query, +) -> Result>, ApiError> { + // Blocking HTTP, like the usage fetch: off the request workers. + let found = tokio::task::spawn_blocking(move || crate::models::search(&query.q)) + .await + .context("model search panicked")? + .map_err(bad_request)?; + Ok(axum::Json(found)) +} + +#[derive(Deserialize)] +struct RepoQuery { + repo: String, +} + +async fn repo_files( + State(store): State>, + Query(query): Query, +) -> Result>, ApiError> { + let files = tokio::task::spawn_blocking(move || crate::models::files(&query.repo, &store)) + .await + .context("listing repository files panicked")? + .map_err(bad_request)?; + Ok(axum::Json(files)) +} + +#[derive(Deserialize)] +struct DownloadRequest { + repo: String, + file: String, +} + +/// Starts a download, or rejoins the one already running for that model. +async fn start_download( + State(store): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let status = store.start(&body.repo, &body.file).map_err(bad_request)?; + Ok(axum::Json(status)) +} + +#[derive(Deserialize)] +struct KeyRequest { + key: String, +} + +async fn cancel_download( + State(store): State>, + axum::Json(body): axum::Json, +) -> Result, ApiError> { + let status = store.cancel(&body.key).map_err(bad_request)?; + Ok(axum::Json(status)) +} + +async fn delete_model( + State(store): State>, + axum::Json(body): axum::Json, +) -> Result { + store.delete(&body.key).map_err(bad_request)?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs new file mode 100644 index 0000000..0805307 --- /dev/null +++ b/server/src/session/claude.rs @@ -0,0 +1,1591 @@ +//! The Claude Code driver: `claude -p` speaking stream-json on stdio, +//! translated into the common event model. +//! +//! This half owns the process -- starting it, *adopting one this server +//! left running*, writing lines to it, and ending it. Where it runs is +//! `session::transport`'s business, not this file's: this one emits a +//! `Launch` and never learns whether it became a local child or an ssh +//! invocation. +//! +//! The process is meant to outlive the server, so that restarting the +//! backend does not end a turn: its stdio lives in the session directory +//! (a fifo it holds open itself, plus logs read from a byte offset) and +//! `session::process` records what it is. Everything comes through +//! [`ClaudeDriver::launch`], which adopts if it can and starts if it +//! cannot -- `--resume` is reachable only on the second path, because two +//! CLIs on one session file duplicate the conversation into it. +//! Turning a line into [`Event`]s is [`translate`], which changes when the +//! CLI's wire format does rather than when any of the above does. +//! +//! The probing record below stays here, since it is the provenance for +//! both halves: the flags are this file's, the message catalogue is what +//! `translate` implements. +//! +//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts +//! summarized here since they live outside the repo): +//! +//! - Outbound: `system/init` (carries `session_id`, the `--resume` token), +//! `stream_event` (raw API deltas; `text_delta` is the streaming text), +//! consolidated `assistant` messages (their `tool_use` blocks have the +//! complete input), `user` messages with `tool_result` blocks, a `result` +//! per turn (usage + cost), `control_request` for anything needing a +//! human, `control_response` answering ours. +//! - Permission prompts require the hidden `--permission-prompt-tool stdio` +//! flag; they arrive as `control_request{subtype:can_use_tool}` and are +//! answered with `{behavior:"allow",updatedInput}` or +//! `{behavior:"deny",message}`. `AskUserQuestion` uses the same shape, +//! with the chosen labels added to `updatedInput` as +//! `answers:{: