Make the Rust client the sole app

This commit is contained in:
iris committed 2026-09-11 01:18:24 -04:00
1 parent a8602c1626
commit d8bb1699a8
230 files changed
+762 -27300

No files matched your search

-10
View File
@@ -1,10 +0,0 @@
# xtask convention (https://github.com/matklad/cargo-xtask), without folding
# every crate in this repo into one workspace -- they are deliberately
# independent (see scripts/run-tests.sh, which cds into each).
#
# `cargo xtask apk` **from the repo root** runs scripts/xtask/src/main.rs.
# The manifest path is relative to the working directory cargo is run from,
# so the root is where it works; this file is found from any directory
# inside the checkout, but the path inside it is not.
[alias]
xtask = "run --quiet --manifest-path scripts/xtask/Cargo.toml --"
+2 -30
View File
@@ -17,9 +17,7 @@ label: "AI Sessions",
// three constants -- there is nothing here worth spawning a process for.
resources: Ron("resources.ron"),
// The two halves this checkout produces: the server a phone talks to, and
// the app that talks to it. They are built in parallel -- this list is the
// set, not a sequence, so nothing here should be read as an order.
// The server and Rust app build in parallel.
components: [
Server(
name: "server",
@@ -39,15 +37,8 @@ components: [
),
Apk(
name: "app",
// Release first: the first mode is the default, and the phone runs
// the release build -- a debuggable one runs Compose at a fraction
// of the speed. Each command below is run with the chosen mode as
// its last argument, which is exactly build-apk.sh's interface.
modes: ["release", "debug"],
// Resolved against this directory, and run in `app/` -- the script
// cds to its own directory anyway, so the cwd is here to say where
// the app is rather than because the build needs it.
build: "app/build-apk.sh",
build: "./build-apk.sh",
cwd: "app",
// The Enroll button in this component's settings: prints the link
// that enrols the phone against the server built here, for the
@@ -55,23 +46,4 @@ components: [
// the terminal the QR would be printed on.
enroll: "server/enroll-link.sh",
),
// E5 (RUST.md): app/shellApp packaged by the xtask instead of Gradle
// (cargo ndk -> javac -> d8 -> aapt2 -> zipalign -> apksigner), signed
// with the same release key as "app" above so the two can install
// over each other -- a separate component, not a mode of "app" above,
// because it is a different applicationId (com.example.aiapp.shell)
// built by a different tool from different sources. No `cwd`: it
// defaults to this checkout's root, which both the `cargo xtask`
// alias (`.cargo/config.toml`, resolved relative to the working
// directory cargo is run from) and `cargo xtask apk`'s own publishing
// step (`scripts/build/outputs/apk/<mode>/*.apk`, matching
// discover.rs's `*/build/outputs/apk/*/*.apk` pattern -- see apk.rs's
// module doc) both need. The publish directory is `scripts/build`
// rather than `scripts/xtask/build` for exactly that reason: the
// pattern is one directory deep, and the tool moved two on 2026-09-09.
Apk(
name: "shell",
modes: ["release", "debug"],
build: "cargo xtask apk",
),
],
+1 -18
View File
@@ -1,19 +1,6 @@
.gradle/
build/
app/androidApp/build/
app/shellApp/build/
local.properties
.kotlin/
*.iml
.idea/
.DS_Store
server/target/
event-model/target/
app-rust/target/
# E3's native library, built by cargo-ndk straight into the Gradle module
# (RUST.md) -- an artifact, like server/target/ above, not source.
app/shellApp/src/main/jniLibs/
app/target/
# Server logs from a development run (ai-server.log by convention,
# wg-test.log from ./test-wg-tunnel.sh).
@@ -32,8 +19,4 @@ sessions/
# iris, the in-house UI library, is vendored at iris/ and built by cargo.
iris/target/
# The packaging xtask and the GPU rigs, both under scripts/. `build/`
# above already covers scripts/build/outputs/apk, where `cargo xtask apk`
# publishes for Dev Updater to find.
scripts/xtask/target/
scripts/rigs/gpu-probe/target/
+136 -735
View File
@@ -1,776 +1,177 @@
# ai-app
A phone interface to AI coding sessions (Claude Code and llama.cpp),
replacing the Claude app for daily use. Rust/Axum backend on the desktop,
Kotlin/Compose Android app, WireGuard + pinned self-signed TLS + bearer token
between them.
A phone and desktop interface to AI coding sessions. The backend is Rust/Axum;
the shared client and UI are Rust, drawn by the in-tree `iris` framework. The
Android app uses a thin Java activity and `android-view`; desktop uses winit.
**`docs/PLAN.md` is the design source of truth** — every decision with its
date, its rationale, and what was rejected. Read it before changing anything
structural, and update it in place when a decision changes rather than
letting this file and the plan become two versions of the truth. This file is
the working notes layer: layout, commands, rigs, and things that have bitten.
The design and working documents live under `docs/` — everything except this
file and `CLAUDE.md`, which stay at the root because that is where Claude
Code and other agent harnesses look for them.
`docs/PLAN.md` is the design source of truth. Read it before structural work
and update it when a decision changes. Working documents are pruned as work
lands: preserve current invariants, measurements, and failed hypotheses, not a
chronicle of completed tasks. Do not create a decisions log.
The central design point, worth not undoing by accident: **a session is a
child process, translated into one common event model.** A new session type
is a new driver — never a session-type branch in shared code (routes,
transcript, app screens).
## Architecture
The second one, for the Rust port on the `rustify` branch: **the phone app
and a planned desktop app share almost all of their code.** Screens,
widgets, folding, paging, config and the network client live in
`app-rust/`'s `client` and `ui` modules, drawn with `iris`; `src/android`
and `src/desktop` are thin entry points that own only what the platform
forces (JNI and the IME on one side, winit and argv on the other). The two
*layouts* will differ, to suit a phone's screen and a finger against a
desktop's screen and a mouse -- but the widgets a layout is made of (a
button, a text field, a list, a card) and the styling (colours, spacing,
type) are one implementation with no per-platform copy. Anything that could
work on both goes in `ui` the first time it is written, and a platform
module growing a widget or a colour is a defect to move, not a convenience
to keep. Iris said this on 2026-09-07; docs/RUST.md carries the details.
A session is a child process translated by a driver into one common event
model. A new session type is a new driver, never a session-type branch in
shared routes, transcripts, or screens.
The third, from Iris on 2026-09-08: **`iris/` is the UI framework and
nothing else.** Nothing in it may know about a session, a transcript, a
setup or a server; anything that does belongs in `app-rust/`, and the
dependency runs one way only. The port's project code is **one crate**
(`ai-app`) rather than the six it was scattered across — see docs/RUST.md's
"One app crate" for what forced each of the splits that were removed and
the two that remain.
Android and desktop share `app/src/client` and `app/src/ui`. Platform modules
own only what the platform forces: JNI, lifecycle, insets and IME on one side;
winit and argv on the other. Layouts may differ, but widgets, styling, folding,
paging, config, and network logic are shared.
`iris/` is a UI framework and nothing else. It must not know about sessions,
transcripts, setups, or servers. Product code belongs in `app/`, and the
dependency runs one way.
## Layout
Mirrors `../dev-updater` deliberately: same stack (axum 0.8 +
axum-server/rustls, tokio, clap; Kotlin 2.4.x + Compose Multiplatform, single
`:androidApp` module), same cert scheme, same registry pattern. Read
dev-updater's `README.md` and `AGENTS.md` before diverging from them.
Module-by-module intent is in `docs/PLAN.md`'s "Backend layout".
- `server/``ai-server`. `routes.rs`'s module comment is the HTTP table.
- `event-model/` — the wire contract shared by server and app.
- `app/` — the `ai-app` crate. `client` is platform/UI independent; `ui`
contains Iris widget trees; `android` and `desktop` are thin hosts.
`android-project/` packages the Rust cdylib. The `bench` feature and
`bench-fixture/` are retained performance rigs, not a second app.
- `iris/` — the framework, proc macro, tabs demo, and input rig.
- `scripts/` — repository-wide scripts and independent profiling rigs.
- `wg-app-link/` — a git submodule shared with dev-updater. Clone with
`--recurse-submodules` or run `git submodule update --init`.
- `docs/` — design and working documents.
- `server/` — the Rust backend (`ai-server`). `routes.rs`'s module doc
comment is the HTTP table and the surface's source of truth.
- `event-model/` — the wire shape `server/` and `app-rust/` both depend on,
which is the whole reason it is a crate of its own rather than part of
either: it is the contract between them, so the two agree by construction.
- `app-rust/` — the Rust app, one crate (`ai-app`) with three faces. `src/
client` is everything with no UI in it (the REST and SSE clients, the
transcript cache and fold, the highlighter, the ANSI parser, config and
the enrolment link); `src/ui` is the screens as iris widget trees;
`src/desktop` + `src/bin_desktop.rs` is the winit binary; `src/android`
is the `android-view` entry point and `android-project/` its Gradle app;
`src/shell` is the separate JNI bridge the Kotlin `app/shellApp` calls.
Features pick which face a build is: `screens` (default) for anything
that draws, `shell` for the Compose app's bridge, `bench` for P0's
fixture build. See its `Cargo.toml` header.
- `iris/` — the UI framework, and **only** the UI framework: `core`,
`macro`, the `iris` crate itself, `tabs-ui` (its own demo widget tree)
and `rig-input`. It must not mention anything this product is about.
- `app/` — the Compose app, package `com.example.aiapp`, label "AI Sessions".
`AppRoot.kt` is the navigation `when`; `MainScreen.kt` the root's four tabs
(sessions, import, models, setups); `Api.kt`/`EventStream.kt` the REST + SSE
clients; `Events.kt` the event model mirror; `ServerConfig.kt` settings and
the Keystore-sealed token.
- `scripts/` — everything at the root that was neither a program nor a
document: the three repo-wide shell scripts (`run-tests.sh`,
`test-wg-tunnel.sh`, `wg-setup-host.sh`), `rigs/` (the `gpu-probe` and
`virtgpu-probe` device probes, and `ui-profile`'s two layer-1
profiling rigs), and `xtask/`. **A project's own scripts
stay with the project** — `app/*.sh`, `app-rust/*.sh`, `iris/*.sh` and
`server/enroll-link.sh` did not move (Iris, 2026-09-09: "I only meant
top level sh files").
`scripts/xtask/` is the [cargo-xtask](https://github.com/matklad/cargo-xtask)
convention: an ordinary Rust binary that does build work a shell script
would otherwise do, run as `cargo xtask apk` **from the repo root**
(`.cargo/config.toml`'s alias, whose `--manifest-path` is relative to
the working directory). It packages `app/shellApp` without Gradle
driving it — `cargo ndk` → `javac`/`d8` → `aapt2` → `zipalign` →
`apksigner` — and publishes to `scripts/build/outputs/apk/<mode>/`,
which is where Dev Updater looks. There is deliberately **no `target/`
at the repo root** any more: there is no workspace there, and what used
to be one was only xtask's own scratch space, now in
`scripts/xtask/target/`.
- `wg-app-link/` — a **git submodule** shared with dev-updater: the pinned CA
and leaf (`certs`), QR enrollment and the bearer token (`enroll`), wg0
binding and the certificate's SANs (`netif`), owner-only files (`private`),
and the RON house rules (`format`). Clone with `--recurse-submodules`, or
`git submodule update --init` in an existing checkout — `server/` will not
build without it, since it is a path dependency, which is what keeps the two
projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers,
sessions and setups are what makes this project itself.
- `docs/` — every design and working document except this file and
`CLAUDE.md`:
- `docs/EXPLORER.md` — the file explorer's design (`server/src/files.rs`
and `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `docs/TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent.
Read it before touching `TranscriptCache.kt`, `TranscriptSource.kt`, or
the opening and stream effects in `SessionScreen.kt`.
- `docs/TODO.md` — the working list.
- `docs/SUBAGENTS.md` — a session's subagents: the wire shape, the
phone's view, and the choices behind the shape.
- `docs/RUST.md` — the plan for moving the app to Rust (on the `rustify`
branch of the `ai-app-2` clone): what has to be reproduced, the
framework decision, and the ordered experiments with their pass
conditions. Read it before touching anything under that branch.
- `docs/IRIS_TODO.md`, `docs/LAYOUT.md`, `docs/TEXTURES.md`,
`docs/CLIENT_CORE.md` — iris's open working list, its layout/render
design, its texture-atlas design, and the design of `app-rust`'s
`client` module, respectively.
Nerd Font icons are a committed subset. `iris/core/build-icon-font.sh`
produces `iris/core/assets/fonts/nerd_icons.ttf`; its codepoints must match
`iris/core/src/icon.rs`. Body and monospace fonts come from the platform.
**These documents are pruned as the work lands, not appended to
forever** (Iris, 2026-09-08: *"remove everything that's already done and
decided… many with checkboxes already ticked off that just fill up
context"*). A ticked box, a finished experiment and a completed review
are deleted once carried out; `IRIS_TODO.md` holds only open items, and
a finished document is removed rather than archived in place. What
survives is what cannot be cheaply re-derived — measurements, dead ends
and failed hypotheses, invariants and their reasons, and the design of
what exists now rather than the route to it.
## Checking work
**There is no decisions log and no design log, and one should not be
started.** `docs/DECISIONS.md` and `docs/IRIS.md` were deleted on
2026-09-09 at Iris's instruction: *"I've decided to instead make
decisions when planning with agents rather than after they do things,
and they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally
run into things for now."* So raise a choice **while planning it with
her**, when the direction is still cheap to change; otherwise decide it,
put the reasoning at the code it governs, and carry on. TODO lists are
still wanted — a list of open work is useful, a list of finished work
is not.
- `docs/SCROLL.md` — how anything in iris scrolls: one
`ScrollController` holds the position, the gesture, the fling and the
pin, and the two widgets that scroll (`ScrollArea`, `LazySpan`) own
one each through the `Scrollable` trait. Read it before touching
`scrollable.rs`, `scroll_area.rs`, `lazy_span.rs`, or anything that
pans, flings or lays out a long list.
- `.dev-updater.ron` — what Dev Updater builds here: the server (run as
`service: Managed(…)`, supervised by Dev Updater's own implementation
rather than a script kept here), the Compose app and the shell APK, in
parallel. **It does not publish either benchmark APK.** Phone benchmark
builds live in the separate `~/repos/ai-app-bench` repository: build here,
copy the verified artifact to that repo's `compose/` or `iris/` Gradle-shaped
path, then commit and push that repo. Dev Updater pulls the committed APK
from there; pushing `ai-app-2` alone cannot update its benchmark card. This
file points at
`resources.ron`, which is *ours* rather than Dev Updater's — it names
`~/.local/share/ai-app` and `~/.config/ai-app` so the Uninstall dialog can
offer them. Note what deleting the config directory takes with it: the CA
under `certs`, which is the one-way door. **Stop** on the server card stops
the server a phone reaches through the tunnel, so on that phone it stays
down until somebody starts it again; Dev Updater reaches it over its own
port and is unaffected, which is what makes the button safe to press and
easy to regret.
Commit each coherent, warning-clean slice and push it.
### Icons
- Whole product: `./scripts/run-tests.sh`.
- Framework: `cd iris && cargo fmt --all --check && cargo clippy --all-targets
-- -D warnings && cargo test`.
- App: `cd app && cargo fmt --all --check && cargo clippy --all-targets --
-D warnings && cargo test`.
- Android: `cd app && ./build-apk.sh debug --abi x86_64` for this machine's
emulator, or `./build-apk.sh release` for a phone. The script builds with
cargo-ndk, packages with Gradle, and verifies the APK. Never infer phone
frame times from a debug emulator build.
**Nerd Fonts glyphs from a committed subset**, not vector assets and not
ordinary Unicode. `NerdIcons.kt` declares each codepoint and
`app/build-icon-font.sh` subsets the font; the two lists have to agree,
because a codepoint in the Kotlin that the script did not subset is a glyph
that silently isn't there. Rerun the script and commit its output when adding
one — it needs network access. `md-cog` and `md-refresh` are deliberately the
same codepoints dev-updater uses and must not drift from it. The subset is
the **Mono** face, where every glyph is one em square, which is what makes
two icon buttons the same width without either being given one — and why
`GLYPH_SIZE` is smaller than it looks like it should be.
`app/`, `iris/`, and `scripts/rigs/ui-profile/` use rolling nightly through
per-directory toolchain files. `server/` and `event-model/` use stable.
**The Rust app does the same, from its own subset**:
`iris/core/build-icon-font.sh` -> `iris/core/assets/fonts/nerd_icons.ttf`,
with the codepoints named in `iris/core/src/icon.rs` and drawn as text
with `Family::Icons`. Same rule about the two lists agreeing (there is a
test, `every_icon_is_in_the_bundled_font`), same Mono face, same Material
Design family so an icon means the same thing in both apps. Its subset is
separate rather than shared because subsetting only what one app draws is
the point. This is the **only** font iris bundles — body and monospace
text come from the platform (decided 2026-09-07), and an icon
is the opposite case: a small closed set of codepoints no system font is
guaranteed to have.
The release signing key lives at `~/.config/ai-app/release.jks`, never in the
checkout. `build-apk.sh` creates it once. Normal builds use application id
`com.example.aiapp`; benchmark builds add `.bench` and are built explicitly:
## Checking your work
./build-apk.sh release --features "transcript-screen bench"
- **Commit completed work.** Once a coherent piece of work has passed its
relevant checks and has no known major issue or unresolved design decision,
commit it rather than leaving it in the worktree. Keep independently
completed slices in separate commits.
## Running the server
- **Rust**: `./scripts/run-tests.sh` from the repo root runs `event-model`,
`server/` and `app-rust/`; `cd iris && cargo test` runs the framework's
own suite, which is slower and not about this product. Each workspace
also gets `cargo clippy --all-targets` and `cargo fmt`. The build stays
warning-clean and rustfmt-clean at the defaults — there is no
`rustfmt.toml` and there should not be one. `app-rust/`, `iris/`, and the
UI profiling rig use the rolling nightly channel through per-directory
`rust-toolchain.toml` files; `server/` and `event-model/` are stable.
- **App**: from `app/`,
`. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
:androidApp:compileDebugKotlin :androidApp:lintDebug
:androidApp:testDebugUnitTest`. The unit tests are JVM-only and cover the
syntax highlighter, the ANSI parser and the transcript cache — the app's
pure logic with no Android in it. Touching anything under `BenchFixture.kt`,
`BenchNetwork.kt`, `BenchRun.kt` or the `bench` build type also needs
`:androidApp:compileBenchKotlin :androidApp:lintBench` — a second build
type compiles separately and lint has caught real bugs debug alone never
would (see "Android Lint" below).
- **Android Lint is not optional and is not run by a build.** It found a
crash that had been shipping (`java.time` on a minSdk-24 app with
desugaring off) and later a permission check that silently dropped every
notification on Android 12 and below. Fully clean as of 2026-08-31; keep it
that way, and suppress with `tools:ignore` plus a written reason rather
than by lowering the bar.
- Then `./build-apk.sh` for the APK to install on a phone through Dev
Updater, or `./run-android.sh` to build, install and launch on the
emulator. **The phone gets the release build**, signed with a key the
script generates once under `~/.config/ai-app/release.jks` (never in the
repo); `./build-apk.sh debug` builds the other variant, and Dev Updater's
build modes call the script with exactly that word. Dev Updater lists every
variant under `build/outputs/apk`, so pick `release` there; a phone still
holding the debug build has to uninstall it first, since the two are signed
differently.
- The emulator scripts stay on the debug build. **Never read a frame time
from one as the app's** — a debuggable build runs Compose at a fraction of
release speed; the render report says which build it came from.
Use `--bind 127.0.0.1` for emulator development. Without it the server binds
wg0, which the emulator cannot reach. Use scratch state:
## Running it here
ai-server --bind 127.0.0.1 --config /tmp/ai-config.ron \
--data-dir /tmp/ai-sessions --port 8444
- Run the server for development with `--bind 127.0.0.1`. Without it the
server binds wg0, which exists here but is unreachable from the emulator
(it dials 10.0.2.2). First run prints the enrollment QR/URI with the token.
`ai-server --enroll-link` mints one more device's link while the server
keeps running; the server adopts that token on its first use. It is what
Dev Updater's Enroll button runs.
- Point development at a scratch state directory rather than the real one:
`--config /tmp/…/config.ron --data-dir /tmp/…/sessions --port 8444`.
- **The APK pins the CA of the machine that builds it**, read at build time
from `$XDG_CONFIG_HOME/ai-app/certs/ca.pem` (`AI_APP_CA` overrides). So the
server must have started once on that machine first — the build stops with
that instruction otherwise — and an APK built in this VM only works against
a server in this VM.
- Prefer exercising the server directly over going through the UI:
`curl --cacert ~/.config/ai-app/certs/ca.pem -H "Authorization: Bearer …" https://127.0.0.1:8443/sessions`.
The CA is wherever `--certs` put it — by default under `$XDG_CONFIG_HOME`,
never in the checkout, so a relative `certs/ca.pem` finds nothing.
The emulator app reaches it at `https://10.0.2.2:8443`; enroll with
`adb shell "am start -a android.intent.action.VIEW -d 'aiapp://enroll?host=10.0.2.2&port=8443&token=…'"`.
- **`ai-server --delay MS` holds every response back.** Over the tunnel a
phone's requests take tens to hundreds of milliseconds, and several faults
live entirely in what the app does *while* one is outstanding. On a
loopback server those windows close before anything can be observed, so the
bug looks like it is not there.
- **`RUST_LOG=ai_server=debug`** logs every transcript page with its `before`,
`after` and what came back, and logs each SSE subscriber's cursor and
whether it was continued or reset (`stream backlog:`). That is the only
place "how far had this phone fallen behind" is answerable — the app sees a
window arrive and cannot tell.
- **`./scripts/test-wg-tunnel.sh up|test|down`** builds a real tunnel between two
network namespaces inside one machine and drives the server through it — a
genuine handshake against 10.66.0.1 with pinned TLS, no router or phone
involved. That is how to verify the wg0-only posture.
The emulator reaches the host at `10.0.2.2`. `ai-server --enroll-link` mints
another device link while the server runs. `--delay MS` is important for UI
states that disappear too quickly on loopback. `RUST_LOG=ai_server=debug`
logs transcript page bounds and SSE catch-up/reset decisions.
## The rigs
Exercise the server directly when possible:
Each exists because something was invisible without it.
curl --cacert ~/.config/ai-app/certs/ca.pem \
-H "Authorization: Bearer …" https://127.0.0.1:8443/sessions
- **The `bench` build type and `app/bench-fixture/`** exist for P0 (RUST.md
and the 2026-09-05 decisions), the phone benchmark gate Iris
asked for before porting continues: a deterministic, checked-in synthetic
transcript (`app/bench-fixture/generate.py`, never a real one) that both
this app and iris open with no server, so a frame-time comparison
measures the renderer rather than the data. `./build-apk.sh bench` builds
it — own application id (`com.example.aiapp.bench`) and label ("AI
Sessions bench") so it installs beside a real enrollment rather than
replacing it. Opening it goes straight to a session screen holding the
fixture (no enrollment, no permission prompts) with a "Run benchmark"
control beside "Copy" in session settings: it drives the same scroll loop
and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over
`ui-trace`, but in-process, since a real phone has no usable system
tracing and no agent can drive one (this-machine-android's skill).
`BenchFixture.kt`/`BenchNetwork.kt` fake the backend by installing a
`URLStreamHandlerFactory` that answers `TranscriptSource`/`EventStream`'s
requests from an in-memory copy of the fixture instead of opening a
socket — so the fold, the paging and `uniqueItems` under test are the
screen's real ones, never a shortcut built just for this. The report
gains a `bench:` section (process CPU time, peak RSS, battery current) on
every build, empty except when `BenchRun.kt` filled it in.
- **`app/ui-sandbox.sh`** — a second `ai-server` with its own `$HOME`, config
and data directory, holding eight invented Claude Code transcripts and a
`claude` that is two lines of shell. **That isolation is the point**: the
import screen lists whatever is in `~/.claude/projects`, which in this VM is
real agent transcripts, so exercising *delete* against the ordinary server
deletes somebody's conversation and exercising *import* starts a real
`--resume` on the owner's account.
Its port and root derive from the checkout's name, so two checkouts'
sandboxes cannot reach each other, and its token is generated once into
`~/.config/ai-app/sandbox-token` and carried across restarts along with any
the enrolment flow appended — so the emulator app is enrolled **once** (the
start banner prints the command) and stays enrolled. It shares the real TLS
certificates, because the installed APK pins that CA.
Driving verbs, so none of this is re-derived per session:
`./ui-sandbox.sh spawn [title]` (an echo session, prints its id),
`./ui-sandbox.sh send SID text|@file`, and
`./ui-sandbox.sh api /path [curl args]`.
`./ui-sandbox.sh keep` restarts the server without wiping the sessions and
enrolment already there — for when the fixture under test was expensive to
build; plain `start` wipes them, which is right for the list-screen
fixtures and wrong for that.
It passes `--delay` by default, and `AI_SANDBOX_BIG_MB` puts one large
transcript among the small ones while `AI_SANDBOX_SPAWN_DELAY` makes the
fake CLI slow to start. Both exist because operations that finish in
milliseconds have states on the way that nothing can observe, and an
unobservable state is one where broken and working look identical.
It also builds a fixture tree at the sandbox home's `~/files` for the
explorer, holding the states otherwise only reachable by finding a real
machine in one: an empty directory, a name with a tab and one with an
apostrophe, a binary file, one over `FILE_LIMIT`, one `chmod 000`, a
symlink to a directory and a broken one, a source file per language, and
the three sizes the limits were measured against (`edit-32k.rs`,
`edit-128k.rs`, `big-source.rs`). Point a session at it with
`./ui-sandbox.sh api /sessions/<id>/cwd -X POST -H 'content-type: application/json' -d '{"cwd":"~/files"}'`.
The explorer's 409 is produced by editing the file on the machine
(`printf … > file`) between pressing the pencil and pressing save.
- **`app/debug-transcript.sh`** — a real conversation on the emulator. The
echo driver is the right rig for most things and the wrong one for anything
whose cost scales with what was actually written: a real reply is longer,
is real markdown, and carries tool calls whose input and output are
kilobytes. Two faults were invisible until a real transcript was loaded — a
page of history landing mid-fling threw the reader back to the newest end,
and parsing one real reply took 51ms against 4.6ms for a synthetic one.
`-b` takes the biggest conversation on the machine rather than the newest,
which is what a scrolling test wants; `--stop` takes it down.
It copies the transcript into `/tmp` and gives the server a `HOME` of its
own, so the import can only see the copy — importing spawns `claude
--resume`, and against the real file that is a second CLI writing to a
conversation somebody may still be in. **A transcript never goes in this
repository**: they hold whatever was said, read and written in that
session, and `~/repos` is shared with the host besides.
- **A fake CLI exercises the process lifecycle without a token.** Point a
`claude_cli` provider's `command` at a two-line script — `#!/bin/sh` and
`cat > /dev/null` — and it behaves the way the lifecycle code cares about:
it holds the fifo open, records a real pid, writes nothing, and dies on a
signal. So adopt, stop, restart and start are all drivable without a real
`--resume` and without spending a turn on somebody's account. Reach for
this when what is under test is *whether a process is running*, and for
`debug-transcript.sh` when it is *what the transcript draws*.
- **`app/transcript-bench.sh`** is the standard scroll measurement: it opens
the first session (or `-k` keeps the current screen), scrolls a fixed
gesture loop, and prints the app's render report — the same one the in-app
copy button produces, whose `on screen:` line names what the viewport was
holding. Compare two runs with the same gestures; the emulator's absolute
frame times transfer nothing, the report's accounting does. Run it either
side of any change under `Markdown*.kt`, `Transcript*.kt` or
`SessionScreen.kt`'s list, and put the report in the commit. The numbers
that move first are the worst `record: one block`, the reparse mean while
streaming, and the draw phase's accounting line.
- **`app/stream-bench.sh [-k] FILE`** is that measurement for a reply still
arriving. It taps "Jump to latest" so the list is pinned to the newest end,
resets the report, sends FILE, waits for the transcript to stop growing,
and prints. Both of those are corrections to a first version that measured
nothing: a transcript parked further back never redraws while a reply
streams into it, and a session is idle at *both* ends of a turn, so polling
for idle answers before the turn has started.
- **`app/trace-draw.sh`** names what a scrolling frame spends inside the
framework, from `atrace` text output with no trace processor needed. It is
how the cost of a layout node per link was attributed to the framework
rather than guessed at.
- **`app-rust/build-apk.sh [debug|release] [--abi ...] [--features
...]`** builds the Rust app's cdylib (`cargo ndk` from `app-rust/`,
straight into `android-project/app/src/main/jniLibs/`) and its APK
(Gradle, from `android-project/`) in one step and verifies the result
(`aapt2`/`apksigner`), and **`app-rust/run-bench.sh [--apk PATH]`**
installs it on this checkout's own emulator, taps "Run benchmark" by
label, and prints the report -- written so the P0
build/install/tap/read-report cycle stops being retyped by hand each
time (docs/RUST.md's P0 box). It passes `--no-default-features`, so
`--features` alone decides what is in the `.so`; that is what keeps the
1.9 MB bench fixture out of a build that did not ask for `bench`. A phone
build is published only by replacing
`~/repos/ai-app-bench/iris/build/outputs/apk/release/iris-bench-arm64.apk`
and pushing the **bench repository**, not this checkout.
- **iris's three test layers** (docs/RUST.md's "Three test layers" has
the commands and what each cannot answer): test at the cheapest one
that can answer the question. `cd app-rust && cargo test` runs the real
transcript screen over the bench fixture with **no window, no
compositor and no GPU** (`iris::harness`), on a clock the test owns and
a gesture replayed from a `t_ms action x y` file under
`app-rust/touch/` -- which is how the batched 120Hz
flick a finger actually makes is testable at all, since a `ui-trace`
swipe is many evenly-spaced events. `iris/run-headless.sh phone --phone
--dir ../app-rust --shot …` opens the same screen in a window at the
phone's own size and density for looking at, and `--replay FILE` drives
the same recording into it (`--dir` names the workspace to build in,
since the rig lives in iris and the app's examples do not). The emulator is for JNI, the IME, insets, the surface
lifecycle and one verification run before a build goes to the phone --
not for iterating on layout.
- **`scripts/rigs/ui-profile/`** holds the two layer-1 profiling rigs, in
a crate of their own so a rig's dependencies stay out of the app's
(Iris, 2026-09-09: *"Rigs should probably all be in their own crate so
dependencies and such don't get mixed"*). Run either from that
directory; both are `#[ignore]`d and assertion-free, so `run-tests.sh`
neither runs them nor can fail on them, and both need **release or the
numbers mean nothing**.
- **`tests/frame_profile.rs`** is what a frame costs on the CPU,
at layer 1 -- `cargo test --release --test frame_profile -- --ignored
--nocapture`. Two runs: a fling over the bench
fixture eight times out and back, and a reply streaming into it one
event at a time. Text shaping dominates, which is why the profile is
meaningless unoptimised. It cannot answer anything about the GPU, the
swapchain or the phone's own clock.
`./scripts/test-wg-tunnel.sh up|test|down` builds a real WireGuard tunnel
between network namespaces and verifies pinned TLS against 10.66.0.1.
What it established on 2026-09-09, worth not re-deriving. A **fling**
is not CPU-bound: only about one frame in six lays anything out (the
rest are moved on the GPU through `move_offsets`), and the
multi-millisecond spikes are all in the *first* pass over a stretch of
transcript -- every later pass over the same rows is p99 0.26ms. A
**streamed event** is, and it is not where it looks: folding the event
is 0.35ms and applying the diff to the widget tree is 0.41ms, while the
*frame* is 3.86ms here and 9.5ms on Iris's phone. (The fold was the
hypothesis, from `foldEvent`'s Compose lesson under "Things that have
bitten"; measuring it is what ruled it out.) That frame is one
`TextBuffer::shape` of the block a delta landed in, and **the fixture's
is 14,888 characters in a single block** -- against a largest-ever
1,580 across 7,706 blocks of real replies. So the stream phase's number
is a property of the fixture, not of streaming; docs/RUST.md's
"Incremental text" has the measurements and why parley cannot help.
## Rigs
The last two runs (`where_a_streamed_deltas_cost_is`,
`what_the_fixture_streams`) exist to keep that answerable: what a delta
costs to re-split and re-compare, and what the fixture actually
streams.
- **`tests/arena_churn.rs`** is what a frame costs to *upload* -- the half
of a frame layer 1 builds and never performs, and so the half
`frame_profile.rs` cannot see at all. It prints three numbers per GPU
array per frame, and the point of the rig is that no two of them alone
are honest: **floor** (entries whose bytes actually differ, found by
diffing), **uploaded** (what iris really writes, read from the same
`Dirty` sets `UiRenderNode::update` consumes), and **whole** (what the
old code wrote whenever anything changed). A gap between the first two
is over-marking; one was 122x and invisible until both were printed
side by side.
- `app/ui-sandbox.sh` runs an isolated delayed server with invented
transcripts, a fake CLI, stable enrollment, and a file-explorer fixture.
Its HOME and data are disposable; never point import/delete tests at real
`~/.claude/projects`.
- A two-line fake CLI (`#!/bin/sh`, `cat > /dev/null`) exercises adoption,
stop, restart, and process lifetime without using an account or token.
- `app/run-bench.sh` installs a benchmark APK on this checkout's emulator,
taps its accessibility-labelled control, and prints the report.
- `cd app && cargo test` drives the real transcript screen without a window
through `iris::harness`; touch recordings live in `app/touch/`.
- `iris/run-headless.sh phone --phone --dir ../app --shot …` opens the same
screen at phone size. `--replay ../app/touch/flick-120hz.touch` replays a
recorded gesture.
- `scripts/rigs/ui-profile/tests/frame_profile.rs` measures CPU frame cost;
`arena_churn.rs` measures GPU-array upload. Run ignored profiling tests in
release mode or the numbers are meaningless.
What it established on 2026-09-09, and what the three optimisations it
drove were. Uploading the whole arena on any change cost **758 MB over
a fling and 1.2 GB over 401 streamed deltas**, p50 3.0 MB per streamed
frame. Three things were wrong and each is now guarded by this rig:
`ArrBuf` reallocated on every length change, so adding one glyph made
the buffer's contents undefined and forced a full rewrite; a redraw
freed its primitives and pushed new ones, which -- since freed slots
are only reusable next frame and provisional layout nested -- grew
the arena to **127,443 slots for 11,569 live primitives**; and nothing
tracked *which* entries changed. Now: the stream arena is 11,569 slots
for 11,569 live, and every array uploads within a hair of its floor.
The CPU half improved with it, since the freeing and renumbering
went away: a streamed frame is p50 1.39ms, from 2.20ms.
The checked-in benchmark transcript is synthetic. Never put a real transcript
in this repository; it contains conversation text, tool input, and file data.
The remaining layout cost was then removed at the framework boundary:
`Painter::set_child_offset` gives a container one retained coordinate slot
for its child subtree, and `LazySpan` keeps row boxes stable behind it.
Pinned growth now uploads instances at **1.1% against a 1.1% floor**, from
71.9% against 71.8%; p50 instance upload is **1,488 bytes**, from 176,496.
`Primitives` also cancels dirty marks for provisional writes restored before
upload, so CPU-only layout states never become GPU work.
- **The emulator is a GLES rig, deliberately** (Iris, 2026-09-08;
docs/RUST.md). Its guest has no hardware Vulkan -- only SwiftShader
in software -- while its GLES *is* the host's real GPU through virgl at
ES 3.1, so an ordinary build's runtime fallback lands there by itself
and nothing should pass `force-gles` to arrange it. The Vulkan path is
verified on the desktop build and on Iris's phone. Do not boot the
emulator with SwiftShader Vulkan to "test the Vulkan path": that
measures a software rasteriser and steers iris away from the one
hardware-accelerated backend it has there. Every run says which adapter
drew it (`iris renderer:` in logcat, printed by `run-bench.sh`); read
that line before reading a number.
The emulator is a GLES rig. Its Vulkan implementation is SwiftShader, while
GLES is host-accelerated through virgl. Let Iris's runtime fallback select
GLES; do not pass `force-gles`. Verify the `iris renderer:` log line before
interpreting a measurement. Vulkan is verified on desktop and a real phone.
### Driving the UI
## Driving Android UI
**No script that drives this app's UI presses a coordinate.** Every control
is found by the name it already carries for assistive technology —
`ui-trace record --do "tap 'Session settings'"` — which resolves the label
against the screen at the moment of the gesture and fails the whole run when
it is not there. `app/bench-lib.sh` is what the bench scripts share for it. A
coordinate is a position measured once by hand, and anything that moves the
control makes the tap land on whatever now sits there — the bench then
reports a number that was never measured, which reads exactly like a result.
Both bench scripts pressed the render report at `tap 723 205` until that
button moved into the session settings dialog on 2026-09-03. The check that
none has crept back:
Read the installed `this-machine-android` skill before using Gradle, adb, an
AVD, screenshots, or UI traces. This checkout gets its own AVD; resolve it
with `emu serial` rather than typing a device name.
grep -n "tap [0-9]" app/*.sh
Scripts tap controls by accessibility label, never by coordinate. Coordinates
are allowed for swipes because a swipe describes a distance across a scrolling
surface. A coordinate tap can silently hit a different control and turn a
failed run into a plausible-looking result.
Swipes are still coordinates, deliberately: a gesture across a scrolling area
is a distance rather than a control.
## Host and VM boundary
**Two traps in the emulator bench loop**, each of which cost a run.
`adb shell pm clear` removes the enrolment and the notification permission
along with the saved anchors, so the next run measures a permission dialog —
re-enrol with the command `ui-sandbox.sh` prints, and
`pm grant … POST_NOTIFICATIONS`. And a saved scroll anchor is per session id,
so the only way two builds start a scroll from the same place is a *fresh
session for each*.
Production `ai-server` runs on the host, where the phone can reach WireGuard.
The Claude CLI is in this VM, so the host reaches it as a remote provider.
The VM's wg0 is useful for development but has no reachable phone peer. A dev
server in the VM creates a throwaway CA; never install an APK enrolled against
that CA on the real phone.
**The emulator is `~/repos/emulator-tools`' business, not this repo's.**
`emu up` creates and boots the AVD named after this checkout — whatever `emu
name` prints, never a name typed out here, since this file is the same in
every clone. `run-android.sh` is that plus a build and an install. The `adb`
on `PATH` after sourcing `android-env.sh` is that repo's wrapper, which fills
in `-s` from the same rule. Gradle does not go through it, so a Gradle init
script from `emulator-tools` runs `emu check` before `installDebug`,
`uninstallDebug` and `connectedAndroidTest` and fails rather than fanning out
to every attached device; when it refuses, say which device you mean at the
moment you use it — `ANDROID_SERIAL=$(emu serial) ./gradlew …`.
For llama.cpp tests, the CPU build is at `~/.local/opt/llama.cpp`; add that
directory to `LD_LIBRARY_PATH`. Avoid 2-bit quants for driver diagnosis because
their fluent nonsense resembles a broken integration.
### Testing llama.cpp and ssh here
For SSH transport tests, SSH this VM to itself with a throwaway key and a
harmless command. Remove the key afterwards. The remote login shell is fish,
so POSIX-quoting assumptions require explicit verification.
The prebuilt CPU llama.cpp lives outside the repo at
`~/.local/opt/llama.cpp` (the 15 MB `ubuntu-x64` release asset). It needs its
own directory on `LD_LIBRARY_PATH`, so start the server as
`LD_LIBRARY_PATH=~/.local/opt/llama.cpp ai-server …` and point a provider's
`command` at `~/.local/opt/llama.cpp/llama-server`. A 0.6B Q8_0 answers at
usable speed on this VM's 8 cores. **Do not test with a 2-bit quant**: the
IQ2_XXS of that model produces fluent nonsense, which reads exactly like a
broken driver — `llama-cli` produces the same from the file directly, which
is how to tell the two apart in a hurry.
## Session invariants
There is no second machine, so **ssh this VM to itself**: generate a
throwaway key, append the public half to `~/.ssh/authorized_keys`, and
configure a host of `bob@127.0.0.1` with `identityFile` pointing at it plus
`options: ["StrictHostKeyChecking=no", "UserKnownHostsFile=…"]` so it touches
nothing real. Point a provider's `command` at something harmless like
`/bin/echo` rather than at `claude`: the transport is what is under test, the
process exiting immediately is the signal, and it costs no tokens. **Take the
key back out afterwards.** The remote login shell here is **fish**; the
remote script and `ssh.rs`'s POSIX quoting happen to mean the same thing in
both, but that is luck rather than design, and a shell that is neither is the
thing to suspect first if a remote spawn ever mangles an argument.
Sessions deliberately outlive `ai-server`. Shutdown leaves marked processes
running; restart adopts their process records without starting stopped
sessions. Sending a message to a stopped session starts it. Use
`--throwaway-sessions` for test-created sessions.
## Where things run (host vs this VM)
Each session directory contains `process.json`, `stdin.fifo`, `stdout.log`,
and `stderr.log`. Do not edit or remove them while live: the stdout byte offset
in `process.json` prevents replay and loss.
This checkout runs in a VM while production runs on its host:
Never import a Claude Code session open in a terminal. One Claude session id
may occur in multiple project directories; import listing deduplicates by id
and prefers the copy with more lines, while deletion removes every copy.
- **`ai-server` belongs on the host in production.** That is where the LAN
address the phone can reach is, and where WireGuard terminates.
`scripts/wg-setup-host.sh` sets that up (keys, `wg0.conf`, the phone's QR); run it
there with `sudo WG_ENDPOINT=<ddns name>`.
- **The tunnel and the real phone can never terminate in the VM**, because
nothing outside can open a connection into it. Phone bring-up is host work.
- `wg0` (10.66.0.1) exists in this VM too, so the production path is
exercisable during development. It has no reachable peer and does not need
one — but with no `--bind` the emulator cannot reach the server.
- **The `claude` CLI is only in the VM, so from the host it is a remote.**
The backend reaches it as it would any other machine.
- Starting the server in the VM makes a separate throwaway dev CA. **Never
install a build pinning that on the real phone.**
Deleting an app session only deletes the provider's transcript when
`deleteForeign=true`. The server deletes the foreign transcript first so an
unreachable machine cannot leave a half-deleted session.
## Sessions outlive the backend
## Known traps
Since 2026-08-29 a session's process is deliberately left running when
`ai-server` stops, and adopted again when it starts. docs/PLAN.md has the design;
day to day:
- **Stopping the server no longer stops the sessions.** After `pkill
ai-server` the `claude` processes are still there, on purpose
(`reattaching to the claude-cli it left running` in the log). To end one,
`POST /sessions/{id}/stop` — which keeps the session and its transcript,
and `/start` brings the process back on the same conversation — or delete
the session, which ends the conversation too.
- **A message or a command sent to a stopped session starts it**, so the
Start button is for when you want a process and nothing to say to it yet.
- **A backend start adopts and starts nothing.** If you are looking for a
stopped session's process after a restart, there is deliberately none.
- **A session spawned while testing cleans itself up**: `--throwaway-sessions`,
which a debug build defaults to on. Pass `--throwaway-sessions=false` to
keep what a development server spawns. The flag decides only what **new**
sessions are marked as; what happens on the way out is decided by the
**mark**.
- Each session directory holds `process.json`, `stdin.fifo`, `stdout.log` and
`stderr.log`. `stdout.log` is the driver's input, read from the byte offset
in `process.json`; removing either by hand while the session is live loses
output or replays it.
## Importing
The import list reports each session's **size as well as its line count**,
because the two disagree in the way that matters: these transcripts embed
screenshots as base64, so one line can be a megabyte. On this machine a 69 MB
session has 3,427 lines and a 44 MB one has 6,792 — nothing about a line
count tells you what continuing a session will cost. Shown, not warned about;
importing a large session is a choice somebody is entitled to make.
**Never import a Claude Code session that is open in a terminal.** The app
refuses it — see docs/PLAN.md for the incident that made that a refusal rather
than a warning.
**One Claude Code session id can name two files, and the listing offers it
once.** Resuming from a different working directory makes the CLI write a
second transcript with the same id under that directory's project folder — an
ordinary state of a machine, not corruption. Everything downstream addresses
a session by id, and the phone keyed its list on it, so two rows sharing one
**closed the app** on a Compose duplicate-key throw. `parse_listing` keeps
the copy with the most lines, because the other is usually a few-hundred-byte
stub and is often the *newer* of the two, so recency is the wrong key.
Deleting removes every copy rather than the first, or the row came back after
a delete that reported success. The phone's half is `uniqueItems`, which
every list keyed on a server-chosen id goes through: a repeat there must
never be able to close the app, whatever produced it.
**Deleting a session offers to take the machine's own transcript with it** —
`DELETE /sessions/{id}?deleteForeign=true`, behind a switch in the
confirmation, and only where the driver keeps a record of its own
(`keepsOwnTranscript`, which today means Claude Code). Off by default,
because leaving that copy is what makes an ordinary delete recoverable — and
the dialog's paragraph is rewritten when it is on rather than appended to,
since the sentence promising the conversation "should still be there to
import again" is exactly the one the switch makes false. The server deletes
the machine's copy *first*, so a machine it cannot reach leaves the session
where it was instead of half-deleted.
## Shared appearance
- **A row something is happening to is dimmed, drained of colour, and says
which operation in a word** — `BusyItem`, used by both the session list and
the import list so the appearance is learned once. The word rather than a
bare spinner because "deleting" and "importing" differ in kind. It does
**not** make the row inert: the caller disables its own click handler while
it passes a label. An overlay consuming pointer events was tried and
swallowed the drag along with the tap, so a list could not be scrolled
while anything in it was busy.
## Things that have bitten
Project-specific only; keep cross-project machine notes out of this file.
- **tracing caches callsite interest process-wide.** A test that hits a
`tracing::warn!` with no subscriber installed can poison the interest cache
for a concurrent test that captures logs (flaky "nothing was logged"
failures). Keep every exercise of a logging code path under the one
capturing subscriber — that is why the auth middleware has a single
combined gating+logging test.
- **The composer can get stuck floating above the bottom of the screen after
the keyboard closes, while a reply is streaming.** The composer's position
and the transcript's bottom padding are both driven by the raw, animated
`WindowInsets.ime` value read inside a `graphicsLayer` block, to avoid
recomposing the whole screen every frame of the keyboard's animation. That
animation is carried by a `WindowInsetsAnimationCallback`, and a callback
interrupted mid-flight leaves whatever it was carrying frozen at its last
value with nothing left to correct it. A streaming reply invalidates the
view every frame, which is exactly the condition known to starve that
callback of its `onEnd`. `WindowInsets.isImeVisible` does not share the
failure mode — it is set once, from the platform's own start/end of the
transition over a different path — so it is read once per keyboard toggle
and used to force both places back to zero.
**The guard is a boolean; the inset itself must never be read in the
composable body.** That correction first shipped as a `padding(bottom = …
imeInsets.getBottom(this) …)`, which subscribes the whole screen to a value
that changes every frame: measured at **16 full recompositions of
`SessionScreen` per keyboard open, against 1**. It is
`.then(if (imeVisible) Modifier.imePadding() else Modifier)` instead —
`imePadding` reads the inset in the layout phase, and dropping the modifier
is the same coercion to zero the boolean was added for. The counter to
check is `session screen recomposed` in the debug report, which should move
by one across a keyboard open, not by the number of frames it took.
- **The keyboard pans the window unless the activity opts into resize.**
Without `android:windowSoftInputMode="adjustResize"`, opening the IME
slides the whole window up (top bar off screen) instead of resizing —
`imePadding()` alone does not fix it and the transcript looks empty.
- **A PEM constant must start at the opening quotes.** A generated
`"""\n-----BEGIN CERTIFICATE-----` costs Android's `CertificateFactory` its
preamble sniff, so it tries DER instead and fails at runtime with
`ASN.1 … DECODE_ERROR` — nowhere near the code that produced it.
- **ZXing only looks for a dark code on a light ground.** The enrollment QR
is block characters in the terminal's foreground colour, so a dark-themed
terminal renders it as a negative and the in-app scanner silently never
matches — while the phone's own camera app, which tries both, does. The
scanner asks for `Intents.Scan.MIXED_SCAN`, which alternates normal and
inverted frames; keep it that way rather than making the server dictate the
colours.
- **`serde_json`'s default float parser is not correctly rounded**, so the
server handed out the same transcript line two different ways: a `ts` of
`1788546972.6030757` came back from `/transcript` as `…0755` while the SSE
stream sent the original. Nothing on screen could show it — a `ts` is drawn
as a relative time — and what found it was the phone's cache comparing a
line it held against the server's answer. The `float_roundtrip` feature in
`server/Cargo.toml` is the fix and
`a_line_read_back_is_the_line_that_was_written` is what keeps it; that test
fails within a second of the feature being dropped.
- **Resolving one importable session used to list every one of them.**
`import::delete` and the import seed both called `list`, which reads every
transcript Claude Code has ever written — measured at 3.7 seconds against
the 867 MB in this VM, paid once per session in a batch. `import::find`
takes the same script with one glob narrower: 78ms. Ids are checked
(`is_session_id`) before they reach that glob, since a `/` or `..` walks it
out of the projects directory.
- **A transcript page used to cost the whole transcript.** `read_window` read
and parsed every line and then kept the last `limit` of them, so the work
was the size of the conversation rather than the size of the answer: one
page of a 21 MB, 24,000-event transcript took ~500ms to return 620 KB, and
took the same 500ms whichever page was asked for. It is a bisection now
(`Indexed` in `transcript.rs`) — sequence numbers only increase, so the
edge of a range is found by parsing one line per halving. Same page,
~110ms, of which ~20ms is the file scan. The file is still read whole; that
is where the remaining cost is, and going further means a chunked backwards
reader.
- **Paging back has two failures that look like "there is simply no more
history", and neither says anything on screen.** Both invisible on a
loopback server and reproducible at `--delay 150`. The pager fires on the
*first layout*, before any event has arrived — `moreHistory` starts true,
so the spinner is in the list and `visibleItemsInfo` is not empty — and
`before = 0` asks for the events before the first one, which is none, which
is exactly how this code is told it has reached the start. `loadOlderPage`
refuses `oldestSeq == 0` now. And `joinPages` only ran `adoptRun` on the
path where a *split* call had been found, so a boundary landing cleanly
between two calls — most of them — left one run of tool calls drawn as two
groups with the seam wherever the reader happened to have paged.
Reproducing either takes a boundary placed on purpose: the opening page is
80 events, so arrange the transcript so that event counts back from the
newest.
- **A page is 800 events and a screen is a handful of rows, and the two have
no fixed ratio.** A run of thirty-five tool calls is one row; a reply is
hundreds of text deltas folded into one. So anything that budgets in rows
has to measure a screen rather than name a number: the history cushion was
eight rows, which on a tool-heavy transcript is less than one screenful, so
the reader hit the end of what was loaded on every swipe and stood there
for a round trip. It is `HISTORY_SCREENS` viewports now, counted from what
is actually on screen.
- **Only `fetchTranscript` was off the main thread; the fold was not.**
`foldEvent` returns a new list per event, so a page is that many copies of
a growing list — fine at 80 events and about 300,000 element copies at 800,
run in the middle of the scroll that asked for it. `warm` had the same
shape: the `markdownIn` scan that decides *what* to parse ran before the
hop to `Dispatchers.Default`. The shape to watch for is a `withContext`
that wraps the *fetch* and leaves the work done with the result outside it.
## Measurements worth not re-taking
- **What the transcript screen costs to scroll.** Taken 2026-08-30 on the GPU
emulator against a real imported transcript with the server at
`--delay 120`. Settled and flinging fast, both into fresh history and back
through rows already drawn: **5.25.9% janky frames, 99th percentile
2932ms, 02 slow UI-thread frames.** The stock Settings app on the same
device is 3.3% and 38ms, so this is at the platform floor. The number that
is *not* at the floor is the first few seconds after opening a session,
where every row on the way is being composed for the first time; that is
inherent to a lazy list and it is why a measurement taken before the screen
settles reads three times worse. **Settle first, then reset `gfxinfo`.**
- **The reset path is not reachable by reopening a session.** Measured
2026-09-04 against a session streaming at 20 events a second: reopening one
with an anchor 1,800 events back connects **87119 events behind**, well
under `CATCH_UP_LIMIT`'s 200, because the restore is two requests — the
opening page, then one span covering the whole distance. To exercise the
reset at all you have to lower `CATCH_UP_LIMIT` in a throwaway build; at 5
the app takes the reset on a live connection, clears, refills and carries
on without reconnecting.
- **The session screen's stream survives backgrounding here** — 20 seconds at
the launcher while 415 events were produced brought no reconnect at all,
which is not what the comment above that loop expects, and is most likely
this emulator being headless rather than the phone's behaviour.
- **Reopening a cached session costs one request for one event** (the probe),
and scrolling the whole conversation back costs nothing more; a cold open
of the same 500-event session is two pages, 100 events. Measured
2026-09-04 on the emulator against the sandbox.
- **Reading is cheap and editing is not.** The viewer handles a 1 MiB,
28,000-line file because it draws one row per line; the editor is one
`BasicTextField`, which costs two seconds a frame at 128 kB and stops the
app at 1 MiB, so `EDIT_LIMIT` caps it at 32 kB with the reason said on
screen. If you make the editor faster, that number is what to move.
docs/EXPLORER.md's "What the measurements said" has the rest.
- `tracing` caches callsite interest process-wide. Logging tests must install
their capturing subscriber before any tested callsite runs.
- `serde_json` needs `float_roundtrip`: transcript pages and SSE must preserve
identical timestamp bytes.
- Import lookup must use `import::find`, not list every transcript. Validate
ids before putting them in a glob.
- Transcript sequence numbers increase, so page edges are found by bisection.
Do not replace indexed window reads with whole-transcript parsing.
- A page's event count has no fixed relationship to visible rows because
deltas and tool calls fold together. History cushions are measured in
viewports, not row counts.
- Android generic motion is separate from touch. Keep hover, wheel, and mouse
button handling in `iris::android`; product UI consumes the same pointer
state on desktop and Android.
-90
View File
@@ -1,90 +0,0 @@
plugins {
id("com.android.application")
}
// The Rust side (this directory's Cargo.toml) is built separately with
// `cargo ndk`, straight into src/main/jniLibs/ -- see the repo-root
// AGENTS.md-style comment at the top of Cargo.toml for why this crate
// stays outside the main Rust workspace, and RUST.md's I2 for the exact
// build command.
android {
namespace = "dev.iris.android.demo"
compileSdk = 37
defaultConfig {
applicationId = "dev.iris.android.demo"
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// 37, matching `compileSdk` and the Compose app in `app/` -- which
// is the one part of this that is measured rather than reasoned:
// that app targets 37 and its keyboard does push the transcript up
// on Iris's phone, and this one targeted 34 and does not
// (2026-09-07). The emulator here is API 36 and the push-up works
// there at either target, so the target is the only difference the
// two devices do not share.
//
// The mechanism, stated as the reading it is: below targetSdk 35
// a window keeps the legacy behaviour, where `adjustResize` shrinks
// the window for the IME and `getInsets(ime()).bottom` therefore
// measures the overlap with an already-shrunk window -- zero, with
// nothing left to push up. `MainActivity`'s
// `setDecorFitsSystemWindows(false)` opts out of that, and on API
// 36 it still takes; Android 16 deprecated it and Android 17 is
// where it appears not to. At 35+ edge-to-edge is not opt-in, so
// the app is handed the real overlap without relying on a
// deprecated call. If the phone still reports `ime_bottom=0` with
// a nonzero `dispatches` in the Diagnostics pane, this reading was
// wrong and the `WindowInsetsAnimation.Callback` in
// `MainActivity` is the other half to look at.
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
// A release build must be signed, and the key is per machine rather than per repo -- same
// reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone
// recognises the app by, and a secret never lives in a checkout (the mount is shared with an
// untrusted VM). `build-apk.sh` generates this key once and points at it through the
// environment; without it a release build here is unsigned, which is fine for everything
// except installing.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
debug {
}
// P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so`
// itself is built separately with `cargo ndk --release --features "transcript-screen
// force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) --
// Gradle here only packages and signs whatever is already there, the same division as the
// debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it
// installable beside a debug build of the tabs demo rather than replacing it.
release {
applicationIdSuffix ".bench"
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -1,68 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only needed by the transcript-screen feature (RUST.md's I5),
which talks to a real ai-server; the plain tabs demo (I2/I4) makes
no network call and never noticed this was missing. Absent,
UreqTransport::new's connect failed with EPERM (Operation not
permitted), not the ECONNREFUSED/ENETUNREACH a firewall or a dead
server would give: a seccomp-level socket denial reads nothing
like a network problem, which is what made it worth a comment. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="iris android-view demo"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- The enrollment link Dev Updater's Enroll button opens
(what `ai-server` mints), the same one the Compose app
in `app/` registers: which app answers it is the phone
owner's choice at the moment of the tap, and both being
offered is the intended behaviour rather than a clash.
BROWSABLE so a link tapped in another app reaches here,
and `android:host` so this app is not offered for every
aiapp:// URI a future route invents. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
</activity>
<!-- This app's own recent log, for Dev Updater to read on the
phone. Iris runs these builds with no adb, and Android
forbids one app reading another's logcat, so this is the
only way a log::info! here reaches her. The shape is Dev
Updater's contract (its README.md, "An app's own log"), not
something invented for this app.
The authority carries ${applicationId}, so the bench package
and the ordinary one each get their own and neither can read
the other's log. Exported, because the whole point is
another app reading it, and guarded by a permission Dev
Updater declares at protectionLevel="normal" (a signature
permission is not available: the two apps are signed with
different locally generated keys). Read-only: insert,
update and delete throw. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
-120
View File
@@ -1,120 +0,0 @@
#!/bin/sh
# Builds the Android app end to end: the cdylib (cargo ndk from this
# directory, straight into android-project/app/src/main/jniLibs/) then the
# APK (Gradle, from android-project/). Written to stop re-typing
# the same incantation by hand every time (ANDROID_HOME/NDK exports, the
# cargo ndk invocation, the keystore env for a release build, apksigner/
# aapt2 verification) -- see docs/RUST.md's P0 box. Same shape as `app/
# build-apk.sh` (the Compose app's own build script) and `app/
# iris-scroll.sh` (no coordinates, set -eu, exit 0 on success).
#
# Usage: ./build-apk.sh [debug|release] [--abi arm64-v8a|x86_64] [--features "a b c"]
# debug/release default to debug (matches this-machine-android's "the
# emulator stays on debug" rule -- pass `release` explicitly for a phone
# build). --abi defaults to arm64-v8a (a phone/real device); pass
# x86_64 for this checkout's own AVD. --features defaults to
# "transcript-screen bench" -- deliberately *without* `force-gles`, and
# nothing should add it back for the emulator's sake.
#
# **The emulator does not need a GLES build, because it has no hardware
# Vulkan to be steered away from** (docs/RUST.md, "What the emulator
# gives a GPU app", 2026-09-08): its guest's only Vulkan is SwiftShader
# in software, its GLES is the host's real GPU through virgl, and iris's
# own runtime fallback -- `Backends::PRIMARY`, no adapter, rebuild on
# `Backends::GL` -- takes an ordinary build there by itself. So the
# emulator and the phone run the *same binary* and differ only in what
# that binary finds, which is the whole point: a build flag that changed
# the backend would mean the thing measured here is not the thing
# shipped.
#
# `force-gles` (`iris/Cargo.toml`'s own doc) pins the backend at compile
# time for a backend-isolation measurement (RUST.md's I5, "Where iris's
# frame time goes"), and the desktop is the better place to run it now
# (`run-headless.sh ... --features iris/force-gles`). It was never meant
# to reach a real device, but this script's old default put it in every
# arm64 build regardless, so the P0 bench APK delivered to Iris's phone
# forced GLES there too -- the named hypothesis in RUST.md's P0 box
# ("iris bench crash on the phone, 2026-09-06"). Never pass it for a
# build meant for a phone.
set -eu
cd "$(dirname "$0")"
BUILD_TYPE="debug"
ABI="arm64-v8a"
FEATURES="transcript-screen bench"
case "${1:-}" in
debug|release) BUILD_TYPE="$1"; shift ;;
esac
while [ $# -gt 0 ]; do
case "$1" in
--abi) ABI="$2"; shift 2 ;;
--features) FEATURES="$2"; shift 2 ;;
*) echo "build-apk.sh: unknown argument: $1" >&2; exit 1 ;;
esac
done
SDK_ROOT="$HOME/Android/Sdk"
export ANDROID_HOME="$SDK_ROOT"
export ANDROID_SDK_ROOT="$SDK_ROOT"
NDK_DIR=$(ls -d "$SDK_ROOT"/ndk/*/ 2>/dev/null | sort -V | tail -1)
if [ -z "$NDK_DIR" ]; then
echo "build-apk.sh: no NDK found under $SDK_ROOT/ndk" >&2
exit 1
fi
export ANDROID_NDK_HOME="$NDK_DIR"
# Only the ABI asked for goes into the APK. cargo ndk adds its output beside
# whatever earlier builds left here, and Gradle packages every directory it
# finds -- a debug x86_64 emulator build left behind made an arm64 "release"
# 339 MB on 2026-09-06.
rm -rf android-project/app/src/main/jniLibs
# ...and Gradle's own copy of them, which `rm -rf jniLibs` does not reach.
# `mergeReleaseNativeLibs` is *up to date* against its cached inputs, so a
# build that switches ABI packages the previous ABI: an `--abi x86_64`
# release APK containing `lib/arm64-v8a/libmain.so` installed fine and
# aborted at startup with `Could not get adapter!: NotFound {
# active_backends: VULKAN }` under libndk_translation -- which reads
# exactly like the phone's own Vulkan problem and is nothing of the kind.
# Scoped to the merge task's directory rather than all of `app/build`, so
# an ABI change costs the native merge and not the whole Gradle build.
rm -rf android-project/app/build/intermediates/merged_native_libs \
android-project/app/build/intermediates/stripped_native_libs \
android-project/app/build/intermediates/merged_jni_libs
echo "build-apk.sh: cargo ndk -t $ABI build ${BUILD_TYPE:+(${BUILD_TYPE})} --features \"$FEATURES\""
if [ "$BUILD_TYPE" = "release" ]; then
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-release --no-default-features --features "$FEATURES"
else
cargo ndk -t "$ABI" -P 29 -o android-project/app/src/main/jniLibs/ build --lib \
--profile android-dev --no-default-features --features "$FEATURES"
fi
GRADLE_TASK="assembleDebug"
APK_DIR="android-project/app/build/outputs/apk/debug"
APK_NAME="app-debug.apk"
if [ "$BUILD_TYPE" = "release" ]; then
GRADLE_TASK="assembleRelease"
APK_DIR="android-project/app/build/outputs/apk/release"
APK_NAME="app-release.apk"
# Same key `app/build-apk.sh` (the Compose app) generates once under
# ~/.config/ai-app/release.jks -- see AGENTS.md's "Checking your work".
export AI_APP_KEYSTORE="$HOME/.config/ai-app/release.jks"
if [ ! -f "$AI_APP_KEYSTORE" ]; then
echo "build-apk.sh: no release key at $AI_APP_KEYSTORE -- run app/build-apk.sh once first" >&2
exit 1
fi
export AI_APP_KEYSTORE_PASSWORD
AI_APP_KEYSTORE_PASSWORD=$(cat "$AI_APP_KEYSTORE.password")
fi
(cd android-project && gradle ":app:$GRADLE_TASK" --console=plain)
APK_PATH="$(pwd)/$APK_DIR/$APK_NAME"
BUILD_TOOLS=$(ls -d "$SDK_ROOT"/build-tools/*/ | sort -V | tail -1)
echo "--- aapt2 dump badging ---"
"${BUILD_TOOLS}aapt2" dump badging "$APK_PATH" | head -5
if [ "$BUILD_TYPE" = "release" ]; then
echo "--- apksigner verify ---"
"${BUILD_TOOLS}apksigner" verify --print-certs "$APK_PATH"
fi
echo "$APK_PATH"
-20
View File
@@ -1,20 +0,0 @@
pub mod client;
#[cfg(feature = "screens")]
pub mod ui;
#[cfg(all(feature = "screens", not(target_os = "android")))]
pub mod desktop;
#[cfg(all(feature = "screens", target_os = "android"))]
pub mod android;
// `jni` 0.22's `native_method!` expands to `AtomicBool::fetch_update`,
// which this toolchain deprecates in favour of `try_update`. The call is
// inside the macro, so there is nothing here to migrate -- the fix is a
// `jni` release, and this allow comes out when one lands. Scoped to the
// module the macro is used in rather than the crate, so a deprecation in
// our own code is still a warning.
#[cfg(feature = "shell")]
#[allow(deprecated)]
pub mod shell;
-99
View File
@@ -1,99 +0,0 @@
use jni::Env;
use jni::errors::Result;
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
use jni::refs::{Global, LoaderContext};
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
use jni::strings::JNIString;
use std::sync::OnceLock;
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
if CLASS_LOADER.get().is_some() {
return Ok(());
}
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
let loader_obj = call_method(
env,
&class_obj,
"getClassLoader",
"()Ljava/lang/ClassLoader;",
&[],
)?
.l()?;
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
let global = env.new_global_ref(&loader)?;
// Lost the race with another entry point calling this concurrently --
// both loaders name the same app, so either one is fine and there is
// nothing to reconcile.
let _ = CLASS_LOADER.set(global);
Ok(())
}
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
/// through the cached app classloader when one has been remembered, and
/// through the ordinary default otherwise -- which is every call made
/// before any entry point has run, and is also correct for a main-thread
/// caller, so there is no case this makes worse.
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
match CLASS_LOADER.get() {
Some(loader) => {
let binary_name = name.replace('/', ".");
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
}
None => env.find_class(JNIString::new(name)),
}
}
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
resolve_class(env, name)
}
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
Ok(env.new_string(text)?.into())
}
pub fn new_object<'local>(
env: &mut Env<'local>,
class: &str,
sig: &str,
args: &[JValue],
) -> Result<JObject<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.new_object(class, sig.method_signature(), args)
}
pub fn call_method<'local>(
env: &mut Env<'local>,
obj: &JObject,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
}
pub fn call_static_method<'local>(
env: &mut Env<'local>,
class: &str,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<JValueOwned<'local>> {
let sig = RuntimeMethodSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
}
pub fn get_static_field<'local>(
env: &mut Env<'local>,
class: &str,
field: &str,
sig: &str,
) -> Result<JValueOwned<'local>> {
let sig = RuntimeFieldSignature::from_str(sig)?;
let class = resolve_class(env, class)?;
env.get_static_field(class, JNIString::new(field), sig.field_signature())
}
-95
View File
@@ -1,95 +0,0 @@
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}
-534
View File
@@ -1,534 +0,0 @@
//! Where a notification is said, and the foreground service that keeps
//! the connection open while the app is closed. Ported from
//! `Notifications.kt`'s `NotificationService`, minus the "session on
//! screen" / "hand to the app as a banner" branches: those read
//! process-wide state that only exists because a screen is drawn to
//! register against, and this experiment draws no screen yet (that is
//! E4's job, on iris). So every notification here takes the third branch
//! Kotlin's `show` already had -- the platform's own drawer -- which is
//! also exactly the case E3's pass condition asks for: **a notification
//! arrives with the app closed.**
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::client::api::UreqTransport;
use crate::client::notifications::{SessionNotification, follow_notifications};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JValue};
use jni::sys::{JNI_TRUE, jint};
use crate::shell::settings::{self, ServerSettings};
const ALERT_CHANNEL: &str = "sessions";
const ONGOING_CHANNEL: &str = "connection";
const ONGOING_ID: i32 = 1;
const ALERT_ID: i32 = 2;
const RECONNECT_DELAY: Duration = Duration::from_millis(5_000);
/// Whether the follow-loop thread is already running. **A deviation from
/// `Notifications.kt`, found by testing rather than planned**: the Kotlin
/// `onStartCommand` spawns a fresh `thread(isDaemon = true) { follow(...) }`
/// on *every* call, with nothing to notice a previous one is still going --
/// and `sync()` calling `startForegroundService` when the service is
/// already running is an ordinary Android start, not a restart, so
/// `onStartCommand` runs again. Enrolling from `MainActivity` (which calls
/// `sync` once itself, then again inside `handle_enrollment` after saving
/// the token) hits exactly this path and was observed opening **two**
/// concurrent connections to `/notifications` from one process -- caught
/// on this build via `adb logcat` showing two `jni::vm::java_vm: Attached
/// thread ai-app-notifications` lines for one enrollment. Guarded here
/// rather than left to match Kotlin's behaviour exactly, since duplicating
/// a live connection is a resource leak with no upside; worth carrying the
/// same guard back to `Notifications.kt` separately.
static RUNNING: AtomicBool = AtomicBool::new(false);
static STOPPING: AtomicBool = AtomicBool::new(false);
fn static_int(env: &mut Env, class: &str, field: &str) -> Result<i32> {
crate::shell::jcall::get_static_field(env, class, field, "I")?.i()
}
fn notification_manager<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/NotificationManagerCompat",
"from",
"(Landroid/content/Context;)Landroidx/core/app/NotificationManagerCompat;",
&[JValue::Object(context)],
)?
.l()
}
fn create_channel(
env: &mut Env,
manager: &JObject,
id: &str,
name: &str,
importance: i32,
) -> Result<()> {
let id_j = crate::shell::jcall::jstr_obj(env, id)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationChannelCompat$Builder",
"(Ljava/lang/String;I)V",
&[JValue::Object(&id_j), JValue::Int(importance)],
)?;
let name_j = crate::shell::jcall::jstr_obj(env, name)?;
crate::shell::jcall::call_method(
env,
&builder,
"setName",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationChannelCompat$Builder;",
&[JValue::Object(&name_j)],
)?;
let channel = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroidx/core/app/NotificationChannelCompat;",
&[],
)?
.l()?;
crate::shell::jcall::call_method(
env,
manager,
"createNotificationChannel",
"(Landroidx/core/app/NotificationChannelCompat;)V",
&[JValue::Object(&channel)],
)?;
Ok(())
}
/// Two channels, because they are two different things to be told -- see
/// `Notifications.kt`'s `createChannels` for the reasoning; the names and
/// importances here are copied from it exactly, since a phone that has
/// seen both apps should not learn two different vocabularies for the
/// same fact.
fn create_channels(env: &mut Env, context: &JObject) -> Result<()> {
let manager = notification_manager(env, context)?;
let default = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_DEFAULT",
)?;
let min = static_int(
env,
"androidx/core/app/NotificationManagerCompat",
"IMPORTANCE_MIN",
)?;
create_channel(
env,
&manager,
ALERT_CHANNEL,
"Sessions needing attention",
default,
)?;
create_channel(env, &manager, ONGOING_CHANNEL, "Staying connected", min)?;
Ok(())
}
fn new_intent_for<'l>(
env: &mut Env<'l>,
context: &JObject,
class_name: &str,
) -> Result<JObject<'l>> {
let target_class = crate::shell::jcall::find_class(env, class_name)?;
crate::shell::jcall::new_object(
env,
"android/content/Intent",
"(Landroid/content/Context;Ljava/lang/Class;)V",
&[JValue::Object(context), JValue::Object(&target_class)],
)
}
/// The intent a tap on an alert opens -- mirrors `Notifications.kt`'s
/// `sessionIntent`, including building the URI through `Uri.Builder`
/// rather than string concatenation, for the same reason: an id needing
/// escaping must survive the round trip.
fn session_intent<'l>(
env: &mut Env<'l>,
context: &JObject,
session_id: &str,
) -> Result<JObject<'l>> {
let intent = new_intent_for(env, context, "com/example/aiapp/shell/MainActivity")?;
let action_view = crate::shell::jcall::jstr_obj(env, "android.intent.action.VIEW")?;
crate::shell::jcall::call_method(
env,
&intent,
"setAction",
"(Ljava/lang/String;)Landroid/content/Intent;",
&[JValue::Object(&action_view)],
)?;
let builder = crate::shell::jcall::new_object(env, "android/net/Uri$Builder", "()V", &[])?;
let scheme = crate::shell::jcall::jstr_obj(env, settings::SCHEME)?;
crate::shell::jcall::call_method(
env,
&builder,
"scheme",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&scheme)],
)?;
let authority = crate::shell::jcall::jstr_obj(env, "session")?;
crate::shell::jcall::call_method(
env,
&builder,
"authority",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&authority)],
)?;
let path = crate::shell::jcall::jstr_obj(env, session_id)?;
crate::shell::jcall::call_method(
env,
&builder,
"appendPath",
"(Ljava/lang/String;)Landroid/net/Uri$Builder;",
&[JValue::Object(&path)],
)?;
let uri = crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/net/Uri;", &[])?
.l()?;
crate::shell::jcall::call_method(
env,
&intent,
"setData",
"(Landroid/net/Uri;)Landroid/content/Intent;",
&[JValue::Object(&uri)],
)?;
Ok(intent)
}
fn pending_activity<'l>(
env: &mut Env<'l>,
context: &JObject,
intent: &JObject,
) -> Result<JObject<'l>> {
let update_current = static_int(env, "android/app/PendingIntent", "FLAG_UPDATE_CURRENT")?;
let immutable = static_int(env, "android/app/PendingIntent", "FLAG_IMMUTABLE")?;
crate::shell::jcall::call_static_method(
env,
"android/app/PendingIntent",
"getActivity",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
&[
JValue::Object(context),
JValue::Int(0),
JValue::Object(intent),
JValue::Int(update_current | immutable),
],
)?
.l()
}
fn builder_call<'l>(
env: &mut Env<'l>,
builder: &JObject<'l>,
method: &str,
sig: &str,
args: &[JValue],
) -> Result<()> {
crate::shell::jcall::call_method(env, builder, method, sig, args)?;
Ok(())
}
/// The type Android 14+ requires a foreground service to declare, and
/// nothing before it -- mirrors `Notifications.kt`'s `foregroundType`.
fn foreground_type(env: &mut Env) -> Result<i32> {
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let upside_down_cake = static_int(env, "android/os/Build$VERSION_CODES", "UPSIDE_DOWN_CAKE")?;
if sdk >= upside_down_cake {
static_int(
env,
"android/content/pm/ServiceInfo",
"FOREGROUND_SERVICE_TYPE_SPECIAL_USE",
)
} else {
Ok(0)
}
}
fn ongoing_notification<'l>(env: &mut Env<'l>, context: &JObject) -> Result<JObject<'l>> {
let channel = crate::shell::jcall::jstr_obj(env, ONGOING_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, "Watching for sessions that need you")?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_sync")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setOngoing",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let priority_min = static_int(env, "androidx/core/app/NotificationCompat", "PRIORITY_MIN")?;
builder_call(
env,
&builder,
"setPriority",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(priority_min)],
)?;
crate::shell::jcall::call_method(env, &builder, "build", "()Landroid/app/Notification;", &[])?
.l()
}
/// Starts the service if there is a server to connect to, and stops it
/// otherwise -- mirrors `Notifications.kt`'s `NotificationService.sync`.
pub fn sync(env: &mut Env, context: &JObject) -> Result<()> {
let service_intent =
new_intent_for(env, context, "com/example/aiapp/shell/NotificationService")?;
if settings::load(env, context)?.is_none() {
crate::shell::jcall::call_method(
env,
context,
"stopService",
"(Landroid/content/Intent;)Z",
&[JValue::Object(&service_intent)],
)?;
return Ok(());
}
create_channels(env, context)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"startForegroundService",
"(Landroid/content/Context;Landroid/content/Intent;)V",
&[JValue::Object(context), JValue::Object(&service_intent)],
)?;
Ok(())
}
pub fn on_start_command(env: &mut Env, service: JObject) -> jint {
match try_start(env, &service) {
Ok(true) => static_int(env, "android/app/Service", "START_STICKY").unwrap_or(1),
Ok(false) => {
let _ = crate::shell::jcall::call_method(env, &service, "stopSelf", "()V", &[]);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
Err(e) => {
log_error(env, "onStartCommand", &e);
static_int(env, "android/app/Service", "START_NOT_STICKY").unwrap_or(2)
}
}
}
fn try_start(env: &mut Env, service: &JObject) -> Result<bool> {
let Some(settings) = settings::load(env, service)? else {
return Ok(false);
};
let ca = settings::load_pinned_ca(env)?;
let notification = ongoing_notification(env, service)?;
let fg_type = foreground_type(env)?;
crate::shell::jcall::call_static_method(
env,
"androidx/core/app/ServiceCompat",
"startForeground",
"(Landroid/app/Service;ILandroid/app/Notification;I)V",
&[
JValue::Object(service),
JValue::Int(ONGOING_ID),
JValue::Object(&notification),
JValue::Int(fg_type),
],
)?;
// See `RUNNING`'s doc: a second `onStartCommand` while the loop from
// the first is still going -- the ordinary case for this service,
// since `sync()` is called from more than one place -- must not open
// a second connection.
if RUNNING.swap(true, Ordering::SeqCst) {
return Ok(true);
}
let vm = env.get_java_vm()?;
let context = env.new_global_ref(service)?;
STOPPING.store(false, Ordering::SeqCst);
std::thread::Builder::new()
.name("ai-app-notifications".to_string())
.spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
follow_loop(env, &context, settings, &ca);
Ok(())
});
})
.ok();
Ok(true)
}
fn follow_loop(env: &mut Env, context: &JObject, settings: ServerSettings, ca: &[u8]) {
while !STOPPING.load(Ordering::SeqCst) {
if let Ok(transport) = UreqTransport::new(settings.base_url(), settings.token.clone(), ca) {
let _ = follow_notifications(&transport, |notification| {
if let Err(e) = show(env, context, &notification) {
log_error(env, "show", &e);
}
!STOPPING.load(Ordering::SeqCst)
});
}
if STOPPING.load(Ordering::SeqCst) {
return;
}
std::thread::sleep(RECONNECT_DELAY);
}
}
fn show(env: &mut Env, context: &JObject, notification: &SessionNotification) -> Result<()> {
let manager = notification_manager(env, context)?;
let sdk = static_int(env, "android/os/Build$VERSION", "SDK_INT")?;
let tiramisu = static_int(env, "android/os/Build$VERSION_CODES", "TIRAMISU")?;
let allowed = if sdk < tiramisu {
true
} else {
let permission =
crate::shell::jcall::jstr_obj(env, "android.permission.POST_NOTIFICATIONS")?;
let granted = static_int(
env,
"android/content/pm/PackageManager",
"PERMISSION_GRANTED",
)?;
let result = crate::shell::jcall::call_static_method(
env,
"androidx/core/content/ContextCompat",
"checkSelfPermission",
"(Landroid/content/Context;Ljava/lang/String;)I",
&[JValue::Object(context), JValue::Object(&permission)],
)?
.i()?;
result == granted
};
let enabled =
crate::shell::jcall::call_method(env, &manager, "areNotificationsEnabled", "()Z", &[])?
.z()?;
if !allowed || !enabled {
return Ok(());
}
let intent = session_intent(env, context, &notification.session_id)?;
let pending = pending_activity(env, context, &intent)?;
let channel = crate::shell::jcall::jstr_obj(env, ALERT_CHANNEL)?;
let builder = crate::shell::jcall::new_object(
env,
"androidx/core/app/NotificationCompat$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&channel)],
)?;
let title = crate::shell::jcall::jstr_obj(env, &notification.title)?;
builder_call(
env,
&builder,
"setContentTitle",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&title)],
)?;
let text = crate::shell::jcall::jstr_obj(env, notification.kind.attention_line())?;
builder_call(
env,
&builder,
"setContentText",
"(Ljava/lang/CharSequence;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&text)],
)?;
let icon = static_int(env, "android/R$drawable", "stat_notify_chat")?;
builder_call(
env,
&builder,
"setSmallIcon",
"(I)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Int(icon)],
)?;
builder_call(
env,
&builder,
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Object(&pending)],
)?;
builder_call(
env,
&builder,
"setAutoCancel",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let when = (notification.at * 1000.0) as i64;
builder_call(
env,
&builder,
"setWhen",
"(J)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Long(when)],
)?;
builder_call(
env,
&builder,
"setShowWhen",
"(Z)Landroidx/core/app/NotificationCompat$Builder;",
&[JValue::Bool(JNI_TRUE)],
)?;
let built = crate::shell::jcall::call_method(
env,
&builder,
"build",
"()Landroid/app/Notification;",
&[],
)?
.l()?;
let tag = crate::shell::jcall::jstr_obj(env, &notification.session_id)?;
crate::shell::jcall::call_method(
env,
&manager,
"notify",
"(Ljava/lang/String;ILandroid/app/Notification;)V",
&[
JValue::Object(&tag),
JValue::Int(ALERT_ID),
JValue::Object(&built),
],
)?;
Ok(())
}
pub fn on_destroy() {
STOPPING.store(true, Ordering::SeqCst);
// `RUNNING`'s path out. Same race as `STOPPING` itself (this doc's own
// comment): the old thread may still be inside a blocked read when a
// new `onStartCommand` follows immediately, which would spawn a
// second one before the first has actually stopped. Narrower than not
// resetting at all -- a service destroyed and never restarted would
// otherwise wedge `RUNNING` true forever -- and no worse than the
// known gap already accepted above.
RUNNING.store(false, Ordering::SeqCst);
}
pub fn log_error(env: &mut Env, where_: &str, error: &jni::errors::Error) {
let message = format!("android-shell: {where_}: {error}");
let _ = (|| -> Result<()> {
let tag = crate::shell::jcall::jstr_obj(env, "android-shell")?;
let msg = crate::shell::jcall::jstr_obj(env, &message)?;
crate::shell::jcall::call_static_method(
env,
"android/util/Log",
"e",
"(Ljava/lang/String;Ljava/lang/String;)I",
&[JValue::Object(&tag), JValue::Object(&msg)],
)?;
Ok(())
})();
}
-133
View File
@@ -1,133 +0,0 @@
//! Enrollment: where the backend is, and the Keystore-sealed token to
//! reach it. This crate does not reimplement the Android Keystore AES-GCM
//! sealing in Rust -- it calls the same `wg-app-link` `ServerStore` Kotlin
//! class the production app already uses (see `ServerConfig.kt`), through
//! JNI, for two reasons: that code is shared with Dev Updater and already
//! tested, and the sealed value on a real phone is keyed to the exact
//! Keystore alias that class already uses -- reimplementing the crypto
//! here would either duplicate it or invalidate an existing enrollment.
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
/// Where the backend is and how to authenticate to it -- the Rust twin of
/// `wg-app-link`'s `ServerSettings` data class, read back field by field
/// rather than kept as a live JNI reference, so it can cross a thread
/// boundary (a `JObject` is tied to one `Env`/thread).
#[derive(Debug, Clone)]
pub struct ServerSettings {
pub host: String,
pub port: i32,
pub token: String,
}
impl ServerSettings {
pub fn base_url(&self) -> String {
format!("https://{}:{}", self.host, self.port)
}
}
pub(crate) const SCHEME: &str = "aiappshell";
const KEY_ALIAS: &str = "aiapp-shell-token-key";
const STORE_CLASS: &str = "com/example/wgapplink/ServerStore";
const SETTINGS_CLASS: &str = "com/example/wgapplink/ServerSettings";
fn new_store<'l>(env: &mut Env<'l>) -> Result<JObject<'l>> {
let scheme = crate::shell::jcall::jstr_obj(env, SCHEME)?;
let alias = crate::shell::jcall::jstr_obj(env, KEY_ALIAS)?;
crate::shell::jcall::new_object(
env,
STORE_CLASS,
"(Ljava/lang/String;Ljava/lang/String;)V",
&[JValue::Object(&scheme), JValue::Object(&alias)],
)
}
fn read_settings(env: &mut Env, settings_obj: &JObject) -> Result<ServerSettings> {
let host = get_string(env, settings_obj, "getHost")?;
let port = crate::shell::jcall::call_method(env, settings_obj, "getPort", "()I", &[])?.i()?;
let token = get_string(env, settings_obj, "getToken")?;
Ok(ServerSettings { host, port, token })
}
fn get_string(env: &mut Env, obj: &JObject, getter: &str) -> Result<String> {
let value =
crate::shell::jcall::call_method(env, obj, getter, "()Ljava/lang/String;", &[])?.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
jstr.try_to_string(env)
}
/// The stored enrollment, or `None` when there is not one -- mirrors
/// `ServerConfig.kt`'s `loadServerSettings`.
pub fn load(env: &mut Env, context: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"load",
"(Landroid/content/Context;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(context)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
pub fn save(env: &mut Env, context: &JObject, settings: &ServerSettings) -> Result<()> {
let store = new_store(env)?;
let host = crate::shell::jcall::jstr_obj(env, &settings.host)?;
let token = crate::shell::jcall::jstr_obj(env, &settings.token)?;
let settings_obj = crate::shell::jcall::new_object(
env,
SETTINGS_CLASS,
"(Ljava/lang/String;ILjava/lang/String;)V",
&[
JValue::Object(&host),
JValue::Int(settings.port),
JValue::Object(&token),
],
)?;
crate::shell::jcall::call_method(
env,
&store,
"save",
"(Landroid/content/Context;Lcom/example/wgapplink/ServerSettings;)V",
&[JValue::Object(context), JValue::Object(&settings_obj)],
)?;
Ok(())
}
pub fn parse_enrollment_uri(env: &mut Env, uri: &JObject) -> Result<Option<ServerSettings>> {
let store = new_store(env)?;
let settings_obj = crate::shell::jcall::call_method(
env,
&store,
"parseEnrollmentUri",
"(Landroid/net/Uri;)Lcom/example/wgapplink/ServerSettings;",
&[JValue::Object(uri)],
)?
.l()?;
if settings_obj.is_null() {
return Ok(None);
}
Ok(Some(read_settings(env, &settings_obj)?))
}
/// The CA this build pins, generated at build time the same way
/// `androidApp`'s `generatePinnedCert` task does (see `build.gradle.kts`)
/// but into a plain Java constant, since this module has no Kotlin of its
/// own to generate into.
pub fn load_pinned_ca(env: &mut Env) -> Result<Vec<u8>> {
let value = crate::shell::jcall::get_static_field(
env,
"com/example/aiapp/shell/PinnedCa",
"PINNED_CA_PEM",
"Ljava/lang/String;",
)?
.l()?;
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(jstr.try_to_string(env)?.into_bytes())
}
-157
View File
@@ -1,157 +0,0 @@
use crate::client::api::{ApiClient, UreqTransport};
use jni::Env;
use jni::errors::Result;
use jni::objects::{JObject, JString, JValue};
use crate::shell::notify;
use crate::shell::settings;
const ACTION_SEND: &str = "android.intent.action.SEND";
const ACTION_SEND_MULTIPLE: &str = "android.intent.action.SEND_MULTIPLE";
const ACTION_VIEW: &str = "android.intent.action.VIEW";
const EXTRA_TEXT: &str = "android.intent.extra.TEXT";
fn get_string_method(env: &mut Env, obj: &JObject, method: &str) -> Result<Option<String>> {
let value =
crate::shell::jcall::call_method(env, obj, method, "()Ljava/lang/String;", &[])?.l()?;
if value.is_null() {
return Ok(None);
}
let jstr: JString = env.cast_local::<JString>(value)?;
Ok(Some(jstr.try_to_string(env)?))
}
fn toast(env: &mut Env, context: &JObject, message: &str) -> Result<()> {
let message = crate::shell::jcall::jstr_obj(env, message)?;
crate::shell::jcall::call_static_method(
env,
"com/example/aiapp/shell/MainActivity",
"toast",
"(Landroid/content/Context;Ljava/lang/String;)V",
&[JValue::Object(context), JValue::Object(&message)],
)?;
Ok(())
}
pub fn handle_intent(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let action = get_string_method(env, intent, "getAction")?;
if matches!(
action.as_deref(),
Some(ACTION_SEND) | Some(ACTION_SEND_MULTIPLE)
) {
return handle_share(env, activity, intent);
}
if action.as_deref() != Some(ACTION_VIEW) {
return Ok(());
}
let uri = crate::shell::jcall::call_method(env, intent, "getData", "()Landroid/net/Uri;", &[])?
.l()?;
if uri.is_null() {
return Ok(());
}
let scheme = get_string_method(env, &uri, "getScheme")?;
if scheme.as_deref() != Some(settings::SCHEME) {
return Ok(());
}
match get_string_method(env, &uri, "getHost")?.as_deref() {
Some("session") => handle_session_open(env, activity, &uri),
Some("enroll") => handle_enrollment(env, activity, &uri),
_ => Ok(()),
}
}
fn handle_session_open(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
let Some(session_id) = get_string_method(env, uri, "getLastPathSegment")? else {
return Ok(());
};
toast(env, activity, &format!("Opened session {session_id}"))
}
fn handle_enrollment(env: &mut Env, activity: &JObject, uri: &JObject) -> Result<()> {
match settings::parse_enrollment_uri(env, uri)? {
Some(parsed) => {
settings::save(env, activity, &parsed)?;
notify::sync(env, activity)?;
toast(
env,
activity,
&format!("Enrolled with {}", parsed.base_url()),
)
}
None => toast(env, activity, "Not a valid enrollment code"),
}
}
fn handle_share(env: &mut Env, activity: &JObject, intent: &JObject) -> Result<()> {
let extra_text = crate::shell::jcall::jstr_obj(env, EXTRA_TEXT)?;
let text = crate::shell::jcall::call_method(
env,
intent,
"getStringExtra",
"(Ljava/lang/String;)Ljava/lang/String;",
&[JValue::Object(&extra_text)],
)?
.l()?;
let text = if text.is_null() {
None
} else {
let jstr: JString = env.cast_local::<JString>(text)?;
Some(jstr.try_to_string(env)?)
};
let Some(text) = text.filter(|t| !t.trim().is_empty()) else {
return toast(
env,
activity,
"Nothing to share -- only shared text is supported so far",
);
};
// Network I/O must not run on the calling thread: `handle_intent` is
// called from `onCreate`/`onNewIntent`, both on the main thread, and a
// blocking socket read there is a `NetworkOnMainThreadException`. So
// the actual send happens on a JNI-attached background thread, the
// same shape `notify::try_start`'s follow loop uses; `toast` from that
// thread is safe because `MainActivity.toast` itself hops back to the
// main looper (see that method).
let vm = env.get_java_vm()?;
let activity_ref = env.new_global_ref(activity)?;
std::thread::spawn(move || {
let _: jni::errors::Result<()> = vm.attach_current_thread(|env| {
share_in_background(env, &activity_ref, text);
Ok(())
});
});
Ok(())
}
fn share_in_background(env: &mut Env, activity: &JObject, text: String) {
let outcome = attach_to_a_session(env, activity, &text);
let message = match outcome {
Ok(title) => format!("Shared into \"{title}\""),
Err(message) => message,
};
let _ = toast(env, activity, &message);
}
fn attach_to_a_session(
env: &mut Env,
activity: &JObject,
text: &str,
) -> std::result::Result<String, String> {
let settings = settings::load(env, activity)
.map_err(|e| e.to_string())?
.ok_or_else(|| "Not enrolled yet".to_string())?;
let ca = settings::load_pinned_ca(env).map_err(|e| e.to_string())?;
let transport = UreqTransport::new(settings.base_url(), settings.token.clone(), &ca)
.map_err(|e| e.to_string())?;
let client = ApiClient::new(transport);
let sessions = client.fetch_sessions().map_err(|e| e.to_string())?;
let target = sessions
.into_iter()
.max_by(|a, b| a.last_activity.total_cmp(&b.last_activity))
.ok_or_else(|| "No session to share into".to_string())?;
client
.send_message(&target.id, text, &[])
.map_err(|e| e.to_string())?;
Ok(target.title)
}
+1 -4
View File
@@ -1,10 +1,7 @@
android-project/.gradle/
android-project/build/
android-project/app/build/
# Rebuilt by `cargo ndk -o app/src/main/jniLibs/ build` before every
# Gradle build -- see RUST.md's I2 for the exact command.
# Rebuilt by build-apk.sh before every Gradle build.
android-project/app/src/main/jniLibs/
target/
Cargo.lock.orig
-1
View File
@@ -175,7 +175,6 @@ dependencies = [
"base64",
"event-model",
"iris",
"jni 0.22.4",
"libc",
"log",
"pulldown-cmark",
+1 -3
View File
@@ -32,10 +32,9 @@ pulldown-cmark = "0.13.4"
base64 = "0.23"
log = { version = "0.4.34", features = ["std"] }
# Optional so the Compose shell does not link the renderer.
# UI dependencies stay optional so client-only tests do not link the renderer.
iris = { path = "../iris", optional = true }
tabs-ui = { path = "../iris/tabs-ui", optional = true }
jni = { version = "0.22", optional = true }
libc = { version = "0.2.189", optional = true }
tokio = { version = "1.53.1", features = ["rt", "time"], optional = true }
@@ -55,7 +54,6 @@ fixture = ["screens"]
transcript-screen = ["screens"]
tabs-screen = ["screens", "dep:tabs-ui"]
bench = ["transcript-screen", "fixture", "dep:libc", "dep:tokio"]
shell = ["dep:jni"]
force-gles = ["screens", "iris/force-gles"]
[dev-dependencies]
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Android SDK environment for this app's Gradle build: locates the SDK and
# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
# Rust/NDK-specific belongs here.
#
# Source this directly for one-off commands instead of going through the
# full run-android.sh (which also creates/boots the emulator, builds,
# installs, and launches):
#
# . ./android-env.sh
# ./gradlew :androidApp:assembleDebug
# adb devices
#
# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
# file is meant to be sourced into whatever shell is already running --
# including a long-lived one a session reuses for unrelated commands -- and
# changing that shell's error-handling options as a side effect of sourcing
# would be surprising. run-android.sh, which does want strict mode, sets its
# own `set -eu` before sourcing this.
# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
# silently follow whatever that happens to be set to elsewhere -- e.g. this
# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
# root-owned install this user can't write to. Everything needed lives under
# the path below instead, matching Android Studio's own default SDK location
# convention on Linux.
SDK_ROOT="$HOME/Android/Sdk"
ANDROID_HOME="$SDK_ROOT"
ANDROID_SDK_ROOT="$SDK_ROOT"
# ~/.local/bin is where the `android` CLI itself installs to (see its own
# installer); adding it here too means sourcing this script guarantees a
# working `android` command even in a shell that hasn't picked up
# ~/.profile yet.
PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
# binary (lookup at start time) are guaranteed to agree on where the AVD
# lives -- left to their own defaults they can resolve different locations
# and disagree on whether it exists.
ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
mkdir -p "$ANDROID_AVD_HOME"
export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
# installs anything missing rather than just probing for it -- still
# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
# script sourcing this under `set -e`.
#
# build-tools is needed twice over: by Gradle for this app's own build, and
# by ../server at runtime for `aapt2` (reading a discovered APK's package
# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
"platforms/android-37.0" "build-tools/37.0.0" \
"system-images/android-36/google_apis/x86_64" \
|| echo " (non-fatal: see above)"
+63
View File
@@ -0,0 +1,63 @@
plugins {
id("com.android.application")
}
// build-apk.sh places the Rust cdylib in src/main/jniLibs before Gradle runs.
def benchBuild = System.getenv("AI_APP_BENCH") == "1"
android {
namespace = "dev.iris.android.demo"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp"
// 29, not 26: `iris::android::view`'s touch handler dates each
// sample with `MotionEvent.getEventTimeNanos` and
// `getHistoricalEventTimeNanos`, both API 29, and a missing JNI
// method there is a hard crash on the first touch rather than a
// degraded fling. Raised deliberately rather than guarded at
// runtime: nothing this app is built for runs below 29, and an
// untested fallback path is its own defect. `build-apk.sh`'s
// `cargo ndk -P` is kept at the same number.
minSdk = 29
// targetSdk 35+ supplies real IME overlap under enforced edge-to-edge.
targetSdk = 37
versionCode = 1
versionName = "1.0"
manifestPlaceholders = [appLabel: benchBuild ? "AI Sessions bench" : "AI Sessions"]
}
// The signing key is machine-local; build-apk.sh creates and supplies it.
def keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
release {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
debug {
if (benchBuild) {
applicationIdSuffix ".bench"
}
}
release {
if (benchBuild) {
applicationIdSuffix ".bench"
}
if (keystore != null) {
signingConfig = signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The transcript client talks to ai-server. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="${appLabel}"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment links minted by ai-server and Dev Updater. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<meta-data android:name="android.app.lib_name" android:value="ai_app" />
</activity>
<!-- Read-only recent logs for Dev Updater. The authority follows
applicationId so normal and benchmark builds stay separate. -->
<provider
android:name=".DevLogProvider"
android:authorities="${applicationId}.devlog"
android:exported="true"
android:readPermission="dev.updater.permission.READ_DEVLOG" />
</application>
</manifest>
File renamed without changes.
File renamed without changes.
-226
View File
@@ -1,226 +0,0 @@
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ktfmt)
}
// Formatting is the formatter's. The one setting is which of ktfmt's two
// styles: kotlinlang is the 4-space one, which is what this code already
// is -- picking the 2-space default would have reindented every file to
// say nothing. Everything else stays at ktfmt's defaults, deliberately.
//
// ./gradlew :androidApp:ktfmtFormat to apply
// ./gradlew :androidApp:ktfmtCheck to verify
ktfmt { kotlinLangStyle() }
// The CA this app pins is baked in at build time from the certificates on
// the machine doing the build -- `$XDG_CONFIG_HOME/ai-app/certs/ca.pem`,
// which the server generates on first start. AI_APP_CA overrides the path.
//
// Reading it rather than keeping a pasted copy in the source is what makes
// the trust boundary follow the build: an APK built on the backend host
// pins the host's CA and never sees any other, while one built in the dev
// VM pins that VM's throwaway CA and is only good for its emulator. There
// is no second trust anchor to get wrong, and no stale paste to notice
// three days later. It also means the private key never has to exist
// anywhere near this repo.
val pinnedCaPath: String =
System.getenv("AI_APP_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/ai-app/certs/ca.pem"
abstract class GeneratePinnedCert : DefaultTask() {
/** Where the certificate is looked for, reported in failures. */
@get:Input abstract val caPath: Property<String>
/**
* 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>("generatePinnedCert") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
android {
namespace = "com.example.aiapp"
compileSdk = 37
defaultConfig {
applicationId = "com.example.aiapp"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
// Read by MainActivity to decide, at startup, whether this is the P0 benchmark build
// (docs/RUST.md's P0 box) rather than the app somebody enrolled. False everywhere except
// the `bench` build type below, which overrides it.
buildConfigField("boolean", "FIXTURE_MODE", "false")
}
buildFeatures {
// Only for FIXTURE_MODE above; nothing else here reaches for generated BuildConfig fields.
buildConfig = true
// Only for the bench build type's resValue("string", "app_name", ...) below.
resValues = true
}
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
// The one native library here is AndroidX's, a few hundred kilobytes with its symbols.
// Stripping them needs an NDK the release build would otherwise not use; keeping them
// is declared so AGP stops warning that it could not.
jniLibs { keepDebugSymbols += "**/libandroidx.graphics.path.so" }
}
// A release build must be signed, and the key is per machine rather than per repo: it is
// what the phone recognises the app by, and a secret never lives in a checkout (the mount is
// shared with an untrusted VM). build-apk.sh keeps it beside the pinned CA and points here
// through the environment; without it the release build is unsigned, which is fine for
// everything except installing.
val keystore = System.getenv("AI_APP_KEYSTORE")
signingConfigs {
if (keystore != null) {
create("release") {
storeFile = file(keystore)
storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD")
keyAlias = "ai-app"
keyPassword = storePassword
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
// P0's benchmark build (docs/RUST.md, docs/DECISIONS.md's 2026-09-05 entry): release
// optimisations so a frame time measured here means what release means everywhere else in
// this project, its own application id so it installs beside a real enrollment rather than
// replacing it, and FIXTURE_MODE so MainActivity opens straight onto the fixture session
// instead of asking to be enrolled. Signed with the same key as release -- it never talks
// to a real backend, so there is no CA of its own to mismatch, and a second keystore would
// be one more secret to keep off this machine's shared mount for no benefit.
create("bench") {
initWith(getByName("release"))
// :link (wg-app-link) has no "bench" build type of its own -- it is a library shared
// with dev-updater and has no reason to know this project invented one -- so this says
// which of its build types to link against instead.
matchingFallbacks += listOf("release")
applicationIdSuffix = ".bench"
// "AI Sessions bench" everywhere the OS shows the app's name (launcher, recents,
// Settings): this resValue overrides res/values/strings.xml's app_name for this
// build type alone, and AndroidManifest.xml's android:label reads @string/app_name
// rather than a literal so a build type can override it without touching the
// manifest.
resValue("string", "app_name", "AI Sessions bench")
buildConfigField("boolean", "FIXTURE_MODE", "true")
if (keystore != null) signingConfig = signingConfigs.getByName("release")
}
}
sourceSets {
// The fixture both bench builds (this one and iris's) open with; see
// app/bench-fixture/README.md. Read directly from its own directory rather than copied
// into androidApp/src -- one file to keep in sync with the generator, not two.
getByName("bench").assets.directories.add("../bench-fixture/assets")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
// minSdk is 24 and UsageScreen formats its countdown with
// java.time, which the platform only has from 26. Without this it
// is a NoClassDefFoundError on 24 and 25 -- an Error, so the
// catch around that code does not stop it.
isCoreLibraryDesugaringEnabled = true
}
}
// AGP 9 wants generated sources registered through the variant API rather
// than added to a source set, so the task dependency is carried properly.
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(
generatePinnedCert,
GeneratePinnedCert::outputDir,
)
}
}
dependencies {
// The link both this app and Dev Updater's need in order to reach a
// machine they were enrolled against: the pinned CA, the enrollment
// store, and the QR capture activity. See wg-app-link's README.
implementation(project(":link"))
// Not a library this code calls: it is what `isCoreLibraryDesugaring
// Enabled` above rewrites java.time against, so API 24 and 25 have it.
coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.compose.runtime)
implementation(libs.compose.runtime.tracing)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.zxing.embedded)
implementation(libs.markdown.renderer)
implementation(libs.androidx.exifinterface)
// The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what
// lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are
// `kotlin.test`, so the tests name no framework; JUnit is what runs them.
testImplementation(libs.kotlin.test.junit5)
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.platform.launcher)
}
// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task.
tasks.withType<Test>().configureEach { useJUnitPlatform() }
-123
View File
@@ -1,123 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
targeting 37+ needs this runtime permission to reach *any* local
network address, including a plain socket to a LAN IP literal.
Without it the traffic is silently dropped, surfacing only as a
connect timeout. See MainActivity.kt's runtime request, and
dev-updater's manifest for the full story. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Telling somebody a session wants them. POST_NOTIFICATIONS is a
runtime permission from Android 13; the foreground-service pair
below is what lets the connection outlive the app being closed,
which is the entire point (see Notifications.kt). -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!-- tools:ignore MissingApplicationIcon: there is no icon yet, and
that is a decision rather than an oversight. An app with no icon
of its own is obvious to anyone who opens a launcher, so the
warning tells nobody here anything they cannot already see, and
the fix is a judgement about how this app should look. Drop this
suppression when a real icon lands. -->
<application
android:label="@string/app_name"
android:allowBackup="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
tools:ignore="MissingApplicationIcon">
<!-- Lets the phone's own System Tracing see this app's trace sections
(Compose's phases, and the composable names runtime-tracing
adds) in a release build, so a frame cost measured on the real
device can be attributed. shell="true" limits it to profilers
run from the shell; it grants nothing to other apps. -->
<profileable android:shell="true" tools:targetApi="q" />
<!-- adjustResize (not the system's default pan): the layout handles
the keyboard itself via imePadding(), so the window must resize
rather than slide the top bar off screen.
stateUnchanged: coming back to the app leaves the keyboard as it
was left. The default, stateUnspecified, lets the system decide,
and what it decides with a focused message field is to open the
keyboard, so switching away and back covered half the transcript
somebody had switched away to compare against. Unchanged rather
than hidden, because a keyboard that was up when the app was left
is one somebody was in the middle of typing into. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize|stateUnchanged"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Enrollment: the server prints its aiapp://enroll QR to the
terminal. This intent filter is the fallback path for a
camera app that redirects a scanned aiapp:// URI here
directly; the Settings screen's own "Scan QR code" button
(zxing-android-embedded) is the primary path and needs no
filter, since it decodes the QR itself and hands the URI
to parseEnrollmentUri in-process. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="aiapp" android:host="enroll" />
</intent-filter>
<!-- The share sheet: a file, a photo or some text from another app
lands here and is attached to a session (see Share.kt). Any
type, because what a session can be handed is the server's
decision rather than the sheet's. -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- specialUse rather than dataSync, which is the type this looks
like: Android 15 caps dataSync at six hours a day, and a
connection that stops listening after six hours is one that
misses the overnight run it exists for. The subtype below is
the reason string that type requires. -->
<service
android:name=".NotificationService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Holds one connection to the user's own backend so a session
that needs an answer can be reported while the app is closed. There is no
push service: the backend is reachable only over the user's WireGuard
tunnel and never talks to a third party." />
</service>
<!-- The scanner behind Settings' "Scan QR code". Declared here so
it can drop the library CaptureActivity's landscape pin: the
code being scanned is usually on a monitor in front of someone
holding the phone upright. zxing_CaptureTheme is the library's
own fullscreen theme, which is all the activity needs. -->
<!-- tools:ignore DiscouragedApi: lint flags every fixed
screenOrientation, because Android 16 ignores most of them.
This one is not a pin but its removal. fullSensor is what
drops the library's landscape lock, so the activity follows
the phone rather than asking anyone to turn it, and where the
platform ignores the attribute the behaviour is the one this
asked for anyway. Scoped to this activity, so a genuine pin
elsewhere would still be reported. -->
<activity
android:name="com.example.wgapplink.EnrollmentScanActivity"
android:clearTaskOnLaunch="true"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden"
tools:ignore="DiscouragedApi" />
</application>
</manifest>
@@ -1,311 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
/**
* The sixteen colours a terminal program names, and the two it assumes.
*
* Its own palette rather than the syntax one: a program that prints in red has chosen red, where a
* highlighter's colours are this app's reading of somebody else's code. They come out of the same
* Catppuccin values so nothing on screen is a colour from somewhere else, but the two are not one
* table -- adding a syntax role to this list would silently move `ls`'s directory blue.
*/
data class AnsiPalette(
/** Indexes 0-7, then 8-15 bright, in the terminal's own order. */
val colours: List<Color>,
/** What uncoloured text is, needed only where a style has to state a colour. */
val foreground: Color,
/** What the text sits on, needed for reverse video. */
val background: Color,
)
/**
* What a tool printed, with its terminal styling applied and everything else taken out.
*
* Bash output arrives exactly as the program wrote it, escape sequences included, and drawn
* verbatim those are line noise in the middle of the thing being read. Stripping them all would be
* the other half-answer -- colour is often the whole of what a diff or a test run is saying.
*
* So the sequences that decide how text *looks* become spans, and every other one is dropped rather
* than shown: the rest move a cursor around a grid this is not, and "go to column 40" has no
* meaning in a scrolling document.
*
* A carriage return is honoured the way a terminal honours it: what was written since the last line
* break is thrown away and the line starts again. That is what makes a progress bar show its final
* state rather than every state it passed through.
*
* Not a composable, and the palette is a parameter, so this can be remembered against the text it
* parsed rather than re-run on every recomposition of the card holding it.
*/
fun ansiStyled(text: String, palette: AnsiPalette): AnnotatedString {
// The common case by a long way -- nothing to do, and nothing allocated to find that out.
if (text.indexOf(ESC) < 0 && text.indexOf('\r') < 0) return AnnotatedString(text)
val runs = mutableListOf<Run>()
var sgr = Sgr.PLAIN
var at = 0
val plain = StringBuilder()
fun flush() {
if (plain.isNotEmpty()) {
runs.add(Run(plain.toString(), sgr.span(palette)))
plain.clear()
}
}
while (at < text.length) {
val c = text[at]
when {
c == ESC -> {
flush()
at =
skipEscape(text, at) { params, final ->
if (final == 'm') sgr = sgr.apply(params, palette)
}
}
// A bare carriage return rewrites the line. One before a newline is the other half of a
// Windows line ending: it rewrites nothing, and it is dropped rather than kept, since
// that pair is one line break.
c == '\r' && text.getOrNull(at + 1) != '\n' -> {
flush()
dropLine(runs)
at++
}
c == '\r' -> at++
// Everything printable, plus the two control characters that are layout rather than
// terminal commands. A stray bell or backspace goes for the same reason a cursor move
// does.
c >= ' ' || c == '\n' || c == '\t' -> {
plain.append(c)
at++
}
else -> at++
}
}
flush()
return buildAnnotatedString {
runs.forEach { run ->
if (run.style == null) {
append(run.text)
} else {
val pushed = pushStyle(run.style)
append(run.text)
pop(pushed)
}
}
}
}
/** One stretch of text that shares a style. */
private class Run(val text: String, val style: SpanStyle?)
/** Throws away everything written since the last line break, as a carriage return does. */
private fun dropLine(runs: MutableList<Run>) {
while (runs.isNotEmpty()) {
val last = runs.removeAt(runs.size - 1)
val breakAt = last.text.lastIndexOf('\n')
if (breakAt >= 0) {
runs.add(Run(last.text.substring(0, breakAt + 1), last.style))
return
}
}
}
private const val ESC = '\u001B'
private const val BELL = '\u0007'
/**
* Steps over the escape sequence starting at [at], reporting a CSI's parameters and final byte.
*
* One reader for every kind, because the point is to *leave* them all behind: a sequence this did
* not recognise would otherwise have its body printed as ordinary text. Three shapes -- the CSI
* (`ESC [ … letter`), the string escapes which run to a terminator, and the two-character ones.
*/
private inline fun skipEscape(text: String, at: Int, onCsi: (String, Char) -> Unit): Int {
val next = text.getOrNull(at + 1) ?: return at + 1
return when (next) {
'[' -> {
var end = at + 2
while (end < text.length && text[end] !in CSI_FINAL) end++
if (end >= text.length) {
// Cut off mid-sequence, which is what a stream that has not finished arriving looks
// like: drop the fragment rather than printing it, and the whole sequence arrives
// with the next delta.
text.length
} else {
onCsi(text.substring(at + 2, end), text[end])
end + 1
}
}
']',
'P',
'X',
'^',
'_' -> {
// Runs to a string terminator: `ESC \`, or the bell that xterm allows after an OSC.
var end = at + 2
while (end < text.length) {
if (text[end] == BELL) return end + 1
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') return end + 2
end++
}
text.length
}
else -> at + 2
}
}
/** The bytes that end a CSI sequence. */
private val CSI_FINAL = '@'..'~'
/** Everything an SGR sequence can turn on, as the terminal tracks it. */
private data class Sgr(
val fg: Color?,
val bg: Color?,
val bold: Boolean,
val dim: Boolean,
val italic: Boolean,
val underline: Boolean,
val strike: Boolean,
val reverse: Boolean,
) {
/** Null while nothing is set, so unstyled output costs no spans at all. */
fun span(palette: AnsiPalette): SpanStyle? {
if (this == PLAIN) return null
val front = if (reverse) bg ?: palette.background else fg
val back = if (reverse) fg ?: palette.foreground else bg
// Dim has to have a colour to dim, so where none was named it dims the ordinary one.
val stated = front ?: palette.foreground.takeIf { dim }
return SpanStyle(
color =
stated?.let { if (dim) it.copy(alpha = DIM_ALPHA) else it } ?: Color.Unspecified,
background = back ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else null,
fontStyle = if (italic) FontStyle.Italic else null,
textDecoration =
when {
underline && strike ->
TextDecoration.combine(
listOf(TextDecoration.Underline, TextDecoration.LineThrough)
)
underline -> TextDecoration.Underline
strike -> TextDecoration.LineThrough
else -> null
},
)
}
/**
* This state with [params] applied -- one `ESC[…m`, which carries any number of them.
*
* A code this does not model is ignored rather than reset from: the program meant something by
* it, and starting again would also drop the codes beside it that are understood.
*/
fun apply(params: String, palette: AnsiPalette): Sgr {
// `ESC[m` means `ESC[0m`, and an empty parameter inside a list is a zero too.
val codes = params.split(';').map { it.trim().toIntOrNull() ?: 0 }
var state = this
var at = 0
while (at < codes.size) {
val code = codes[at]
state =
when (code) {
0 -> PLAIN
1 -> state.copy(bold = true)
2 -> state.copy(dim = true)
3 -> state.copy(italic = true)
4 -> state.copy(underline = true)
7 -> state.copy(reverse = true)
9 -> state.copy(strike = true)
21,
22 -> state.copy(bold = false, dim = false)
23 -> state.copy(italic = false)
24 -> state.copy(underline = false)
27 -> state.copy(reverse = false)
29 -> state.copy(strike = false)
in 30..37 -> state.copy(fg = palette.colours[code - 30])
in 90..97 -> state.copy(fg = palette.colours[code - 90 + 8])
in 40..47 -> state.copy(bg = palette.colours[code - 40])
in 100..107 -> state.copy(bg = palette.colours[code - 100 + 8])
39 -> state.copy(fg = null)
49 -> state.copy(bg = null)
38,
48 -> {
val (colour, last) = extendedColour(codes, at, palette)
at = last
if (code == 38) state.copy(fg = colour) else state.copy(bg = colour)
}
else -> state
}
at++
}
return state
}
companion object {
val PLAIN =
Sgr(
fg = null,
bg = null,
bold = false,
dim = false,
italic = false,
underline = false,
strike = false,
reverse = false,
)
}
}
/** How much of its colour dim text keeps: enough to read, little enough to recede. */
private const val DIM_ALPHA = 0.65f
/**
* The colour named by a `38`/`48` at [at], and the index of that colour's last parameter.
*
* Two forms: `5;n` for the 256-colour table and `2;r;g;b` for a literal one. The first sixteen of
* that table are the palette's own, so a program asking for "colour 1" through either spelling gets
* the same red.
*/
private fun extendedColour(codes: List<Int>, at: Int, palette: AnsiPalette): Pair<Color?, Int> =
when (codes.getOrNull(at + 1)) {
5 -> {
val n = codes.getOrNull(at + 2)
if (n == null) null to at + 1 else indexedColour(n, palette) to at + 2
}
2 -> {
val r = codes.getOrNull(at + 2)
val g = codes.getOrNull(at + 3)
val b = codes.getOrNull(at + 4)
if (r == null || g == null || b == null) null to at + 1
else Color(r.coerceIn(0, 255), g.coerceIn(0, 255), b.coerceIn(0, 255)) to at + 4
}
else -> null to at + 1
}
/** One of the 256 colours: the palette's sixteen, then a 6x6x6 cube, then a grey ramp. */
private fun indexedColour(n: Int, palette: AnsiPalette): Color =
when {
n < 0 -> palette.foreground
n < 16 -> palette.colours[n]
n < 232 -> {
val i = n - 16
Color(CUBE[i / 36], CUBE[i / 6 % 6], CUBE[i % 6])
}
n < 256 -> {
val grey = 8 + (n - 232) * 10
Color(grey, grey, grey)
}
else -> palette.foreground
}
/** The six levels of each channel in the 256-colour cube, as xterm defines them. */
private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255)
File diff suppressed because it is too large. Load diff
@@ -1,297 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.example.wgapplink.localNetworkAllowed
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One `when` rather than a navigation library: a handful of screens, with [Screen.Main] as the root
* and the back button the only other way between them.
*
* Import, models and setups are tabs inside [MainScreen] -- four views of the same backend, none of
* them a step down from another -- and what is left here is only what genuinely is a step down: one
* session, spawning one, and settings.
*/
private sealed class Screen {
/**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* docs/SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/**
* One session, with the file explorer over it when [files] is set.
*
* The explorer is a layer on this screen rather than a screen of its own, so the session under
* it stays composed: its event stream keeps flowing, its scroll position and draft stay put,
* and coming back from a file costs nothing. As a sibling `Screen` it would be disposed and re-
* created on every return, refetching the transcript over the tunnel.
*/
data class Session(val summary: SessionSummary, val files: FilesTarget? = null) : Screen()
data object Spawn : Screen()
data object Settings : Screen()
}
/**
* A session a notification tap asked to open, before it is a screen.
*
* The notification names an id and nothing else, so opening it means fetching the session first.
* [serial] tells two taps on the same session's notification apart, since they are two requests and
* would otherwise compare equal.
*/
data class SessionOpenRequest(val sessionId: String, val serial: Int)
/** A tap that could not be turned into a screen, kept with its request so Try again knows what. */
private data class FailedOpen(val request: SessionOpenRequest, val message: String)
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent (see MainActivity), re-
* reading the stored settings -- a plain `remember` would keep serving the pre-enrollment null.
*
* [openRequest] is the session a notification tap asked for, likewise from MainActivity.
*
* [shareRequest] is what another app shared in, likewise. It is held here until a session takes it,
* because the share arrives before anyone has said which session it is for.
*/
@Composable
fun AppRoot(
settingsVersion: Int,
openRequest: SessionOpenRequest?,
shareRequest: ShareRequest? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(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<FailedOpen?>(null) }
// Bumped whenever another screen changes something the list shows, so returning to it
// refetches.
var reloadToken by remember { mutableIntStateOf(0) }
// Cleared by the session screen that attached it, not when a newer request arrives: a share
// must be attached exactly once, and only the screen that did it knows that it has.
var share by remember { mutableStateOf<ShareRequest?>(null) }
LaunchedEffect(shareRequest) {
if (shareRequest != null) {
share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main()
}
}
// A standing condition rather than a per-request failure, so it is stated once here instead of
// appended to every error it might cause. Without this the app is simply unreachable and every
// screen blames the server or the tunnel for it.
if (!localNetworkAllowed(context)) {
Text(
"This app is not allowed to reach local network addresses, so it cannot " +
"connect to the backend at all. Grant \"local network\" in Android's app " +
"settings; until then every screen here will look like the server is down.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
}
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR path lands in MainActivity
// and recomposes from the top.
Box(Modifier.imePadding()) {
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.Main()
},
onBack = null,
)
}
return
}
// The one way back, whichever screen is showing and whether it was reached by the system back
// gesture or a screen's own Back button. Every leaf screen can have changed something the list
// shows, so it always refetches.
val goToMain = {
reloadToken++
screen = Screen.Main()
}
if (screen !is Screen.Main) {
BackHandler(onBack = goToMain)
}
// Turning a notification into the screen it points at. The id has to be resolved to a session
// first, because that is what SessionScreen is given -- and unlike a list row, there is nothing
// here to seed it from.
//
// A failure is reported rather than swallowed: somebody deliberately tapped a notification, so
// an app that opens to the session list with no explanation looks like the tap missed.
val open: suspend (SessionOpenRequest) -> Unit = { request ->
failedOpen = null
try {
val session = withContext(Dispatchers.IO) { fetchSession(current, request.sessionId) }
screen = Screen.Session(session)
} catch (e: ApiException) {
failedOpen = FailedOpen(request, e.message ?: "Unknown error")
}
}
LaunchedEffect(openRequest) { openRequest?.let { open(it) } }
val failed = failedOpen
if (failed != null) {
AlertDialog(
onDismissRequest = { failedOpen = null },
title = { Text("Couldn't open that session") },
text = { Text(failed.message) },
confirmButton = {
TextButton(onClick = { scope.launch { open(failed.request) } }) {
Text("Try again")
}
},
dismissButton = { TextButton(onClick = { failedOpen = null }) { Text("Cancel") } },
)
}
// Every screen but the session takes the keyboard as bottom padding here. The session screen
// deliberately does not: resizing a whole screen on every frame of the keyboard animation is
// the cost that made it lag, so it moves only its composer and transcript.
when (val here = screen) {
is Screen.Main ->
Box(Modifier.imePadding()) {
MainScreen(
settings = current,
reloadToken = reloadToken,
share = share,
onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn },
onImported = { imported ->
reloadToken++
screen = Screen.Session(imported)
},
onSettings = { screen = Screen.Settings },
)
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
}
is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this
// one showing other rows. SessionScreen remembers a transcript, an open stream, a draft
// and a scroll position, and without the key Compose keeps all of it across the change
// and merges two conversations -- which crashes the list on the first duplicate row
// key. Only reachable since a notification can move straight from one session to
// another.
key(here.summary.id) {
// A Box so the explorer can be drawn *over* the session rather than instead of it.
// No imePadding here, for the reason above -- the explorer adds its own.
Box {
SessionScreen(
settings = current,
summary = here.summary,
onBack = goToMain,
onFiles = { screen = here.copy(files = it) },
share = share,
onShareTaken = { share = null },
)
// Its own back handler is registered after this screen's, so it is the one the
// platform asks first, and it steps back inside itself before closing.
here.files?.let { target ->
FilesScreen(
settings = current,
target = target,
onClose = { screen = here.copy(files = null) },
)
}
}
}
is Screen.Spawn ->
Box(Modifier.imePadding()) {
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = goToMain,
)
}
is Screen.Settings ->
Box(Modifier.imePadding()) {
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
goToMain()
},
onBack = goToMain,
)
}
}
// Last, so it draws over the screen above rather than under it: these are stacked in the Box
// the activity puts around this, and that Box paints in the order it was given. A session
// wanting attention is not a fact about the page somebody happens to be on. Tapping one is the
// same act as tapping a notification, so it goes through the same `open`.
SessionAlerts(onOpen = { request -> scope.launch { open(request) } })
}
@@ -1,392 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
/** One question's answer on its way back, so a card can hand over several at once. */
data class QuestionAnswer(val questionId: String, val answers: List<String>)
/**
* What the reader has settled on for one question, before any of it is sent.
*
* Held here rather than inferred from the transcript, which is what made picking an option feel
* broken: the mark used to appear only when the answer had crossed the tunnel and come back as an
* event, so the card sat unchanged for most of a second after a tap.
*
* Picked options and typed words are one field each because they are alternatives rather than
* parts: typing puts the picks away and picking puts the words away, so there is never a draft that
* means two things.
*/
data class Draft(val picked: Set<String> = emptySet(), val other: String = "") {
val settled: Boolean
get() = picked.isNotEmpty() || other.isNotBlank()
/**
* What goes back, in the order the options were offered rather than the order they were tapped:
* the reader is answering a list, and it should read back as that list.
*/
fun answers(options: List<QuestionOption>): List<String> =
if (other.isNotBlank()) listOf(other.trim())
else options.map { it.label }.filter { it in picked }
}
/**
* Every question one tool call is waiting on, one at a time.
*
* All of it comes from the question events themselves. None of it is read out of the call's own
* input, which is one provider's JSON: parsing that here would put that provider's schema in the
* app, where no other provider can reach it and where it drifts the first time the schema moves.
*
* One question on screen with arrows to the others, rather than all of them stacked. A card asking
* three questions with four options and a description each is several screens tall, so the reader
* scrolls past the question they are answering to reach the button that sends it. Paged, each
* question is a screen and the count says how many are left.
*
* Nothing is sent until Submit. Answering is one act even when it is several questions: the tool
* asked them together, and sending each as it was tapped meant the reader could not change their
* mind about the first after reading the third.
*/
@Composable
fun AskUserQuestionBody(
asks: List<TranscriptItem.QuestionCard>,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// Seeded from what was already answered, so a card the reader comes back to shows their answers
// rather than an empty draft over them.
var drafts by
remember(asks.map { it.id }) {
mutableStateOf(
asks.associate { ask ->
ask.id to
Draft(
picked =
ask.answers
.filter { a -> ask.options.any { it.label == a } }
.toSet(),
other =
ask.answers
.firstOrNull { a -> ask.options.none { it.label == a } }
.orEmpty(),
)
}
)
}
var at by remember(asks.map { it.id }) { mutableIntStateOf(0) }
var sending by remember(asks.map { it.id }) { mutableStateOf(false) }
if (asks.isEmpty()) return
val showing = asks[at.coerceIn(0, asks.size - 1)]
val outstanding = asks.filter { it.answers.isEmpty() }
Column(Modifier.fillMaxWidth()) {
if (asks.size > 1) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Question ${at + 1} of ${asks.size}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Disabled at the ends rather than absent, so the pair keeps its place and the
// reader can see there is nothing further that way.
MarkButton("Previous question", { at-- }, enabled = at > 0) {
Chevron(Pointing.Left, colour = LocalContentColor.current)
}
MarkButton("Next question", { at++ }, enabled = at < asks.size - 1) {
Chevron(Pointing.Right, colour = LocalContentColor.current)
}
}
}
Spacer(Modifier.height(4.dp))
AskedQuestion(
showing,
draft = drafts[showing.id] ?: Draft(),
onDraft = { drafts = drafts + (showing.id to it) },
)
if (outstanding.isNotEmpty()) {
Spacer(Modifier.height(12.dp))
// Greyed until every question has an answer, because the tool is waiting on all of
// them: a submit that sent two of three would leave the third asked and the card
// looking dealt with.
val ready = outstanding.all { drafts[it.id]?.settled == true }
Button(
onClick = {
sending = true
onAnswer(
outstanding.map { ask ->
QuestionAnswer(ask.id, (drafts[ask.id] ?: Draft()).answers(ask.options))
}
) {
// Back to a button whatever happened. A refusal is reported by the screen
// around this, and the draft is still here to send again -- a spinner that
// never stops would be the only sign of a failure this card cannot
// describe.
sending = false
}
},
enabled = ready && !sending,
modifier = Modifier.fillMaxWidth(),
) {
if (sending) {
// In the button rather than beside it, so the row does not change height at the
// moment it is pressed.
CircularProgressIndicator(
Modifier.height(18.dp).width(18.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current,
)
} else {
Text(
if (outstanding.size > 1) "Submit ${outstanding.size} answers" else "Submit"
)
}
}
}
}
}
/**
* One question: what is being asked, what can be answered, and what was.
*
* The same body wherever a question appears -- on the call that asked it, or as a card of its own
* when nothing did. Two renderings of it would be two places for an answer to go missing.
*
* [draft] is what the reader has picked so far and [onDraft] is how they change it; nothing here
* sends anything. An answered question ignores both and draws what was answered.
*/
@Composable
fun AskedQuestion(
ask: TranscriptItem.QuestionCard,
draft: Draft,
onDraft: (Draft) -> Unit,
) {
Column(Modifier.fillMaxWidth()) {
ask.header?.let { header ->
// Its own line rather than beside the question, because it is a label *for* the
// question and the question is the thing to read.
Text(
header.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(ask.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
// An answered question keeps its options and marks the one that was taken, rather than
// replacing them with a line repeating it. The options are what the question *was*, and
// dropping them leaves an answer with nothing to have been an answer to -- "Sonnet" says
// very little without the three it was chosen over. Marked in the same purple that says
// "picked" while the question is open, so it is one appearance learned once.
val answered = ask.answers.isNotEmpty()
// What is marked: what was answered once there is an answer, and what the finger has chosen
// until then.
val marked = if (answered) ask.answers.toSet() else draft.picked
// Null once the question is answered: the options stay and stop being pressable.
val onPick: ((String) -> Unit)? =
if (answered) null else { label -> onDraft(pick(draft, label, ask.multiSelect)) }
if (ask.options.all { it.description == null && it.preview == null }) {
// Nothing to read, so nothing to lay out: Allow and Deny are two words, and two words
// do not need a card each.
AnswerOptions(ask.options, marked.toList(), onPick)
} else {
ask.options.forEach { option ->
OptionCard(option, selected = option.label in marked) {
onPick?.invoke(option.label)
}
}
}
// What was answered in the reader's own words, which no option can mark. Only ever the
// answers that match nothing offered, so a question answered by picking says it by the
// mark.
val inWords = ask.answers.filterNot { answer -> ask.options.any { it.label == answer } }
if (inWords.isNotEmpty()) {
Text(
"Answered: ${inWords.joinToString(", ")}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 8.dp),
)
}
if (!answered) {
OtherAnswer(draft.other) { onDraft(Draft(other = it)) }
}
}
}
/**
* [label] added to, or taken out of, what [draft] has picked. A single-answer question replaces
* rather than accumulates, and either way picking puts any typed words away -- see [Draft].
*/
private fun pick(draft: Draft, label: String, multiSelect: Boolean): Draft =
when {
!multiSelect -> Draft(picked = setOf(label))
label in draft.picked -> Draft(picked = draft.picked - label)
else -> Draft(picked = draft.picked + label)
}
/**
* One option: what it is called, what it means, and what it would produce.
*
* Outlined rather than tinted. Drawn first as a card one step up the surface ladder, it was
* indistinguishable from the card behind it -- three paragraphs of text where three things to press
* should have been. A border is one cue and it is unambiguous.
*/
@Composable
private fun OptionCard(option: QuestionOption, selected: Boolean, onPick: () -> Unit) {
OutlinedCard(
onClick = onPick,
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
colors =
CardDefaults.outlinedCardColors(
containerColor =
if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surface
),
// Picked shows in the border as well as the fill, because the fill alone is a colour
// difference somebody has to have seen the unpicked version to notice.
border =
BorderStroke(
if (selected) 2.dp else 1.dp,
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
) {
Column(Modifier.padding(12.dp)) {
Text(option.label, style = MaterialTheme.typography.titleSmall)
option.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
option.preview?.let { Preview(it) }
}
}
}
/**
* An option's worked example, shown as written.
*
* On its own surface, because it is a different kind of thing from the sentence above it: that
* describes the option, this is a sample of what the option produces, and monospace alone reads as
* a description that happens to be in code font.
*/
@Composable
private fun Preview(preview: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text(
preview,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
// Not wrapped: these are mockups and diffs, where a wrapped line reads as two lines of
// the thing being previewed.
softWrap = false,
modifier = Modifier.padding(8.dp).horizontalScroll(rememberScrollState()),
)
}
}
/**
* The choice the asker always leaves open, and the app has to as well.
*
* Every AskUserQuestion carries an implicit "Other" -- the reader may answer in their own words
* rather than pick. Leaving it out narrows a question that was never that narrow.
*/
@Composable
private fun OtherAnswer(text: String, onText: (String) -> Unit) {
// No Send of its own: this is one more way to answer the question, and the card's Submit is
// what sends it. A second send button beside the field made the shorter half of the card look
// like the one that finishes it.
OutlinedTextField(
value = text,
onValueChange = onText,
label = { Text("Other") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
}
/**
* Bare options, wrapped rather than in a row.
*
* A Row hands out intrinsic widths in order and clips whatever runs past the edge, so a question
* with four options showed the first one or two and dropped the rest off the side of the screen.
* That reads as those having been the only choices.
*/
@Composable
fun AnswerOptions(
options: List<QuestionOption>,
/** What is chosen: the answer once there is one, and what the finger has marked until then. */
answers: List<String> = emptyList(),
/** Null once the question is answered -- the buttons stay, and stop being buttons. */
onPick: ((String) -> Unit)?,
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
options.forEach { option ->
val taken = option.label in answers
OutlinedButton(
onClick = { onPick?.invoke(option.label) },
// Disabled rather than removed, so an answered question still shows what it
// offered. Material dims a disabled button's own border and label, which would take
// the mark with it -- both are stated here instead.
enabled = onPick != null,
border =
BorderStroke(
if (taken) 2.dp else 1.dp,
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
),
colors =
ButtonDefaults.outlinedButtonColors(
disabledContentColor =
if (taken) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
),
) {
Text(option.label)
}
}
}
}
@@ -1,65 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* Whether [ref] names an image the server stored as one -- `<hex>.<extension>`, with an extension
* from the list it writes -- rather than a file kept under its own name. Mirrors the server's
* `media` table, which is the one other place the list lives.
*/
fun isImageRef(ref: String): Boolean = ref.substringAfterLast('.', "") in IMAGE_EXTENSIONS
private val IMAGE_EXTENSIONS = setOf("png", "jpg", "gif", "webp")
/**
* The name a file was attached under: the ref less the hex the server put before it. The hex has no
* dash in it, so the first one is the boundary however many the name has.
*/
fun attachmentName(ref: String): String = ref.substringAfter('-', ref)
/**
* One attachment on a sent message, drawn as what it is: an image inline, a file as its name. A
* file is not fetched -- there is nothing on this phone to open a trace with -- so the name is all
* of it.
*/
@Composable
fun Attachment(
settings: ServerSettings,
sessionId: String,
ref: String,
onOpenImage: (String) -> Unit,
) {
if (isImageRef(ref)) SessionImage(settings, sessionId, ref, onOpenImage)
else
FileName(
attachmentName(ref),
Modifier.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 4.dp),
)
}
/**
* A file's name, one line, in the face names are read in. Overlong names lose their middle: a name
* is identified by both ends -- what it is at the front, what kind at the back.
*/
@Composable
fun FileName(name: String, modifier: Modifier = Modifier) {
Text(
name,
modifier = modifier,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
@@ -1,171 +0,0 @@
package com.example.aiapp
import android.content.ContentResolver
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import android.provider.OpenableColumns
import androidx.exifinterface.media.ExifInterface
import java.io.ByteArrayOutputStream
import kotlin.math.max
/**
* Getting a picked photo to a session, at a size the session can actually take.
*
* A phone camera produces twelve megapixels and several megabytes. The Claude API resizes anything
* larger than 1568px on its long edge before looking at it and refuses images past a much higher
* bound outright, so a photo sent straight off the camera roll was uploaded whole over the tunnel
* to be either thrown away or rejected -- which is what "sending an image is broken" was.
*
* Shrunk here rather than on the backend, so the bytes that never mattered are never sent: the
* expensive part on a phone is the upload, not the decode. What the limit *is* comes from the
* server, per session, because that is where a provider's requirements are known.
*/
suspend fun uploadPickedImage(
context: Context,
settings: ServerSettings,
sessionId: String,
uri: Uri,
maxEdge: Int?,
): String {
val (bytes, mime) = readForUpload(context, uri, maxEdge)
return uploadAttachment(settings, sessionId, mime, "image") { it.write(bytes) }
}
/**
* Uploads whatever [uri] names, the way its kind needs. An image goes through [uploadPickedImage]
* and is shrunk; anything else goes whole, under the name the other app or the file chooser gave
* it, because the session is told that name rather than shown the bytes.
*/
suspend fun uploadPicked(
context: Context,
settings: ServerSettings,
sessionId: String,
uri: Uri,
maxEdge: Int?,
): String {
val resolver = context.contentResolver
val mime = resolver.getType(uri)
if (mime != null && mime.startsWith("image/")) {
return uploadPickedImage(context, settings, sessionId, uri, maxEdge)
}
// Opened before the request starts, so a provider that refuses says so here and not from inside
// the connection; then streamed, since a trace is bigger than this process should hold at once.
val source = openSource(resolver, uri)
val name = displayName(resolver, uri)
return uploadAttachment(settings, sessionId, mime ?: "application/octet-stream", name) { out ->
try {
source.use { it.copyTo(out, COPY_BUFFER) }
} catch (e: java.io.IOException) {
// Either side of the copy can fail; the message names the file, which is the part the
// reader can do something about.
throw ApiException("couldn't send $name: ${e.message}", cause = e)
}
}
}
private const val COPY_BUFFER = 64 * 1024
/**
* A stream of [uri], or the refusal as the kind the composer reports beside the message.
*
* A share arrives with whatever access the other app granted, and a provider that refuses says so
* with a `SecurityException`; a file gone between the pick and the read is an `IOException`. Both
* are things the reader can act on.
*/
private fun openSource(resolver: ContentResolver, uri: Uri): java.io.InputStream =
try {
resolver.openInputStream(uri)
?: throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: nothing there")
} catch (e: SecurityException) {
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: no access to it")
} catch (e: java.io.IOException) {
throw ApiException("couldn't read ${uri.lastPathSegment ?: uri}: ${e.message}")
}
/** Everything at [uri]; an image is decoded whole anyway, so it is read whole. */
private fun readAll(resolver: ContentResolver, uri: Uri): ByteArray =
openSource(resolver, uri).use { it.readBytes() }
/**
* The name a document provider shows for [uri]. The last path segment is the fallback because a
* provider's own id for a file is usually a number, which says nothing to the session.
*/
private fun displayName(resolver: ContentResolver, uri: Uri): String {
resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (column >= 0 && cursor.moveToFirst())
cursor.getString(column)?.let {
return it
}
}
return uri.lastPathSegment ?: "file"
}
/**
* The bytes to upload and what they are, scaled down only if they need to be.
*
* An image already inside the limit is uploaded exactly as it came, rather than decoded and re-
* encoded to the same size: a round trip through JPEG loses a little every time. This is also the
* path a provider with no limit always takes.
*/
private fun readForUpload(context: Context, uri: Uri, maxEdge: Int?): Pair<ByteArray, String> {
val resolver = context.contentResolver
val mime = resolver.getType(uri) ?: "image/jpeg"
val original = readAll(resolver, uri)
if (maxEdge == null) return original to mime
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(original, 0, original.size, bounds)
val longest = max(bounds.outWidth, bounds.outHeight)
// outWidth is -1 when the bytes are not an image this device can decode. Sent on untouched:
// this function's job is the size, and refusing something the server might understand is a
// decision it has no business making.
if (longest <= 0 || longest <= maxEdge) return original to mime
// Powers of two first, which is all the decoder can do, and then the exact scale. Decoding the
// full twelve megapixels only to shrink it is how this runs out of memory on the images it most
// needs to handle.
val decode =
BitmapFactory.Options().apply {
inSampleSize = Integer.highestOneBit(max(1, longest / maxEdge))
}
val decoded =
BitmapFactory.decodeByteArray(original, 0, original.size, decode) ?: return original to mime
val scale = maxEdge.toFloat() / max(decoded.width, decoded.height)
val matrix = Matrix()
if (scale < 1f) matrix.postScale(scale, scale)
// The camera writes which way up the picture is into EXIF rather than rotating the pixels, and
// re-encoding drops the tag -- so a portrait photo would arrive at the model on its side.
// Applied to the same matrix as the scale, so it costs no second copy of the bitmap.
matrix.postRotate(exifRotation(original))
val scaled = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
val out = ByteArrayOutputStream()
// JPEG whatever came in: this is a photograph being made smaller, which is what JPEG is for,
// and a PNG of a resampled photo is several times the size for no visible difference.
scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, out)
return out.toByteArray() to "image/jpeg"
}
/** How far to turn the picture so it is the way up it was taken. */
private fun exifRotation(bytes: ByteArray): Float =
try {
when (
ExifInterface(bytes.inputStream())
.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} catch (_: java.io.IOException) {
// No EXIF, or none this can read. Upright is the assumption every image without the tag is
// displayed under anyway.
0f
}
/** High enough that resampling is what the reader notices, not the encoder. */
private const val JPEG_QUALITY = 90
@@ -1,108 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.util.concurrent.CopyOnWriteArrayList
/**
* P0's benchmark gate (see docs/RUST.md and the 2026-09-05 decision): an in-process fake of the
* backend, so the `bench` build type can drive a real session screen -- the real
* [TranscriptSource], the real fold, the real paging -- with no server and no network permission.
*
* Only ever installed when [BuildConfig.FIXTURE_MODE] is true (see [MainActivity]); everything else
* in this build compiles it in but never calls it, since Kotlin has no per-build-type source set
* that both [MainActivity] (which every variant compiles) and this can share without one.
*
* The design: [requestFromServer] and [Sse] talk to `https://$FIXTURE_HOST:$FIXTURE_PORT` through
* ordinary `java.net.URL`, exactly as they would talk to a real server. A
* [java.net.URLStreamHandlerFactory] registered once for the whole process intercepts every
* `https://` connection to that host and answers from this object's in-memory event log instead of
* opening a socket -- see BenchNetwork.kt. Everything above that (TranscriptSource, SessionScreen,
* the fold, uniqueItems) never learns the difference.
*/
object BenchFixture {
const val FIXTURE_HOST = "bench.fixture.invalid"
const val FIXTURE_PORT = 1
/** How many of the fixture's events are the opening backlog; see bench-fixture/README.md. */
private const val BACKLOG_COUNT = 3202
val settings = ServerSettings(FIXTURE_HOST, FIXTURE_PORT, "bench")
/** The session id every bench run opens; nothing else in this build ever mints one. */
const val SESSION_ID = "bench-fixture-session"
/**
* The whole transcript, seq order, growing as [pushLive] is called during the streaming phase.
* Read by both the REST page handler and the SSE handler, so a page requested mid- stream and a
* live frame agree on what has "already happened" -- the same thing a real server's own
* transcript file guarantees.
*/
private val log = CopyOnWriteArrayList<Pair<String, SeqEvent>>()
/** The events not yet appended to [log] -- the streaming phase's own source. */
private var streamTail: List<Pair<String, SeqEvent>> = emptyList()
private val images = mutableMapOf<String, ByteArray>()
@Volatile private var loaded = false
/**
* Parses the bundled fixture once. Safe to call more than once; only the first does anything.
*/
@Synchronized
fun ensureLoaded(context: Context) {
if (loaded) return
val lines =
context.assets.open("transcript.jsonl").bufferedReader().readLines().filter {
it.isNotBlank()
}
val parsed = lines.map { it to parseSeqEvent(it) }
log.addAll(parsed.take(BACKLOG_COUNT))
streamTail = parsed.drop(BACKLOG_COUNT)
for (name in listOf("bench1.png", "bench2.png")) {
images[name] = context.assets.open(name).readBytes()
}
loaded = true
}
/** The events the streaming phase has left to send. */
fun remainingStreamEvents(): Int = streamTail.size
/** Sends the next fixture event onto the live log, as a real SSE frame would arrive. */
fun pushNextLiveEvent(): Boolean {
val next = streamTail.firstOrNull() ?: return false
streamTail = streamTail.drop(1)
log.add(next)
return true
}
/** Undoes [pushNextLiveEvent] and reloads the opening backlog, for running the bench twice. */
@Synchronized
fun resetToBacklog(context: Context) {
loaded = false
log.clear()
ensureLoaded(context)
}
fun fileBytes(name: String): ByteArray? = images[name]
/**
* Raw JSON lines with seq > [after], in order -- what an `/events?after=` connection replays.
*/
fun linesAfter(after: Long): List<String> =
log.filter { it.second.seq > after }.map { it.first }
/**
* One REST page: [fetchTranscript]'s `before`/`limit`/`after`, against the growing log. Ignores
* `coalesce` -- the fixture's own deltas are already split the way a real reply streams, and
* what the benchmark exercises is the fold and the paging, not the server's row-joining, which
* client-core's own port tracks separately (CLIENT_CORE.md).
*/
fun page(before: Long?, limit: Int, after: Long?): List<String> {
val upper = before ?: (log.lastOrNull()?.second?.seq?.plus(1) ?: 1L)
val candidates = log.filter {
it.second.seq < upper && (after == null || it.second.seq > after)
}
return candidates.takeLast(limit).map { it.first }
}
}
@@ -1,181 +0,0 @@
package com.example.aiapp
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.security.Principal
import java.security.cert.Certificate
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLPeerUnverifiedException
import org.json.JSONArray
/**
* Installs the process-wide interception [BenchFixture] needs. Idempotent and safe to call more
* than once; the JDK only allows [URL.setURLStreamHandlerFactory] to be called successfully once
* per process, and a second real call throws -- so this guards it rather than relying on every
* caller to remember.
*
* Scoped to [BenchFixture.FIXTURE_HOST]: any other `https://` URL falls through to the platform's
* ordinary handler, so this only ever changes behaviour for the one host the bench build invents.
*/
@Synchronized
fun installFixtureNetworkOnce() {
if (installed) return
installed = true
URL.setURLStreamHandlerFactory(
URLStreamHandlerFactory { protocol ->
if (protocol != "https") null
else
object : URLStreamHandler() {
override fun openConnection(url: URL): HttpURLConnection =
if (url.host == BenchFixture.FIXTURE_HOST) FixtureConnection(url)
else
// The bench build makes no other https call -- this factory is
// installed only in FIXTURE_MODE (MainActivity) -- so there is
// deliberately no delegate to a platform handler here: once a
// URLStreamHandlerFactory is installed there is no supported way to
// ask the JDK for its own default handler back, and re-entering this
// same factory for the fallback would recurse forever rather than
// reach one.
throw java.io.IOException(
"bench build's fixture network has no route to https host " +
"${url.host} -- only ${BenchFixture.FIXTURE_HOST} is served"
)
}
}
)
}
private var installed = false
/**
* Answers one request against [BenchFixture] instead of opening a socket. Implements just enough of
* [HttpsURLConnection] for [requestFromServer] and [Sse] to work unmodified: both only call
* `connect`/`disconnect`, set a handful of request properties they never need answered, and read
* `responseCode` and `inputStream`.
*/
private class FixtureConnection(url: URL) : HttpsURLConnection(url) {
private var input: InputStream? = null
private var writer: Thread? = null
override fun connect() {
if (input != null) return
input = route(url.path, url.query)
}
override fun disconnect() {
writer?.interrupt()
try {
input?.close()
} catch (_: IOException) {}
}
override fun usingProxy() = false
override fun getResponseCode(): Int {
connect()
return 200
}
override fun getInputStream(): InputStream {
connect()
return input!!
}
override fun getErrorStream(): InputStream? = null
// Nothing here reads any of these; implemented only because HttpsURLConnection declares them
// abstract. A fixture never negotiates real TLS, so each says exactly that rather than
// fabricating a plausible-looking certificate.
override fun getCipherSuite() = "none (bench fixture, no TLS)"
override fun getLocalCertificates(): Array<Certificate>? = null
override fun getServerCertificates(): Array<Certificate> =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getPeerPrincipal(): Principal =
throw SSLPeerUnverifiedException("bench fixture connection presents no certificate")
override fun getLocalPrincipal(): Principal? = null
/**
* [path] is `/sessions/{id}/...`; everything else this build's fixture is asked for is a bug.
*/
private fun route(path: String, query: String?): InputStream {
val params =
(query ?: "")
.split("&")
.filter { it.contains('=') }
.associate {
val (k, v) = it.split("=", limit = 2)
k to java.net.URLDecoder.decode(v, "UTF-8")
}
return when {
path.endsWith("/transcript") -> {
val lines =
BenchFixture.page(
before = params["before"]?.toLongOrNull(),
limit = params["limit"]?.toIntOrNull() ?: 80,
after = params["after"]?.toLongOrNull(),
)
val body = JSONArray(lines.map { org.json.JSONObject(it) })
ByteArrayInputStream(body.toString().toByteArray())
}
path.endsWith("/events") -> openEventsStream(params["after"]?.toLongOrNull() ?: 0L)
path.contains("/files/") -> {
val name = path.substringAfterLast("/files/")
val bytes =
BenchFixture.fileBytes(name)
?: throw IOException("bench fixture has no file named $name")
ByteArrayInputStream(bytes)
}
else -> throw IOException("bench fixture has no route for $path")
}
}
/**
* A live SSE body: [BenchFixture.linesAfter] replayed immediately, then polled every 50ms for
* anything [BenchFixture.pushNextLiveEvent] has added since -- the same shape a real backend's
* backlog-then-follow gives [Sse], just polled instead of woken, which is a fixture's business
* rather than something worth a condition variable for.
*/
private fun openEventsStream(after: Long): InputStream {
val pipeIn = PipedInputStream(1 shl 16)
val pipeOut = PipedOutputStream(pipeIn)
var sent = after
val thread = Thread {
try {
while (!Thread.currentThread().isInterrupted) {
val fresh = BenchFixture.linesAfter(sent)
for (line in fresh) {
pipeOut.write("data: $line\n\n".toByteArray())
pipeOut.flush()
sent = org.json.JSONObject(line).getLong("seq")
}
Thread.sleep(50)
}
} catch (_: InterruptedException) {
// disconnect() -- the ordinary way this ends.
} catch (_: IOException) {
// The reader side (Sse) closed its end.
} finally {
try {
pipeOut.close()
} catch (_: IOException) {}
}
}
.also {
it.isDaemon = true
it.start()
}
writer = thread
return pipeIn
}
}
@@ -1,325 +0,0 @@
package com.example.aiapp
import android.content.Context
import android.os.BatteryManager
import android.os.Process
import android.view.View
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.ui.focus.FocusRequester
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* P0's scripted benchmark, run in-process instead of by a shell script: the phone has no usable
* system tracing (this-machine-android's skill) and no agent can drive it, so the same scroll loop
* and streaming phase `transcript-bench.sh`/`stream-bench.sh` drive over `ui-trace` are reproduced
* here against [LazyListState] and [BenchFixture] directly. Only reachable from the `bench` build
* (see [SessionSettingsDialog]'s `onRunBenchmark`), but compiled into every build for the reason
* [BenchFixture]'s doc comment gives.
*
* **v2 (2026-09-06)**, asked for by Iris because the v1 fling was too gentle to stress-test the
* scroll path and said nothing about typing or the keyboard. Four phases now, each a slice of the
* same [FrameStats] recording ([FrameStats.markPhase]/[FrameStats.phaseLines] -- one recorder, not
* two): **fling** (real `FlingBehavior`, not `animateScrollBy`), **stream** (unchanged from v1),
* **type** (600 fixed characters into the real composer `TextFieldValue`, then deleted), and
* **keyboard** (five show/hide cycles). The exact constants below are also written into
* `docs/RUST.md`'s P0 box, "Benchmark v2 (2026-09-06)", so the iris half implements the identical
* spec -- changing a number here without updating that box makes the two apps measure different
* things while looking like the same benchmark.
*/
object BenchRun {
/** transcript-bench.sh's default: 6 cycles of 4 swipes each, kept as the pre-v2 comparison. */
private const val CYCLES = 6
private const val SWIPE_PX = 900f
private const val SWIPE_MS = 200
private const val SWIPE_PAUSE_MS = 500L
/**
* Fling phase (v2): a real fling through the list's own [FlingBehavior], not `animateScrollBy`
* -- Iris's ask was that it "travel way faster" than the old tween-based swipe, and a tween can
* never exceed the distance it is told to cover in the time it is given, while a real fling
* decays from an initial velocity the way a finger flick does. 12,000 px/s is roughly a hard,
* fast flick on a ~420dp/in device (about 30 dp/ms-equivalent initial speed); chosen well above
* the ~4,500 px/s a moderate `animateScrollBy` swipe implies, so this phase exercises the fast
* end of what the platform's fling decay produces rather than the gentle one v1 measured.
*/
private const val FLING_VELOCITY_PX_S = 12_000f
private const val FLING_COUNT = 8
private const val FLING_SETTLE_CAP_MS = 3_000L
private const val FLING_PAUSE_MS = 300L
/** stream-bench.sh's shape: a real reply arrives as many small deltas, not one big write. */
private const val STREAM_EVENTS_PER_SEC = 20
private const val STREAM_SECONDS = 20
/**
* Type phase (v2): sentences built from long, multisyllabic words so the composer actually
* wraps across lines rather than fitting one, and long enough (600 chars) that the composer's
* own height grows over several frames, pushing the transcript above it upward the same way a
* real long message does. Exactly this string is also in `docs/RUST.md`'s P0 box so the iris
* half types the identical content.
*/
const val TYPE_TEXT =
"Benchmarking this transcript screen requires unusually long, multisyllabic words so " +
"wrapping and reflow are properly exercised: internationalization, " +
"counterproductiveness, disproportionately, incomprehensibility, " +
"deinstitutionalization, uncharacteristically, overenthusiastically, " +
"misunderstanding, straightforwardness, telecommunications, and interdisciplinary " +
"collaboration all push a narrow composer field to wrap across several lines while " +
"the transcript above is pushed upward by the growing keyboard-adjacent box, which " +
"is exactly what a real reader typing a long message sees happening now!!!"
private const val TYPE_CHAR_DELAY_MS = 50L
/**
* Keyboard phase (v2): five show/hide cycles, a second apart, is enough to see whether the
* transition is ever actually observed rather than being a one-off fluke either way.
*/
private const val KEYBOARD_CYCLES = 5
private const val KEYBOARD_SHOW_WAIT_MS = 1_000L
private const val KEYBOARD_HIDE_WAIT_MS = 1_000L
/**
* Scrolls, flings, streams, types and toggles the keyboard, then returns the extra report lines
* P0 asked for (per-phase travel/typing/keyboard counts, plus CPU time, peak RSS, battery
* current) -- [FrameStats] and [DebugStats] are reset first, exactly as `copyRenderReport`
* resets them, so the two accountings cover the same stretch of work.
*/
suspend fun run(
context: Context,
scope: CoroutineScope,
listState: LazyListState,
flingBehavior: FlingBehavior,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
): List<String> {
FrameStats.reset()
DebugStats.reset()
val cpuStartMs = Process.getElapsedCpuTime()
val battery = BatterySampler(context)
// Launched in the caller's scope rather than a fresh coroutineScope{} here, which would
// suspend this function until the sampler job ended -- and it only ends when told to.
val samplerJob = scope.launch {
while (isActive) {
battery.sample()
delay(1000)
}
}
val travel = runFlingPhase(listState, flingBehavior)
val sent = runStreamPhase()
runTypePhase(listState, composerFocus, setComposerText, view)
val keyboard = runKeyboardPhase(context, view)
samplerJob.cancel()
val cpuMs = Process.getElapsedCpuTime() - cpuStartMs
val rssLine = peakRssLine()
val batteryLine = battery.finish()
return listOf(
" fling: $FLING_COUNT flings out + $FLING_COUNT back at" +
" ${FLING_VELOCITY_PX_S.toInt()}px/s, travel $travel",
" scroll: $CYCLES cycles (${CYCLES * 4} swipes, legacy tween), " +
"streamed $sent/${STREAM_EVENTS_PER_SEC * STREAM_SECONDS} fixture events",
" type: ${TYPE_TEXT.length} characters inserted then deleted, one per" +
" ${TYPE_CHAR_DELAY_MS}ms",
keyboard,
" process CPU time over this run: ${cpuMs}ms",
rssLine,
batteryLine,
)
}
/**
* Phase 1: starting pinned at the newest end, [FLING_COUNT] flings away from it (toward older
* messages) through the list's real fling path, then [FLING_COUNT] back. Positive velocity here
* matches this list's existing scroll-offset convention (`TranscriptList`'s `reverseLayout`
* pins index 0 -- the newest item -- at the bottom; a positive scroll offset moves the viewport
* toward higher indices, i.e. away from the newest end and toward older content), the same sign
* the pre-v2 swipe loop below already used for its first two swipes.
*/
private suspend fun runFlingPhase(
listState: LazyListState,
flingBehavior: FlingBehavior,
): String {
FrameStats.markPhase("fling")
listState.scrollToItem(0)
val start = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val outward = position(listState)
repeat(FLING_COUNT) {
listState.scroll { with(flingBehavior) { performFling(-FLING_VELOCITY_PX_S) } }
waitForSettle(listState)
delay(FLING_PAUSE_MS)
}
val back = position(listState)
return "start=$start outward=$outward end=$back"
}
private fun position(listState: LazyListState) =
"idx=${listState.firstVisibleItemIndex}/off=${listState.firstVisibleItemScrollOffset}px"
/** Belt-and-suspenders on top of `performFling` already suspending until its own decay ends. */
private suspend fun waitForSettle(listState: LazyListState) {
val startedAt = System.currentTimeMillis()
while (
listState.isScrollInProgress &&
System.currentTimeMillis() - startedAt < FLING_SETTLE_CAP_MS
) {
delay(16)
}
}
/**
* Phase 2 (unchanged from v1): pinned to the newest end before streaming starts, the way
* stream-bench.sh's "Jump to latest" tap is -- a reply streamed into a list parked further back
* arrives off-screen and the report would show nothing happened.
*/
private suspend fun runStreamPhase(): Int {
FrameStats.markPhase("stream")
var sent = 0
val total = STREAM_EVENTS_PER_SEC * STREAM_SECONDS
while (sent < total && BenchFixture.remainingStreamEvents() > 0) {
BenchFixture.pushNextLiveEvent()
sent++
delay(1000L / STREAM_EVENTS_PER_SEC)
}
// Lets the last few deltas land and draw before the next phase starts.
delay(300)
return sent
}
/**
* Phase 3: focuses the real composer, shows the keyboard if the platform allows it, then types
* [TYPE_TEXT] one character at a time through the same `TextFieldValue` state a real keystroke
* updates, and deletes it the same way -- this is what exercises wrapping and the transcript
* being pushed upward, not a single big write.
*/
private suspend fun runTypePhase(
listState: LazyListState,
composerFocus: FocusRequester,
setComposerText: (String) -> Unit,
view: View,
) {
FrameStats.markPhase("type")
listState.scrollToItem(0)
composerFocus.requestFocus()
showIme(view.context, view)
// Lets focus and the keyboard's opening animation land before typing starts, so the frames
// this phase records are the wrap/reflow it is measuring, not the keyboard opening.
delay(300)
var typed = ""
for (ch in TYPE_TEXT) {
typed += ch
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
delay(200)
while (typed.isNotEmpty()) {
typed = typed.dropLast(1)
setComposerText(typed)
delay(TYPE_CHAR_DELAY_MS)
}
}
/**
* Phase 4: [KEYBOARD_CYCLES] show/hide cycles through the same [WindowInsetsControllerCompat]
* path a real IME toggle goes through, reporting how many of each were actually confirmed by
* [android.view.WindowInsets.isVisible] rather than assumed from having asked -- UI_RULES:
* never present an inferred value as a measured one. If the platform never shows it even once,
* this says so in words rather than reporting a phase with no keyboard in it.
*/
private suspend fun runKeyboardPhase(context: Context, view: View): String {
FrameStats.markPhase("keyboard")
var shown = 0
var hidden = 0
repeat(KEYBOARD_CYCLES) {
showIme(context, view)
delay(KEYBOARD_SHOW_WAIT_MS)
if (imeVisible(view)) shown++
hideIme(context, view)
delay(KEYBOARD_HIDE_WAIT_MS)
if (!imeVisible(view)) hidden++
}
return if (shown == 0) {
" keyboard: could not be shown ($KEYBOARD_CYCLES attempts, 0 confirmed visible)"
} else {
" keyboard: shown $shown/$KEYBOARD_CYCLES, hidden $hidden/$KEYBOARD_CYCLES" +
" (confirmed via isImeVisible)"
}
}
private fun controller(context: Context, view: View): WindowInsetsControllerCompat? {
val window = context.activity()?.window ?: return null
return WindowInsetsControllerCompat(window, view)
}
private fun showIme(context: Context, view: View) {
controller(context, view)?.show(WindowInsetsCompat.Type.ime())
}
private fun hideIme(context: Context, view: View) {
controller(context, view)?.hide(WindowInsetsCompat.Type.ime())
}
private fun imeVisible(view: View): Boolean =
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
/** VmHWM from /proc/self/status: the process's high-water mark, in kB, since it started. */
private fun peakRssLine(): String {
val kb =
try {
File("/proc/self/status")
.readLines()
.firstOrNull { it.startsWith("VmHWM:") }
?.trim()
?.removePrefix("VmHWM:")
?.trim()
?.removeSuffix("kB")
?.trim()
?.toLongOrNull()
} catch (_: Exception) {
null
}
return " peak RSS: " +
(kb?.let { "${it}kB" } ?: "unavailable (/proc/self/status unreadable)")
}
}
/**
* Samples [BatteryManager.BATTERY_PROPERTY_CURRENT_NOW] (microamps) once a second for the length of
* a run. The property returns `Int.MIN_VALUE` on hardware that does not support it -- most
* emulators -- and that is reported as "unavailable" rather than folded into an average with the
* real samples, which would silently understate every number after it. See UI_RULES: never present
* an inferred value as a measured one.
*/
private class BatterySampler(context: Context) {
private val manager = context.getSystemService(BatteryManager::class.java)
private val samples = mutableListOf<Int>()
fun sample() {
val value = manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
if (value != null && value != Int.MIN_VALUE) samples.add(value)
}
fun finish(): String {
if (samples.isEmpty()) return " battery current: unavailable on this device"
val meanUa = samples.sum() / samples.size
return " battery current: mean ${meanUa}µA over ${samples.size} samples" +
" (min ${samples.min()}, max ${samples.max()})"
}
}
@@ -1,51 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
// The composer's row of settings and pickers, and the menus they open. One file because the outline
// and the corner are one appearance: a control shaped like this opens a surface shaped like this.
/**
* A bordered pill: a control that can be seen without being pressed.
*
* The composer's row -- attach, model, permission mode -- was text buttons, which draw nothing at
* all until they are touched. Three bare words under the message field read as a caption about the
* field rather than as three things to press. The outline says "control" without the weight of a
* filled button, which is reserved for the two that act on the session.
*/
@Composable
fun BubbleButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
shape = BubbleShape,
// A text button's padding rather than a filled button's 24dp: these sit three across under
// the message field, and the wider padding is what decides whether the row fits.
contentPadding = ButtonDefaults.TextButtonContentPadding,
modifier = modifier,
) {
content()
}
}
/** Fully round ends, so the control reads as a bubble rather than as a box. */
val BubbleShape: Shape = RoundedCornerShape(percent = 50)
/**
* The corner on a menu one of these opens.
*
* A radius rather than [BubbleShape]'s half-height: a menu is as tall as its options, and rounding
* ends that tall would bow its sides.
*/
val BubbleMenuShape: Shape = RoundedCornerShape(20.dp)
@@ -1,95 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
/**
* An item something is happening to: dimmed, drained of colour, inert, with a spinner and the name
* of the operation over it.
*
* One composable rather than a pattern each list repeats, because "this row is busy" has to look
* the same in the import list and the session list or the appearance becomes a per-screen dialect.
*
* [label] names the operation and `null` means none is running. One parameter rather than a boolean
* beside a string, which can disagree. It is a *word* because a spinner alone cannot say which
* operation this is -- deleting and importing are different in kind.
*
* It does **not** make the row inert; the caller disables its own click handling while it passes a
* label. That was the other way round at first -- an overlay consuming pointer events -- and it
* swallowed the drag along with the tap, so a list could not be scrolled while anything in it was
* busy.
*/
@Composable
fun BusyItem(label: String?, content: @Composable () -> Unit) {
Box {
Box(Modifier.busy(label != null)) { content() }
if (label != null) {
Box(Modifier.matchParentSize(), contentAlignment = Alignment.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.width(8.dp))
// Full strength, over content that is not: the operation is the one thing on
// this row that is still current, and it has to read against a card whose own
// text is still visible behind it.
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
/**
* How an item looks while it is being acted on: darker, and nearly grey.
*
* Both, rather than either alone. Dimming by itself is the same cue as a disabled control, so a
* busy row read as one more thing that could not be tapped. Draining the colour is what says the
* row is *suspended* -- the status word and everything else that means something by its colour stop
* meaning it for as long as the operation runs, which is exactly true.
*
* Not all the way to grey: a row with no colour left is hard to find again in a list.
*/
private fun Modifier.busy(busy: Boolean): Modifier =
if (!busy) this
else
this.graphicsLayer { alpha = 0.5f }
.drawWithContent {
drawIntoCanvas { canvas ->
canvas.saveLayer(
Rect(Offset.Zero, size),
Paint().apply {
colorFilter =
ColorFilter.colorMatrix(
ColorMatrix().apply { setToSaturation(0.2f) }
)
},
)
drawContent()
canvas.restore()
}
}
@@ -1,69 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.unit.dp
/** Which way a [Chevron] points. */
enum class Pointing {
Up,
Down,
Left,
Right,
}
/**
* A chevron, pointing whichever of the four ways is asked for.
*
* Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font
* may simply not have, and the reader who gets an empty box instead is never the one who wrote it.
*
* One composable for all four directions rather than one per axis that differ by which coordinate
* gets the minus sign -- the copies would drift, and the drift would be a bug in exactly one
* direction. The shape is written once in its own coordinates, and [Pointing] is only a table of
* how those map onto the box.
*
* It draws no label of its own, so every caller owes it a `contentDescription`.
*/
@Composable
fun Chevron(
pointing: Pointing,
modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
val sideways = pointing == Pointing.Left || pointing == Pointing.Right
Canvas(
modifier
.width(if (sideways) CHEVRON_DEPTH else CHEVRON_SPAN)
.height(if (sideways) CHEVRON_SPAN else CHEVRON_DEPTH)
) {
val inset = 2.dp.toPx()
val wide = size.width - inset
val tall = size.height - inset
fun at(across: Float, along: Float) =
when (pointing) {
Pointing.Up -> Offset(lerp(inset, wide, across), lerp(tall, inset, along))
Pointing.Down -> Offset(lerp(inset, wide, across), lerp(inset, tall, along))
Pointing.Left -> Offset(lerp(wide, inset, along), lerp(inset, tall, across))
Pointing.Right -> Offset(lerp(inset, wide, along), lerp(inset, tall, across))
}
val stroke = 2.dp.toPx()
drawLine(colour, at(0f, 0f), at(0.5f, 1f), strokeWidth = stroke, cap = StrokeCap.Round)
drawLine(colour, at(0.5f, 1f), at(1f, 0f), strokeWidth = stroke, cap = StrokeCap.Round)
}
}
private fun lerp(from: Float, to: Float, fraction: Float) = from + (to - from) * fraction
/** How far the chevron opens, across the direction it points. */
private val CHEVRON_SPAN = 20.dp
/** How far it reaches in the direction it points. */
private val CHEVRON_DEPTH = 10.dp
@@ -1,220 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.model.State
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
/**
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
* sits on, scrolling sideways rather than wrapping.
*
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
* call's command colours a reply's code the same way, so a `kotlin` fence and the Kotlin a tool
* wrote are the same colours. A fence in a language [scan] has no rules for is plain rather than
* wrongly coloured.
*
* Finding the code is still the library's: which children of the node are the fence markers, the
* language word and the code between them is its knowledge of the parser.
*/
@Composable
fun CodeFence(
content: String,
node: ASTNode,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean = false,
) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies, streaming)
}
/** An indented code block, which is a fence with no language word. */
@Composable
fun CodeBlock(
content: String,
node: ASTNode,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean = false,
) {
val (code, language) = remember(content, node) { fenceContent(content, node) } ?: return
CodeBlockText(code, language, style, replies, streaming)
}
/**
* The code inside a fence or indented block, and the highlighter's language for its info word.
*
* Copied from the library's `MarkdownCodeFence` rather than called: that one is a composable, and
* the whole point here is that [warm] can run this on a background thread and highlight the same
* string the drawing will ask for. Two extractions would be two keys, and the warmed answer would
* be silently missed at every fence.
*
* Null for a fence too short to hold anything -- an unterminated one still arriving.
*/
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
val word =
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
val language = fenceLanguage(word)
if (node.type == MarkdownElementTypes.CODE_BLOCK) {
val start = node.children.firstOrNull()?.startOffset ?: return null
val end = node.children.lastOrNull()?.endOffset ?: return null
return content.substring(start, end).replaceIndent() to language
}
if (node.children.size < 3) return null
val start = node.children[2].startOffset
val fenceCount = if (word != null && node.children.size > 3) 3 else 2
val end = node.children[(node.children.size - 2).coerceAtLeast(fenceCount)].endOffset
return content.substring(start, end).replaceIndent() to language
}
/**
* Plain while [streaming], coloured once the block is finished; see [MarkdownRoot].
*
* The renderer's own block, less what nothing here needs: the same background, corner, padding and
* sideways scroll, without the shadow, the border and the empty pointer handler it also carried.
*/
@Composable
private fun CodeBlockText(
code: String,
language: Language?,
style: TextStyle,
replies: ParsedReplies,
streaming: Boolean,
) {
val colors = LocalMarkdownColors.current
val dimens = LocalMarkdownDimens.current
val padding = LocalMarkdownPadding.current
Box(
Modifier.fillMaxWidth()
.padding(vertical = 8.dp)
.background(colors.codeBackground, RoundedCornerShape(dimens.codeBackgroundCornerSize))
.semantics { isTraversalGroup = true }
) {
BasicText(
// No language while the block is still being written, which is what draws it plain.
replies.highlighted(code, language.takeUnless { streaming }),
style = style,
modifier = Modifier.horizontalScroll(rememberScrollState()).padding(padding.codeBlock),
)
}
}
/**
* The highlighter's language for a fence's info word, or null for one it has no rules for.
*
* The aliases are what people actually write after the backticks: the file extension as often as
* the name. A word not here gets no colour rather than the nearest language's, because a fence
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
* see.
*/
fun fenceLanguage(name: String?): Language? =
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
/**
* The highlighter's language for a *file*, from its name.
*
* The same table [fenceLanguage] reads, deliberately: it already keys on the extensions people
* write after the backticks. One table rather than two, so a language added for fences is a
* language added for files and neither can be the one somebody forgot.
*
* The extension is the part after the *last* dot, which is what makes `build.gradle.kts` Kotlin. A
* leading dot is not one: `.bashrc` has no extension, it has a name that starts with a dot. A name
* with no dot at all -- `Makefile` -- is likewise null, and null is drawn plain.
*/
fun fileLanguage(name: String): Language? {
val dot = name.lastIndexOf('.')
if (dot < 1) return null
return fenceLanguage(name.substring(dot + 1))
}
private val FENCE_LANGUAGES: Map<String, Language> =
mapOf(
"kotlin" to Language.KOTLIN,
"kt" to Language.KOTLIN,
"kts" to Language.KOTLIN,
"rust" to Language.RUST,
"rs" to Language.RUST,
"sh" to Language.SHELL,
"bash" to Language.SHELL,
"shell" to Language.SHELL,
"zsh" to Language.SHELL,
"console" to Language.SHELL,
"python" to Language.PYTHON,
"py" to Language.PYTHON,
"javascript" to Language.JAVASCRIPT,
"js" to Language.JAVASCRIPT,
"jsx" to Language.JAVASCRIPT,
"typescript" to Language.TYPESCRIPT,
"ts" to Language.TYPESCRIPT,
"tsx" to Language.TYPESCRIPT,
"java" to Language.JAVA,
"c" to Language.C,
"h" to Language.C,
"cpp" to Language.CPP,
"c++" to Language.CPP,
"cc" to Language.CPP,
"hpp" to Language.CPP,
"csharp" to Language.CSHARP,
"cs" to Language.CSHARP,
"c#" to Language.CSHARP,
"go" to Language.GO,
"golang" to Language.GO,
"swift" to Language.SWIFT,
"dart" to Language.DART,
"ruby" to Language.RUBY,
"rb" to Language.RUBY,
"php" to Language.PHP,
"perl" to Language.PERL,
"pl" to Language.PERL,
"coffeescript" to Language.COFFEESCRIPT,
"coffee" to Language.COFFEESCRIPT,
"ron" to Language.RON,
"toml" to Language.TOML,
"fish" to Language.FISH,
"json" to Language.JSON,
"markdown" to Language.MARKDOWN,
"md" to Language.MARKDOWN,
)
/**
* Every fence in [parse], as the code and language [highlight] will be asked for. Walks the whole
* tree rather than the top level: a fence inside a list item or a quote is drawn the same way and
* costs the same to lex.
*/
fun fences(parse: State): List<Pair<String, Language?>> {
val success = parse as? State.Success ?: return emptyList()
val out = ArrayList<Pair<String, Language?>>()
fun walk(node: ASTNode) {
if (
node.type == MarkdownElementTypes.CODE_FENCE ||
node.type == MarkdownElementTypes.CODE_BLOCK
) {
fenceContent(success.content, node)?.let { if (it.second != null) out += it }
return
}
node.children.forEach(::walk)
}
walk(success.node)
return out
}
@@ -1,151 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Something a session can be asked to do to itself, rather than something to say to it.
*
* These are the two this app understands, and understanding them is what lets it show them: a
* suggestion while one is being typed, a name in the settings screen that sends one, and a bubble
* that stays up while the session is too busy to run it. Anything else beginning with "/" is passed
* through, because a dialect's own vocabulary grows without this list.
*/
data class SessionCommand(
/** With the slash, as it is typed and as it is sent. */
val name: String,
/** One line, in the suggestion list: what it does, not how. */
val summary: String,
/** What follows the name, named for the reader, or null when nothing does. */
val argument: String?,
) {
/** What to put in the box when this is picked: ready to send, or ready to be finished. */
fun typed(): String = if (argument == null) name else "$name "
}
val SESSION_COMMANDS =
listOf(
SessionCommand(
"/compact",
"Summarise the conversation so far and carry on from the summary",
null,
),
SessionCommand(
"/clear",
"Start fresh: drop the conversation from the session's context, keeping it on screen",
null,
),
SessionCommand("/rename", "Change what this session is called", "name"),
)
/**
* The commands worth offering for what has been typed so far.
*
* Only for a line that starts with a slash and has not yet become a whole command with an argument
* -- once there is something after "/rename ", the reader is writing the name and a list of
* commands underneath it is in the way.
*/
fun suggestedCommands(input: String): List<SessionCommand> {
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<SessionCommand>,
onPick: (SessionCommand) -> Unit,
modifier: Modifier = Modifier,
) {
if (commands.isEmpty()) return
Card(modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
Column(Modifier.padding(vertical = 4.dp)) {
commands.forEach { command ->
Row(
Modifier.fillMaxWidth()
.clickable { onPick(command) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
// The command in the colour commands are, so the suggestion and the bubble
// it becomes are visibly the same thing.
if (command.argument == null) command.name
else "${command.name} <${command.argument}>",
style = MaterialTheme.typography.titleSmall,
color = commandColor,
)
Spacer(Modifier.width(12.dp))
Text(
command.summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/**
* A command, where the reader put it: at their end of the conversation.
*
* Blue rather than the colour of something they said, because they did not say it to the model --
* it is an instruction to the session, and the reply to it is the session changing rather than
* anything appearing here.
*
* [waiting] is a command the session is too busy to run yet, which is a state with a spinner and a
* reason: pressing Compact in the middle of a long turn otherwise does nothing visible for minutes.
*/
@Composable
fun CommandBubble(text: String, waiting: Boolean = false) {
Box(Modifier.fillMaxWidth()) {
Card(
colors = CardDefaults.cardColors(containerColor = commandColor),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Column(Modifier.padding(12.dp)) {
// Stated beside the fill rather than inherited: a semantic colour has to carry its
// own contrast, because the surface under it will not change to rescue it.
Text(text, color = MaterialTheme.colorScheme.inverseOnSurface)
if (waiting) {
Spacer(Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.width(12.dp).height(12.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
Spacer(Modifier.width(6.dp))
Text(
"waiting for this turn to end",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
}
@@ -1,62 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* The mark a compaction leaves in the transcript.
*
* A divider rather than something anybody said: everything above it is out of the session's context
* now, and that is a fact about the conversation, not a turn in it. Drawn by [TranscriptDivider],
* which a clear also uses, so the two marks cannot drift apart.
*
* Blue is [commandColor]: the session acting on itself rather than working on what was asked of it.
*/
@Composable
fun CompactedRow(item: TranscriptItem.CompactedNote, modifier: Modifier = Modifier) {
TranscriptDivider(compactionSummary(item), commandColor, modifier)
}
/**
* What to say about a compaction: the two sizes, and nothing else.
*
* The counts are the whole point -- "a million tokens became ten thousand" is the reader's answer
* to why the wait was worth it. When they were not reported this says only that a compaction
* happened, rather than filling in a plausible number.
*/
fun compactionSummary(item: TranscriptItem.CompactedNote): String {
val pre = item.preTokens
val post = item.postTokens
return if (pre != null && post != null) {
"Compacted • ${tokens(pre)}${tokens(post)} tok"
} else {
"Compacted"
}
}
/**
* A token count as a reader reads one.
*
* Shared with the status row rather than formatted at each: the divider and the row report the same
* quantity about the same moment, and one grouping its thousands while the other did not read as
* two different measurements.
*/
fun tokens(count: Long): String = "%,d".format(count)
/**
* What the working indicator says while a compaction is running.
*
* Elapsed time and nothing else, because elapsed time is all there is: the CLI announces that a
* compaction has begun and then says nothing until it has finished, so any bar or estimate here
* would be this screen's guess wearing a measurement's clothes.
*
* [seconds] is null when this device did not see the compaction start, which is what opening a
* session that is already compacting looks like. That case says only "compacting": a number counted
* from the moment the screen opened would be wrong in the direction that matters.
*/
fun compactingLabel(seconds: Long?): String =
when {
seconds == null -> "compacting"
seconds < 60 -> "compacting ${seconds}s"
else -> "compacting ${seconds / 60}m ${seconds % 60}s"
}
@@ -1,63 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* The last crash, kept so the debug button can hand it over.
*
* The alternative is asking somebody to reproduce a crash with the phone plugged into a computer
* and `logcat` running, which is the one thing nobody has set up at the moment it happens. This
* costs one file write on a process that is already dying, and it turns "it crashes when I open
* that chat" into the frame it crashed in.
*
* Kept until it is read rather than cleared on the next launch: the app restarts before anybody can
* ask about it, so a log that lives for one session is a log that is never read.
*/
private const val CRASH_FILE = "last-crash.txt"
/**
* How much of a stack is kept.
*
* This is pasted into a conversation, so it has a budget like any other output written for a
* reader. The top of a stack is what identifies a crash and the bottom is framework plumbing.
*/
private const val CRASH_LIMIT = 4000
/**
* Records uncaught exceptions, then lets the platform do what it was going to do.
*
* Chained rather than replacing: the default handler is what shows the "app has stopped" dialog and
* ends the process, and an app that swallows that instead sits there in an unknown state.
*/
fun installCrashLog(context: Context) {
val app = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
runCatching { File(app.filesDir, CRASH_FILE).writeText(describe(thread, error)) }
previous?.uncaughtException(thread, error)
}
}
private fun describe(thread: Thread, error: Throwable): String {
val when_ = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date())
val stack = StringWriter().also { error.printStackTrace(PrintWriter(it)) }.toString()
val kept =
if (stack.length <= CRASH_LIMIT) stack
else stack.take(CRASH_LIMIT) + "\n ... ${stack.length - CRASH_LIMIT} more characters"
return "$when_ on thread ${thread.name}\n$kept"
}
/** The last crash, or null if there has not been one since it was last read. */
fun lastCrash(context: Context): String? =
File(context.applicationContext.filesDir, CRASH_FILE).takeIf { it.exists() }?.readText()
/** Forgets the last crash, once somebody has taken a copy of it. */
fun clearCrash(context: Context) {
File(context.applicationContext.filesDir, CRASH_FILE).delete()
}
@@ -1,184 +0,0 @@
package com.example.aiapp
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.core.content.getSystemService
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Counters and timers for the work the transcript does, for the readout behind the debug button.
*
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
* is under the floor of what it can measure. Counts do not have that problem: how many times a row
* was composed, or a reply parsed, is the same number on any machine, and it is the number that
* says whether the work is proportional to what is on screen or to everything ever loaded.
*
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
* already allocate lists and parse markdown, and a counter that is only compiled into the build
* nobody is holding when it is slow is not an instrument.
*/
object DebugStats {
private val counts = ConcurrentHashMap<String, AtomicLong>()
private val nanos = ConcurrentHashMap<String, AtomicLong>()
private val worst = ConcurrentHashMap<String, AtomicLong>()
private fun at(map: ConcurrentHashMap<String, AtomicLong>, 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 <T> 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<String> =
counts.keys.sorted().map { name ->
val n = counts[name]?.get() ?: 0
val total = nanos[name]?.get() ?: 0
if (total == 0L) " $name: $n"
else
" $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," +
" ${ms(worst[name]?.get() ?: 0)}ms worst"
}
/** How long everything named [name] took in total, or zero if it never happened. */
fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0
private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0)
}
/**
* How much of the frame's draw phase is this app's own work, and how much is not.
*
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
* different things is high. The transcript times its own measure, placement and recording, and this
* is the subtraction. What is left over is the framework's per-frame bookkeeping after a layout,
* which grows with how many nodes are alive rather than how many are on screen.
*
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
* are not themselves per-frame, so these are shares of an average frame.
*/
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
if (frames == 0 || drawNanos == 0L) return emptyList()
val measure = DebugStats.nanosOf("measure: the whole transcript")
val place = DebugStats.nanosOf("place: the whole transcript")
// The rows and blocks record *inside* this one, so adding them too would count them twice.
val record = DebugStats.nanosOf("draw: the whole transcript")
val ours = measure + place + record
val rest = (drawNanos - ours).coerceAtLeast(0)
fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames)
return listOf(
" draw phase ${per(drawNanos)}ms per frame, of which:",
" the transcript: ${per(ours)}ms" +
" (measure ${per(measure)}, place ${per(place)}, record ${per(record)})",
" everything else: ${per(rest)}ms" +
" (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})",
)
}
/**
* Everything the debug button copies: what the device is, what the transcript is holding, where the
* frames went, and what the app did to produce them.
*
* Written for somebody to paste into a conversation, so it is plain text with the units on every
* number -- a report whose reader has to ask what the columns mean costs another round trip.
*/
fun debugReport(
device: String,
transcript: List<String>,
frames: List<String>,
accounting: List<String>,
crash: String?,
/**
* P0's benchmark-only measurements (process CPU time, peak RSS, battery current) -- empty on
* every path but [BenchRun.runP0Benchmark], which is the only caller that has them. A section
* heading only appears when there is something to put under it, so an ordinary copy from the
* render-report button reads exactly as it did before this existed.
*/
extra: List<String> = emptyList(),
/**
* Bench v2's per-phase frame accounting ([FrameStats.phaseLines]) --
* fling/stream/type/keyboard, each a slice of the same frames the whole-run sections below
* still cover in full. Empty on every path but the scripted bench run, same reasoning as
* [extra].
*/
phaseFrames: List<String> = emptyList(),
): String = buildString {
appendLine("ai-app render report")
appendLine(device)
appendLine()
// First, because a crash outranks every timing below it and the reader should not have to
// scroll past two screens of counters to find out the app fell over.
if (crash != null) {
appendLine("last crash:")
crash.trimEnd().lines().forEach { appendLine(" $it") }
appendLine()
}
appendLine("transcript:")
transcript.forEach { appendLine(it) }
appendLine()
if (phaseFrames.isNotEmpty()) {
appendLine("per phase:")
phaseFrames.forEach { appendLine(it) }
appendLine()
}
appendLine("frames:")
frames.forEach { appendLine(it) }
appendLine()
if (accounting.isNotEmpty()) {
appendLine("where the draw phase went:")
accounting.forEach { appendLine(it) }
appendLine()
}
appendLine("work since this was last copied:")
val work = DebugStats.lines()
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
if (extra.isNotEmpty()) {
appendLine()
appendLine("bench:")
extra.forEach { appendLine(it) }
}
}
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
fun Context.copyToClipboard(label: String, text: String) {
getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(label, text))
}
@@ -1,90 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* A line across the transcript saying what left the session's context.
*
* Centred between two rules, because it is a divider rather than something anybody said. Two things
* produce one -- a compaction and a clear -- and they are drawn the same way on purpose: to a
* reader scrolling back, both mean "the session no longer has what is above this", and which of the
* two it was is said by the words and the colour.
*
* The rules take [color] too, so the whole divider reads as one mark of one kind.
*/
@Composable
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
HorizontalDivider(Modifier.weight(1f), color = color)
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
HorizontalDivider(Modifier.weight(1f), color = color)
}
}
/**
* The mark a clear leaves.
*
* Red, and no counts: a clear takes the conversation out of what the session is given, and unlike a
* compaction it summarises nothing and measures nothing. Everything above stays on screen and stays
* scrollable -- the reader can see that, which is why this does not say it.
*/
@Composable
fun ClearedRow(modifier: Modifier = Modifier) {
TranscriptDivider("Context cleared", clearedColor, modifier)
}
/**
* The mark running out of quota leaves.
*
* The same red the usage bar takes when a window is spent, because it is the same fact in a second
* place: colour by consequence, so "there is nothing left to spend" is learned once.
*
* A time rather than a countdown. The row is folded once and never re-measured, so a span would go
* stale on screen the moment it was drawn; and this is when the *account* said it would reset,
* which is not a promise about when the session picks back up. A limit the session was told no
* reset time for says nothing about one -- that state has its own words rather than a plausible
* number.
*/
@Composable
fun LimitRow(item: TranscriptItem.LimitNote, modifier: Modifier = Modifier) {
TranscriptDivider(limitSummary(item.resetsAt, ZoneId.systemDefault()), overLimitColor, modifier)
}
/**
* What the row says. Split out so the wording is testable without a screen, since the two states it
* has to keep apart -- a reset time that arrived and one that never did -- are exactly the pair
* that reads the same when it goes wrong.
*
* [zone] is a parameter rather than read here so a test says the same thing wherever it runs.
*/
fun limitSummary(resetsAt: Double?, zone: ZoneId): String {
val at = resetsAt?.let {
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(zone)
.format(Instant.ofEpochSecond(it.toLong()))
} catch (_: Exception) {
null
}
}
return if (at == null) "Usage limit reached" else "Usage limit reached • resets $at"
}
@@ -1,33 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
private const val DRAFTS = "session-drafts"
/**
* A message typed into a session and not sent yet.
*
* On this device rather than on the backend, which is where this app otherwise keeps state so that
* every device sees it. A draft is the case that rule is not about: it is the contents of a text
* box on the phone somebody is holding, and half a sentence surfacing on another device would be a
* surprise. What has been *sent* is the server's.
*
* Kept per session id: one shared box would hand a message meant for one session to whichever was
* opened next.
*/
fun loadDraft(context: Context, sessionId: String): String =
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).getString(sessionId, "").orEmpty()
/**
* Records [text] as the draft for [sessionId], or forgets it when there is nothing left to keep.
*
* The path out is emptying the box, which is what sending does. A session *deleted* while it held a
* draft does leave its key behind: pruning those means a pass over the live session list, and the
* residue is a few bytes per session ever abandoned mid-sentence.
*/
fun saveDraft(context: Context, sessionId: String, text: String) {
context.getSharedPreferences(DRAFTS, Context.MODE_PRIVATE).edit {
if (text.isEmpty()) remove(sessionId) else putString(sessionId, text)
}
}
@@ -1,37 +0,0 @@
package com.example.aiapp
/**
* A span of milliseconds, written the way somebody reads it.
*
* A tool's timeout arrives as `480000`, which nobody reads as eight minutes. The rule has two
* halves, because a short span and a long one are read for different things. Under a minute the
* question is "roughly how long", so only the largest unit is shown and a fraction carries the rest
* -- `2.5s`. At a minute or more the question is "how long exactly", so every unit with something
* in it is written out -- `5d 12h 4m`. Empty units are left out rather than written as zero.
*
* Sub-second precision is dropped past a minute: nothing that takes days is measured in
* milliseconds.
*/
fun formatMillis(ms: Long): String {
if (ms < 0) return "-" + formatMillis(-ms)
if (ms < 1000) return "${ms}ms"
if (ms < 60_000) {
val tenths = (ms + 50) / 100
val whole = tenths / 10
val rest = tenths % 10
return if (rest == 0L) "${whole}s" else "$whole.${rest}s"
}
val seconds = ms / 1000
val parts =
listOf(
"d" to seconds / 86_400,
"h" to seconds / 3600 % 24,
"m" to seconds / 60 % 60,
"s" to seconds % 60,
)
return parts.filter { it.second > 0 }.joinToString(" ") { "${it.second}${it.first}" }
}
/** [text] as a span when it is a whole number of milliseconds, and unchanged when it is not. */
fun formatMillisText(text: String): String =
text.trim().toLongOrNull()?.let { formatMillis(it) } ?: text
@@ -1,44 +0,0 @@
package com.example.aiapp
/**
* The frame name the server uses to say a cursor was too far behind to continue from. Must match
* `send_backlog` in the backend's routes.rs.
*/
private const val RESET_EVENT = "reset"
/**
* The SSE half of the API: one long-lived GET per open session screen, replaying the transcript
* after a cursor and then following it live.
*
* The connection and its framing belong to [Sse]; what stays here is what this stream's frames
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor.
*/
class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings)
fun close() = stream.close()
/**
* Streams events after [after] into [onEvent] until the stream drops.
*
* [onReset] fires when the server answers that the cursor is too far behind to continue from:
* everything already displayed is stale and the events that follow are a fresh window, so the
* caller drops what it holds and rebuilds. It arrives before those events, so a caller that
* clears on it stays in order.
*/
fun run(
after: Long,
onOpen: () -> Unit,
onReset: () -> Unit,
// The frame's own text as well as the event parsed from it: the transcript cache stores the
// one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit,
) {
stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
}
}
}
@@ -1,317 +0,0 @@
package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs -- the app renders purely from
// this stream (replayed from the transcript by cursor, then live), so there is no separate "load
// history" shape to keep in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
/**
* One choice offered in answer to a question.
*
* More than a label because the reader is deciding rather than confirming. Both are absent on a
* permission, whose Allow and Deny mean exactly what they say.
*/
data class QuestionOption(val label: String, val description: String?, val preview: String?)
sealed class SessionEvent {
data class UserMessage(
val text: String,
/**
* The [MessageQueued] this resolves, or null when it never waited.
*
* Matched on rather than the text, because the same message sent twice is two waiting
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
*/
val id: String?,
/**
* What was attached to it, by the ref the files route serves: images, and any file, told
* apart by [isImageRef].
*
* On the message rather than beside it: these arrived as separate image events until
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
* it, and left this app deciding from adjacency which message an image went with.
*/
val attachments: List<String>,
) : SessionEvent()
/**
* A message the server has accepted and the session has not read yet.
*
* From the server, not from this app's memory of what it sent. The pending bubble used to be
* screen state, so leaving the session drew nothing waiting while the message was still queued
* -- and nothing waiting is what "there is nothing" looks like.
*
* Resolved by the [UserMessage] carrying the same id.
*/
data class MessageQueued(val id: String, val text: String, val attachments: List<String>) :
SessionEvent()
/**
* A queued message taken back before the session read it.
*
* Recorded by the server for the same reason [MessageQueued] is: a phone that reconnects
* replays both, and without this one it would put back a bubble for a message that is never
* coming.
*/
data class MessageDropped(val id: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
data class ToolEnd(val id: String, val output: String) : SessionEvent()
data class Image(
val ref: String,
/** The tool call whose result carried it, or null for a person's own attachment. */
val about: String?,
) : SessionEvent()
data class Question(
val id: String,
val prompt: String,
/** A few words naming what the question is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** 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<String>) : SessionEvent()
/**
* A message another agent sent this session.
*
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
* claim they had. It is also the explanation for a session that starts working on something
* this device never asked for.
*/
data class PeerMessage(
val from: String,
val text: String,
/**
* Where the turn this started begins, when the server could say.
*
* The live Claude Code path only learns a turn was somebody else's when the turn ends, so
* the event arrives below everything it caused; this is what puts it back above it. Null
* for a message read out of a session file, and for one that started no turn.
*/
val turnStart: Long? = null,
) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one.
*/
data class CommandQueued(val id: String, val text: String) : SessionEvent()
/** The same command, handed to the session. */
data class CommandSent(val id: String, val text: String) : SessionEvent()
data class Status(val state: String) : SessionEvent()
/**
* What the session is set to, as the session itself reports it.
*
* Either field alone: the two are confirmed separately. Asking for a change is not having one,
* so this -- not the request -- is what the pickers show.
*/
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and how much the model was holding when it ended.
*
* [context] is prompt plus both cache figures. Carried on the event rather than summed by the
* reader, because it is not a sum: a conversation's context drops at a compaction and a clear,
* so adding turns up would report a figure the session stopped being true of. Null where the
* dialect did not say, which leaves the context unmeasured rather than unchanged.
*/
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
*
* The counts are nullable because the server sends them only when it was told them: a zero here
* would read as "recovered nothing" and a made-up number would read as a measurement.
*/
data class Compacted(
val preTokens: Long?,
val postTokens: Long?,
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
val trigger: String?,
) : SessionEvent()
/**
* The conversation was cleared. Everything above this is still here to read and is no longer in
* the session's context. An object rather than a class because what it means is entirely its
* position in the transcript.
*/
data object Cleared : SessionEvent()
/**
* The session stopped because its account's usage limit was reached.
*
* Its own event rather than an [Error] carrying the CLI's sentence, because it is a state
* rather than something that went wrong -- and because the raw sentence is `Claude AI usage
* limit reached|1788546972`, which is not readable by the person it is shown to.
*
* [resetsAt] is epoch seconds and null where the session was told nothing. Only the server acts
* on it; what this draws it as is a time, not a countdown, because nothing here re-measures it.
*/
data class LimitReached(val resetsAt: Double?) : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept rather than thrown so one
* new event kind degrades to a placeholder row instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
/**
* A JSON array of strings under [name], empty when the field is absent -- the ordinary case, since
* the server omits the field rather than sending an empty list.
*/
private fun JSONObject.stringList(name: String): List<String> {
val array = optJSONArray(name) ?: return emptyList()
return (0 until array.length()).map { array.getString(it) }
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event =
when (val type = body.getString("type")) {
"userMessage" ->
SessionEvent.UserMessage(
body.getString("text"),
body.optString("id").ifEmpty { null },
body.stringList("attachments"),
)
"messageQueued" ->
SessionEvent.MessageQueued(
body.getString("id"),
body.getString("text"),
body.stringList("attachments"),
)
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" ->
SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own business, and the UI
// only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" ->
SessionEvent.Image(
ref = body.getString("ref"),
about = body.optString("about").ifEmpty { null },
)
"question" ->
SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
header = body.optString("header").ifEmpty { null },
options =
body.getJSONArray("options").let { options ->
(0 until options.length()).map { at ->
val option = options.getJSONObject(at)
QuestionOption(
label = option.getString("label"),
description = option.optString("description").ifEmpty { null },
preview = option.optString("preview").ifEmpty { null },
)
}
},
multiSelect = body.optBoolean("multiSelect", false),
about = body.optString("about").ifEmpty { null },
)
"answered" ->
SessionEvent.Answered(
body.getString("id"),
body.getJSONArray("answers").let { answers ->
(0 until answers.length()).map { answers.getString(it) }
},
)
"peerMessage" ->
SessionEvent.PeerMessage(
body.getString("from"),
body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null,
)
"commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
"status" -> SessionEvent.Status(body.getString("state"))
"settings" ->
SessionEvent.Settings(
model = body.optString("model").ifEmpty { null },
permissionMode = body.optString("permissionMode").ifEmpty { null },
)
"usageDelta" ->
SessionEvent.UsageDelta(
body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null,
)
"compacted" ->
SessionEvent.Compacted(
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
trigger = body.optString("trigger").ifEmpty { null },
)
"cleared" -> SessionEvent.Cleared
"limitReached" ->
SessionEvent.LimitReached(
if (body.has("resetsAt")) body.getDouble("resetsAt") else null
)
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* Whether [state] is one the session is doing work in -- the states a turn is still open under.
*
* One predicate because two readers have to agree on the list: the session screen's working
* indicator, and the fold's decision that the newest reply is finished. Two copies would drift the
* first time the server grows a state, and the drift would be a reply that never splits or one
* split mid-stream.
*/
fun sessionWorking(state: String): Boolean = state == "running" || state == "compacting"
/**
* The context after [event], given what it was before.
*
* The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which each of them can reach.
*/
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a turn
// -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
else -> current
}
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
/**
* The largest file this app will open in the editor, in bytes.
*
* Measured on the emulator 2026-09-04, in a debug build, on generated Rust:
*
* | file | lines | scan per keystroke | worst frame record | typing |
* |--------|--------|--------------------|--------------------|-------------------|
* | 32 kB | 917 | 10ms | 183ms | sluggish, correct |
* | 128 kB | 3,633 | 40ms | 2,027ms | characters lost |
* | 1 MB | 28,660 | -- | -- | stops responding |
*
* The number that decides this is the **frame record**, not the scan: highlighting a 128 kB file
* costs 40ms a keystroke, which is survivable, while laying the same text out in one
* `BasicTextField` costs two seconds. So switching highlighting off above a size -- what
* EXPLORER.md expected to have to decide -- would not have saved it; every arrangement of a single
* text field pays that cost. A line-by-line editor is the way past this.
*
* 32 kB because it is the largest size actually measured as usable. The viewer's own limit stays
* the server's `FILE_LIMIT` of 1 MiB: reading a big file is fine, and only editing one is not.
*/
const val EDIT_LIMIT = 32L * 1024
/**
* The same file, editable, in the same face and colours it was being read in.
*
* `BasicTextField(TextFieldValue)` with a [VisualTransformation] is the one Compose arrangement
* that colours a field's own text rather than replacing the field with something that only looks
* like one: the transformation returns the text unchanged and the scanner's spans as styles, so
* [OffsetMapping.Identity] is correct by construction. The newer `TextFieldState` API has no hook
* for styles at all.
*
* The cost is that the whole file is re-scanned on every keystroke, which is what [EDIT_LIMIT] is
* sized against.
*
* The gutter is one `Text` of `1\n2\n…` beside the field rather than a number per row, because
* there are no rows here -- the field is one text object. It lines up for the same reason the
* viewer's does: nothing wraps, so a logical line is a visual line.
*/
@Composable
fun FileEditor(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
language: Language?,
modifier: Modifier = Modifier,
) {
val style = codeStyle().copy(color = MaterialTheme.colorScheme.onSurface)
val scroll = rememberScrollState()
val count = value.text.removeSuffix("\n").count { it == '\n' } + 1
val gutter = gutterWidth(count, style)
val numbers = remember(count) { (1..count).joinToString("\n") }
val transformation =
remember(language) {
VisualTransformation { text ->
TransformedText(highlight(text.text, language), OffsetMapping.Identity)
}
}
Row(verticalAlignment = Alignment.Top, modifier = modifier.fillMaxWidth()) {
Text(
numbers,
style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.End,
softWrap = false,
modifier = Modifier.width(gutter),
)
// The same gap the viewer puts between its numbers and its code, so switching between
// reading and editing does not move the text sideways under the reader.
Spacer(Modifier.width(GUTTER_GAP))
Box(Modifier.horizontalScroll(scroll)) {
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = style,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = transformation,
)
}
}
}
/**
* What to do about a file that changed on the machine while it was open here.
*
* Three ways out rather than one, and each says what it costs, because there is no answer this app
* can pick on somebody's behalf: an agent editing the same file is the ordinary case here, and both
* versions are somebody's work.
*/
@Composable
fun ConflictDialog(
message: String,
busy: Boolean,
onOverwrite: () -> Unit,
onReload: () -> Unit,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = onCancel,
// The server's own sentence as the title, rather than a heading of this app's above it
// saying the same thing twice: there is one statement of what happened and it comes from
// the side that found out.
title = { Text(message.replaceFirstChar { it.uppercase() }) },
text = {
Text(
"Overwrite keeps what you typed and loses the other change. " +
"Reload keeps the other change and loses what you typed. " +
"Cancel leaves both alone and keeps you here."
)
},
confirmButton = {
TextButton(onClick = onOverwrite, enabled = !busy) {
Text(if (busy) "Saving..." else "Overwrite")
}
},
dismissButton = {
Row {
TextButton(onClick = onReload, enabled = !busy) { Text("Reload") }
TextButton(onClick = onCancel, enabled = !busy) { Text("Cancel") }
}
},
)
}
/** Leaving an editor with edits in it, which is the one way to lose them by accident. */
@Composable
fun UnsavedDialog(onDiscard: () -> Unit, onCancel: () -> Unit) {
AlertDialog(
onDismissRequest = onCancel,
title = { Text("Leave without saving?") },
text = {
Text(
"The edits you have made here will be lost. They have not been written to the machine."
)
},
confirmButton = { TextButton(onClick = onDiscard) { Text("Discard") } },
dismissButton = { TextButton(onClick = onCancel) { Text("Keep editing") } },
)
}
@@ -1,122 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/**
* A file split into lines, with the highlighter's colours already worked out for each one.
*
* The pure half of the viewer, so it has a JVM unit test and so [of] can run off the main thread:
* scanning a megabyte is work, and doing it inside a composable would do it on the drawing thread
* and again on every recomposition.
*
* Why per line at all: the viewer is a `LazyColumn` of lines rather than one `Text`, because text
* layout is linear in the text. That means each row needs *its* colours, and the scanner answers in
* offsets into the whole file -- so the spans are bucketed here, once, in one pass.
*/
class FileLines
private constructor(
/** The text of each line, without its newline. */
val lines: List<String>,
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
private val spans: List<List<Span>>,
/**
* The longest line, in character columns -- what the viewer sizes every row to.
*
* Every row has to be the *same* width or they scroll sideways by different amounts; see
* [FileViewer]. Columns rather than measured pixels because the face is monospace, so one
* number and one character's advance give the width of the widest line without measuring twenty
* thousand strings.
*/
val columns: Int,
) {
val size: Int
get() = lines.size
/**
* One line, coloured. Built when the row is composed rather than up front: a file has far more
* lines than a screen shows, and an `AnnotatedString` per line for all of them is the cost the
* lazy list exists to avoid.
*/
fun line(index: Int): AnnotatedString {
val text = lines[index]
val here = spans[index]
if (here.isEmpty()) return AnnotatedString(text)
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(text)
here.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
companion object {
/**
* [text] scanned as [language] and cut into lines.
*
* Exactly one trailing newline is dropped before splitting, so a file that ends the way
* text files are supposed to end has the number of lines its author would count -- `wc -l`
* agrees. Without that, every well-formed file gained a phantom empty last line. An empty
* file is one empty line numbered 1, which is what it is.
*/
fun of(text: String, language: Language?): FileLines =
// Timed, and always, for the reason everything else here is: the cost of opening a
// large file is the number that decides whether the server's size limit is right, and
// an instrument that is only in the build nobody is running answers nothing.
DebugStats.timed("file scanned and cut into lines") {
val body = text.removeSuffix("\n")
val lines = body.split('\n')
val scanned = if (language == null) emptyList() else spansOf(body, language)
FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf))
}
/**
* How many columns a line occupies.
*
* A tab counts as eight rather than one, and deliberately upwards: this decides how far the
* viewer can scroll, and over-estimating leaves a little empty space past the longest line
* where under-estimating makes the end of that line unreachable.
*/
private fun columnsOf(line: String): Int {
var count = 0
for (character in line) count += if (character == '\t') 8 else 1
return count
}
/**
* The scanner's spans, in file offsets, as spans per line in line offsets.
*
* One walk down both lists, which is what the scanner's guarantee buys: its spans come out
* ordered, non-overlapping and inside the text. A span crossing a line break is cut at each
* break and appears in each line it covers, because a row is drawn on its own and cannot
* inherit a colour from the row above.
*/
private fun bucket(lines: List<String>, spans: List<Span>): List<List<Span>> {
val out = ArrayList<List<Span>>(lines.size)
var lineStart = 0
var next = 0
for (line in lines) {
val lineEnd = lineStart + line.length
var here: ArrayList<Span>? = null
// Spans that ended before this line begins are behind the walk for good.
while (next < spans.size && spans[next].end <= lineStart) next++
var at = next
while (at < spans.size && spans[at].start < lineEnd) {
val span = spans[at]
val start = maxOf(span.start, lineStart) - lineStart
val end = minOf(span.end, lineEnd) - lineStart
if (end > start) {
(here ?: ArrayList<Span>().also { here = it }).add(
Span(start, end, span.kind)
)
}
at++
}
out.add(here ?: emptyList())
// The newline itself, which is in the text and not in any line.
lineStart = lineEnd + 1
}
return out
}
}
}
@@ -1,242 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
@Composable
fun codeStyle(): TextStyle =
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
/**
* [content] scanned off the main thread, then drawn.
*
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
*
* Keyed on the text and the language, so re-reading the same file does not rescan it.
*/
@Composable
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
LaunchedEffect(content, language) {
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
}
when (val ready = lines) {
null -> CircularProgressIndicator(Modifier.padding(8.dp))
else -> FileViewer(ready, modifier)
}
}
/**
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
*
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
* that each row needs its own colours, which is what [FileLines] works out once and off this
* thread.
*
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
* block and a long line does not silently become three -- which would put the gutter's numbers
* against the wrong text.
*
* **Every row is given the same content width**, and that is what makes the shared scroll state
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
* short line's range is zero and it never moves while a long one beside it does. Each row also
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
* looks like.
*
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
* so only the line under the finger stretched. Only possible because every row now has the same
* range.
*
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
* is measured from the digit count of the line count in the style it is drawn in.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
*/
@Composable
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle()
val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val rows = rememberLazyListState()
val gutter = gutterWidth(lines.size, style)
val content = contentWidth(lines.columns, style)
Box(modifier.fillMaxSize()) {
// One container around the whole file rather than one per line, so a selection can run
// across lines -- the same arrangement the transcript uses.
SelectionContainer {
// The stretch is drawn here, once, over everything this box holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
// Where the numbers go, drawn from outside this box.
Spacer(Modifier.width(gutter + GUTTER_GAP))
Text(
lines.line(index),
style = style,
softWrap = false,
// The scroll outside the width: the scrolling node's viewport is
// what the row has room for, and its content is the whole file's
// widest line. The shared effect is given to every row and rendered
// by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
}
}
}
}
}
LineGutter(rows, gutter, style)
}
}
/**
* The line numbers, drawn beside the file rather than in it.
*
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this composes from the answer the list has just produced rather than one it read
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
* current while the set of numbers was a composition behind, so during a fling the numbers would
* slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
*
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
* the stretch can carry the text sideways under this column, and a digit with a smear of code
* behind it reads as a rendering fault.
*/
@Composable
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
val surface = rawSurface
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
constraints ->
val visible = rows.layoutInfo.visibleItemsInfo
val numbers = visible.map { item ->
subcompose(item.index) {
Text(
(item.index + 1).toString(),
style = style,
color = colour,
textAlign = TextAlign.End,
maxLines = 1,
)
}
.first()
.measure(Constraints.fixedWidth(constraints.maxWidth))
}
layout(constraints.maxWidth, constraints.maxHeight) {
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
}
}
}
/**
* How wide the widest line number is, measured rather than guessed.
*
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
* size, a density or a display scale nobody here chose.
*/
@Composable
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val digits = maxOf(1, lineCount.toString().length)
return remember(digits, style, density) {
with(density) {
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
}
}
}
/**
* How wide to make every row: the widest line in the file, in this style.
*
* One character measured rather than the line itself, because the face is monospace and measuring
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
* rounding.
*
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
* reached, which is the tolerable half of that trade.
*/
@Composable
private fun contentWidth(columns: Int, style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
return remember(columns, style, density) {
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
}
}
/**
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
* past any line anybody reads.
*/
private const val MAX_CONTENT_PX = 100_000f
/**
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
* by the row, and this is only so the digits and the first character are not touching.
*/
val GUTTER_GAP = 8.dp
@@ -1,688 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Which machine's files to show, and where to start.
*
* A **setup**, not a session: a filesystem is a property of a machine, and a session only says
* where it was working. That is what makes a second way in -- from the setups tab -- one more
* caller rather than any new code here.
*/
data class FilesTarget(val setup: String, val setupName: String, val start: String)
/** Where the explorer is: in a directory, or in one file. */
private sealed class Spot(val path: String) {
class Dir(path: String) : Spot(path)
class Doc(path: String) : Spot(path)
}
/**
* The files on the machine a session runs on: browse them, read one, change one.
*
* Drawn **over** the session rather than instead of it (see [AppRoot]), so its event stream keeps
* flowing and coming back from a file costs nothing. Back steps one level inside here -- editor to
* viewer, viewer to the directory it came from, directory to the one above -- and only closes from
* where it opened.
*
* Every directory that has been visited is kept for as long as this is open; the refresh glyph is
* how one gets asked again on purpose, and creating something refetches the directory it was
* created in.
*/
@Composable
fun FilesScreen(settings: ServerSettings, target: FilesTarget, onClose: () -> Unit) {
val scope = rememberCoroutineScope()
var stack by remember { mutableStateOf(listOf<Spot>(Spot.Dir(target.start))) }
val listings = remember { mutableStateMapOf<String, LoadState<Listing>>() }
var creating by remember { mutableStateOf(false) }
// Edit mode and whether anything has been typed live here rather than in the pane below,
// because they are what back has to know about -- and back arrives from two places, the arrow
// and the platform's own gesture, which must mean the same thing.
var editing by remember { mutableStateOf(false) }
var dirty by remember { mutableStateOf(false) }
var askUnsaved by remember { mutableStateOf(false) }
val here = stack.last()
fun go(spot: Spot) {
editing = false
dirty = false
stack = stack + spot
}
fun back() {
when {
editing && dirty -> askUnsaved = true
editing -> editing = false
stack.size > 1 -> {
stack = stack.dropLast(1)
editing = false
dirty = false
}
else -> onClose()
}
}
suspend fun load(path: String, again: Boolean) {
if (!again && listings[path] is LoadState.Loaded) return
listings[path] = LoadState.Loading
listings[path] =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(fetchDir(settings, target.setup, path))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
BackHandler(onBack = ::back)
Box(
Modifier.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
// The session under this deliberately takes no keyboard inset, so the explorer adds its
// own -- otherwise the editor types under the keyboard.
.imePadding()
) {
Column(Modifier.fillMaxSize()) {
when (val spot = here) {
is Spot.Dir -> {
val state = listings[spot.path] ?: LoadState.Loading
// The resolved path once there is one: a directory opened as `~` is called what
// it turned out to be, not what it was asked for.
val at = (state as? LoadState.Loaded)?.value?.path ?: spot.path
FilesHeader(
title = baseName(at),
path = at,
machine = target.setupName,
onBack = ::back,
) {
GlyphButton(
REFRESH_GLYPH,
"Refresh this directory",
{ scope.launch { load(spot.path, again = true) } },
enabled = state !is LoadState.Loading,
)
GlyphButton(
PLUS_GLYPH,
"Create here",
{ creating = true },
enabled = state is LoadState.Loaded,
)
}
LaunchedEffect(spot.path) { load(spot.path, again = false) }
DirectoryBody(state, onOpen = ::go)
}
is Spot.Doc ->
DocPane(
settings = settings,
target = target,
path = spot.path,
name = baseName(spot.path),
editing = editing,
onEditing = { editing = it },
onDirty = { dirty = it },
onBack = ::back,
)
}
}
}
if (askUnsaved) {
UnsavedDialog(
onDiscard = {
askUnsaved = false
editing = false
dirty = false
},
onCancel = { askUnsaved = false },
)
}
val dir = here as? Spot.Dir
val listing = (listings[dir?.path] as? LoadState.Loaded)?.value
if (creating && dir != null && listing != null) {
CreateDialog(
settings = settings,
setup = target.setup,
directory = listing.path,
onDismiss = { creating = false },
onCreated = { path, isDirectory ->
creating = false
scope.launch {
// The directory it was created in is the one thing that changed, so that is
// what gets asked again -- not the whole stack.
load(dir.path, again = true)
// A new file has nothing to look at, so it opens where it can be filled in.
if (!isDirectory) {
go(Spot.Doc(path))
editing = true
}
}
},
)
}
}
/**
* The row every view in here has at the top: back, what this is, and what acts on it.
*
* The path is truncated in the middle when it will not fit, because both ends carry something the
* reader needs -- the machine and the top of the tree at one end, the file at the other -- and it
* is the longest paths, the ones being read most closely, that get cut.
*/
@Composable
private fun FilesHeader(
title: String,
path: String,
machine: String,
onBack: () -> Unit,
actions: @Composable () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
Text(
"$machine · $path",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
)
}
Row { actions() }
}
}
/**
* What is in a directory.
*
* A listing that failed says why, in the machine's own words, where the rows would be -- never an
* empty list, which is what "there is nothing here" looks like and is the one wrong answer that
* looks like a right one.
*/
@Composable
private fun ColumnScope.DirectoryBody(state: LoadState<Listing>, onOpen: (Spot) -> Unit) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(16.dp))
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(16.dp),
)
is LoadState.Loaded -> {
val listing = state.value
val sorted = remember(listing) { sortForDisplay(listing.entries) }
LazyColumn(Modifier.weight(1f).fillMaxWidth()) {
parentOf(listing.path)?.let { parent ->
item("..") {
EntryRow(
glyph = FOLDER_GLYPH,
name = "..",
trailing = null,
onClick = { onOpen(Spot.Dir(parent)) },
)
}
}
if (sorted.isEmpty()) {
item("empty") {
Text(
"Nothing here",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp),
)
}
}
uniqueItems(sorted, key = { it.name }) { entry ->
val path = join(listing.path, entry.name)
EntryRow(
glyph = if (entry.isDirectory) FOLDER_GLYPH else FILE_GLYPH,
name = entry.name,
trailing = trailingOf(entry),
onClick = {
onOpen(if (entry.isDirectory) Spot.Dir(path) else Spot.Doc(path))
},
)
}
}
}
}
}
/**
* What a row says after the name, or nothing.
*
* A symlink says so instead of giving a size, because the size a listing reports for one is the
* length of the path it points at -- a number that looks exactly like a file size and is about
* something else. `other` covers a fifo, a device, and a link whose target is gone: the row still
* appears, because a directory that hid what it held would be lying about being empty.
*/
private fun trailingOf(entry: DirEntry): String? =
when {
entry.link -> "link"
entry.isDirectory -> null
entry.kind == "file" -> humanSize(entry.size) ?: "0 B"
else -> "other"
}
@Composable
private fun EntryRow(glyph: String, name: String, trailing: String?, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Glyph(glyph, colour = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.width(12.dp))
Text(
name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f),
)
trailing?.let {
Spacer(Modifier.width(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* One file: read, and edited behind the pencil.
*
* Its own composable so that everything about one file -- what came back, what has been typed, and
* whether a save is out -- is remembered under that file's path and thrown away when the reader
* moves to another. What is *not* here is edit mode itself: back has to know about it.
*/
@Composable
private fun ColumnScope.DocPane(
settings: ServerSettings,
target: FilesTarget,
path: String,
name: String,
editing: Boolean,
onEditing: (Boolean) -> Unit,
onDirty: (Boolean) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var state by remember(path) { mutableStateOf<LoadState<FileContent>>(LoadState.Loading) }
var draft by remember(path) { mutableStateOf(TextFieldValue()) }
var saving by remember(path) { mutableStateOf(false) }
var saveError by remember(path) { mutableStateOf<String?>(null) }
var conflict by remember(path) { mutableStateOf<String?>(null) }
// The editor's own vertical scroll, hoisted so the gutter and the text move together: they are
// two composables in one row, and a scroll inside either would leave the other behind.
val editScroll = rememberScrollState()
val language = remember(name) { fileLanguage(name) }
val loaded = (state as? LoadState.Loaded)?.value as? FileContent.Text
// Readable but not editable: see [EDIT_LIMIT]. The size is the one the machine reported, so
// this is decided before anything is typed rather than discovered by a keyboard that stops
// answering.
val editable = loaded != null && loaded.size <= EDIT_LIMIT
suspend fun fetch() {
state = LoadState.Loading
state =
try {
val got = withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
if (got is FileContent.Text) draft = TextFieldValue(got.content)
LoadState.Loaded(got)
} catch (e: ApiException) {
LoadState.failed(e)
}
onDirty(false)
}
LaunchedEffect(path) { fetch() }
val changed = loaded != null && draft.text != loaded.content
LaunchedEffect(changed) { onDirty(changed) }
/** Writes the draft back, [against] being the digest it is allowed to replace. */
fun save(against: String) {
if (saving) return
saving = true
saveError = null
scope.launch {
try {
val written =
withContext(Dispatchers.IO) {
writeFile(settings, target.setup, path, draft.text, against)
}
state =
LoadState.Loaded(
FileContent.Text(
path,
written.size,
written.modified,
written.sha256,
draft.text,
)
)
conflict = null
onDirty(false)
onEditing(false)
} catch (e: ApiException) {
// The one refusal that is a question rather than a message: somebody else's edit is
// on the machine, and which of the two survives is not this app's to decide.
if (e.status == 409) conflict = e.message ?: "It changed on the machine."
else saveError = e.message
} finally {
saving = false
}
}
}
FilesHeader(title = name, path = path, machine = target.setupName, onBack = onBack) {
if (editing) {
if (saving) {
GlyphSpinner("Saving")
} else {
GlyphButton(
SAVE_GLYPH,
"Save",
{ loaded?.let { save(it.sha256) } },
// Disabled rather than hidden while there is nothing to write: a button that
// comes and goes makes its own absence the signal.
enabled = changed,
)
}
} else {
GlyphButton(
REFRESH_GLYPH,
"Read this file again",
{ scope.launch { fetch() } },
enabled = state !is LoadState.Loading,
)
GlyphButton(EDIT_GLYPH, "Edit", { onEditing(true) }, enabled = editable)
}
}
saveError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
// Why the pencil is off. A disabled control teaches what the thing can do but cannot say why it
// is disabled -- and a reader who cannot edit a file they can plainly read will otherwise
// conclude the app is broken. Said once, here, rather than waiting for a tap a disabled button
// never gets.
if (loaded != null && !editable) {
Text(
"Too big to edit here (${humanSize(loaded.size)}; the limit is " +
"${humanSize(EDIT_LIMIT)}). A text field this large stops answering the keyboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
Box(Modifier.weight(1f).fillMaxWidth().background(rawSurface).padding(horizontal = 8.dp)) {
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator(Modifier.padding(8.dp))
is LoadState.Error ->
Text(
current.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
)
is LoadState.Loaded ->
when (val file = current.value) {
is FileContent.Text ->
if (editing) {
FileEditor(
draft,
{ draft = it },
language,
Modifier.verticalScroll(editScroll),
)
} else {
ScannedFile(file.content, language)
}
// Said in words, with the measurement that makes it make sense. Neither of
// these is an empty file and neither is an error, so neither may look like one.
is FileContent.Binary ->
Note(
"This is not text (${humanSize(file.size) ?: "0 B"}), so there is nothing to show."
)
is FileContent.TooBig ->
Note(
"This file is ${humanSize(file.size)}, which is more than the server will " +
"send. Nothing was read, so nothing here is a sample of it."
)
}
}
}
conflict?.let { message ->
ConflictDialog(
message = message,
busy = saving,
onOverwrite = {
// Re-read only to learn what it hashes to *now*, which is the digest an overwrite
// has to be allowed against. The content is deliberately thrown away: overwriting
// is the choice to lose it.
scope.launch {
val fresh =
try {
withContext(Dispatchers.IO) { fetchFile(settings, target.setup, path) }
} catch (e: ApiException) {
saveError = e.message
conflict = null
return@launch
}
if (fresh is FileContent.Text) save(fresh.sha256)
else {
saveError =
"It is no longer a text file, so this app will not write over it."
conflict = null
}
}
},
onReload = {
conflict = null
scope.launch { fetch() }
},
onCancel = { conflict = null },
)
}
}
/** A sentence where the file's content would be, for the two states that have no content. */
@Composable
private fun Note(text: String) {
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(8.dp),
)
}
/**
* Naming one thing in the directory that is open.
*
* A name and a switch, not a name and a body: the editor is where content is typed, and a modal
* with a text area in it is a second editor to keep in step with the first. A created file opens
* straight into edit mode, because an empty file is not something to look at.
*/
@Composable
private fun CreateDialog(
settings: ServerSettings,
setup: String,
directory: String,
onDismiss: () -> Unit,
onCreated: (String, Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var isDirectory by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
fun create() {
val chosen = name.trim()
if (busy || chosen.isEmpty()) return
busy = true
error = null
val path = join(directory, chosen)
scope.launch {
try {
withContext(Dispatchers.IO) {
if (isDirectory) createDir(settings, setup, path)
else createFile(settings, setup, path)
}
onCreated(path, isDirectory)
} catch (e: ApiException) {
// Beside the button that caused it: this dialog is the only thing on screen that
// knows something was being created, and the reason is usually the name itself.
error = e.message
busy = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Create in ${baseName(directory)}") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Directory", modifier = Modifier.weight(1f))
Switch(
checked = isDirectory,
onCheckedChange = { isDirectory = it },
enabled = !busy,
)
}
Text(
"A name that is already taken is refused rather than replaced.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(onClick = { create() }, enabled = !busy && name.isNotBlank()) {
Text(if (busy) "Creating..." else "Create")
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } },
)
}
/**
* Directories first, then by name ignoring case, and stably.
*
* Sorted here rather than by the machine: presentation order is a display decision, and `find`
* answers in whatever order the directory happens to be stored in. Dotfiles are not hidden -- in a
* repository they are half of what matters.
*/
internal fun sortForDisplay(entries: List<DirEntry>): List<DirEntry> =
entries.sortedWith(compareBy({ !it.isDirectory }, { it.name.lowercase() }))
/**
* The directory above [path], or null at the root.
*
* A string operation on a path the *machine* resolved, which is what makes it safe: every listing
* answers with its own `pwd -P`, so there is never a `..` or a symlink left in here to reason
* about, and this app never has to resolve one.
*/
internal fun parentOf(path: String): String? {
val trimmed = path.trimEnd('/')
if (trimmed.isEmpty()) return null
val cut = trimmed.lastIndexOf('/')
return when {
cut < 0 -> null
cut == 0 -> "/"
else -> trimmed.substring(0, cut)
}
}
/** What a path names: its last segment, with `/` naming itself. */
internal fun baseName(path: String): String {
val trimmed = path.trimEnd('/')
return if (trimmed.isEmpty()) "/" else trimmed.substringAfterLast('/')
}
internal fun join(directory: String, name: String): String =
if (directory.endsWith("/")) "$directory$name" else "$directory/$name"
@@ -1,204 +0,0 @@
package com.example.aiapp
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.view.FrameMetrics
import android.view.Window
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
/**
* How long each frame took, and which phase of it, taken from the platform rather than from a frame
* counter of our own.
*
* The point of splitting it up is that "the scroll is laggy" has two completely different causes
* and one appearance. If the layout-and-measure and draw figures are small and the total is large,
* the time is going into rasterising and compositing, and no amount of doing less work per row will
* move it. If they are large, the work per row is the problem and it is ours to fix.
*
* The phases are the platform's own: [FrameMetrics] reports each frame's cost in nanoseconds,
* broken into the parts the UI thread is responsible for and the parts after it.
*
* One of these for the app, like [DebugStats], because the two are read as one report and
* [drawAccounting] divides one by the other. Held per screen it was emptied by leaving a session
* and the counters were not, so a report copied after visiting two sessions divided every session's
* work by the newest one's frame count -- 36.8 seconds of placement inside a 13.5 second window.
*/
object FrameStats {
private val total = ArrayList<Long>()
private val waited = ArrayList<Long>()
private val input = ArrayList<Long>()
private val animation = ArrayList<Long>()
private val layout = ArrayList<Long>()
private val draw = ArrayList<Long>()
private val sync = ArrayList<Long>()
private val issue = ArrayList<Long>()
private val swap = ArrayList<Long>()
private val gpu = ArrayList<Long>()
private var since = System.currentTimeMillis()
/**
* Where a named phase of a scripted run (bench v2's fling/stream/type/keyboard) started, as an
* index into [total] and a wall-clock time -- not a second recorder, just a mark on this one,
* so a phase's frames are the same [FrameMetrics] the whole-run report already has, sliced.
*/
private data class PhaseMark(val name: String, val startIndex: Int, val startMs: Long)
private val phaseMarks = ArrayList<PhaseMark>()
/** Call at the start of each named phase of a scripted run; see [BenchRun]. */
@Synchronized
fun markPhase(name: String) {
phaseMarks += PhaseMark(name, total.size, System.currentTimeMillis())
}
@Synchronized
fun add(metrics: FrameMetrics) {
// The first frame after a window opens includes inflating it and is nobody's scroll.
if (metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 1L) return
if (total.size >= CAP) return
total += metrics.getMetric(FrameMetrics.TOTAL_DURATION)
// How long the frame waited for the UI thread to be free before it could start. Reported
// because the phases otherwise do not add up to the total, and the gap is the interesting
// part: the frame being held up by work that is not the frame's.
waited += metrics.getMetric(FrameMetrics.UNKNOWN_DELAY_DURATION)
input += metrics.getMetric(FrameMetrics.INPUT_HANDLING_DURATION)
animation += metrics.getMetric(FrameMetrics.ANIMATION_DURATION)
layout += metrics.getMetric(FrameMetrics.LAYOUT_MEASURE_DURATION)
draw += metrics.getMetric(FrameMetrics.DRAW_DURATION)
sync += metrics.getMetric(FrameMetrics.SYNC_DURATION)
issue += metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION)
swap += metrics.getMetric(FrameMetrics.SWAP_BUFFERS_DURATION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
gpu += metrics.getMetric(FrameMetrics.GPU_DURATION)
}
}
@Synchronized
fun reset() {
listOf(total, waited, input, animation, layout, draw, sync, issue, swap, gpu).forEach {
it.clear()
}
phaseMarks.clear()
since = System.currentTimeMillis()
}
@Synchronized
fun lines(refreshHz: Float): List<String> {
if (total.isEmpty()) return listOf(" no frames recorded -- scroll first, then press this")
val seconds = (System.currentTimeMillis() - since) / 1000.0
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val late = total.count { it / 1_000_000.0 > budget }
return listOf(
" ${total.size} frames over ${"%.1f".format(seconds)}s" +
" at ${"%.0f".format(refreshHz)}Hz (${"%.1f".format(budget)}ms budget)",
" late: $late (${percent(late, total.size)})" +
if (total.size >= CAP) " [capped]" else "",
phase("total ", total),
phase("waited", waited),
phase("input ", input),
phase("anim ", animation),
phase("layout", layout),
phase("draw ", draw),
phase("sync ", sync),
phase("issue ", issue),
phase("swap ", swap),
) + if (gpu.isEmpty()) emptyList() else listOf(phase("gpu ", gpu))
}
/**
* One block per [markPhase] call: how many frames landed between that mark and the next (or the
* end of the run, for the last one), how many were late, the total/p50/p90/p99, the worst
* single frame, and how long the phase actually ran. Marks with no frames between them (a phase
* that finished before a frame was drawn) still get a line rather than being silently dropped
* -- UI_RULES' "say what you don't know" applies to a phase as much as to a single number.
*/
@Synchronized
fun phaseLines(refreshHz: Float): List<String> {
if (phaseMarks.isEmpty()) return emptyList()
val budget = if (refreshHz > 0) 1000.0 / refreshHz else 16.7
val lines = ArrayList<String>()
phaseMarks.forEachIndexed { i, mark ->
val endIndex = if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startIndex else total.size
val endMs =
if (i + 1 < phaseMarks.size) phaseMarks[i + 1].startMs
else System.currentTimeMillis()
val samples = total.subList(mark.startIndex, endIndex)
val seconds = (endMs - mark.startMs) / 1000.0
lines += " ${mark.name}: ${samples.size} frames over ${"%.1f".format(seconds)}s"
if (samples.isEmpty()) {
lines += " no frames recorded in this phase"
} else {
val late = samples.count { it / 1_000_000.0 > budget }
lines += " late: $late (${percent(late, samples.size)})"
lines += " " + phase("total ", samples)
lines += " worst ${"%.1fms".format(samples.max() / 1_000_000.0)}"
}
}
return lines
}
/** How long the frames recorded here spent in their draw phase, and how many there were. */
@Synchronized fun drawPhase(): Pair<Long, Int> = draw.sum() to draw.size
private fun phase(name: String, samples: List<Long>): String {
val sorted = samples.sorted()
return " $name p50 ${at(sorted, 50)} p90 ${at(sorted, 90)} p99 ${at(sorted, 99)}"
}
private fun at(sorted: List<Long>, percentile: Int): String {
if (sorted.isEmpty()) return "-"
val index = (sorted.size - 1) * percentile / 100
return "%.1fms".format(sorted[index] / 1_000_000.0)
}
private fun percent(part: Int, whole: Int) = "%.1f%%".format(100.0 * part / whole)
}
/** Enough for a couple of minutes of scrolling; this is a diagnostic, not a log. */
private const val CAP = 20_000
/**
* Records into [FrameStats] for as long as this screen is on it.
*
* The listener is what comes and goes; what it writes into does not, so a report covers the same
* stretch of time as the counters beside it.
*
* The listener is handed its own thread because the platform calls it for every frame and the
* documentation is explicit that doing that on the main thread taxes the very thing being measured.
*/
@Composable
fun RecordFrames() {
val window = LocalContext.current.activity()?.window
DisposableEffect(window) {
if (window == null) return@DisposableEffect onDispose {}
val thread = HandlerThread("frame-stats").apply { start() }
val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ ->
FrameStats.add(metrics)
}
window.addOnFrameMetricsAvailableListener(listener, Handler(thread.looper))
onDispose {
window.removeOnFrameMetricsAvailableListener(listener)
thread.quitSafely()
}
}
}
/** The activity behind a composable's context, which is what owns the window. */
fun Context.activity(): Activity? {
var context: Context? = this
while (context is ContextWrapper) {
if (context is Activity) return context
context = context.baseContext
}
return null
}
/** What the display is actually refreshing at, so "late" is measured against the real budget. */
fun Context.refreshHz(): Float =
@Suppress("DEPRECATION") (activity()?.windowManager?.defaultDisplay?.refreshRate ?: 60f)
@@ -1,316 +0,0 @@
package com.example.aiapp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
/** What a span of code is, in the terms the palette has a colour for. */
enum class Kind {
KEYWORD,
STRING,
LITERAL,
COMMENT,
METADATA,
PUNCTUATION,
MARK,
}
/** A run of [Kind] in the code, as a half-open range. */
data class Span(val start: Int, val end: Int, val kind: Kind)
/**
* The colours the highlighter draws with, ours rather than a library's; [catppuccinSyntax] is the
* one instance and lives with the rest of the palette.
*/
data class SyntaxPalette(
val keyword: Color,
val string: Color,
val literal: Color,
val comment: Color,
val metadata: Color,
val punctuation: Color,
val mark: Color,
) {
fun of(kind: Kind): Color =
when (kind) {
Kind.KEYWORD -> keyword
Kind.STRING -> string
Kind.LITERAL -> literal
Kind.COMMENT -> comment
Kind.METADATA -> metadata
Kind.PUNCTUATION -> punctuation
Kind.MARK -> mark
}
}
/**
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
*
* Shared by a tool call's input and a reply's fences, so the same code is the same colours wherever
* it appears.
*
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
* off the drawing thread.
*
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
* caches the answer rather than a `remember` inside the fence recomputing it on every scroll back.
*/
fun highlight(code: String, language: Language?): AnnotatedString {
if (language == null) return AnnotatedString(code)
val spans = DebugStats.timed("code highlighted") { spansOf(code, language) }
val palette = catppuccinSyntax()
return buildAnnotatedString {
append(code)
spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
}
}
/**
* [code] read once, left to right, into the spans that carry a colour.
*
* One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a
* locator per token kind over the whole text, which is what the library did and is why it found
* comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a
* block-comment opener inside a shell glob each commented out the rest of a line that was nothing
* of the sort.
*
* Every span is produced by advancing an index forward, so the result is ordered, non-overlapping
* and inside the code by construction. Nothing here throws: an unterminated string or comment runs
* to the end of the code, which is also what it looks like while a fence is still being written.
*
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
* finally a single punctuation or mark character, which are coloured only in ordinary code.
*/
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */
private const val PUNCTUATION = ",.:;"
private const val MARKS = "()={}<>-+[]|&"
private class Scanner(private val code: String, private val rules: Rules) {
private val spans = ArrayList<Span>()
private var at = 0
fun run(): List<Span> {
while (at < code.length) {
// Every branch that answers true has advanced `at`, so this terminates.
val consumed =
blockComment() ||
lineComment() ||
rawString() ||
characterOrLifetime() ||
string() ||
attribute() ||
number() ||
word() ||
singleCharacter()
if (!consumed) at++
}
return spans
}
private fun emit(start: Int, kind: Kind) {
if (at > start) spans.add(Span(start, at, kind))
}
private fun starts(token: String) = code.startsWith(token, at)
/** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */
private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&("
/** Whether only whitespace stands between the start of this line and here. */
private fun atLineStart(): Boolean {
var back = at - 1
while (back >= 0 && code[back] != '\n') {
if (!code[back].isWhitespace()) return false
back--
}
return true
}
private fun toEndOfLine() {
while (at < code.length && code[at] != '\n') at++
}
/** From an open bracket through the one that matches it, or to the end if none does. */
private fun toMatchingBracket() {
var depth = 0
while (at < code.length) {
when (code[at]) {
'[' -> depth++
']' -> depth--
}
at++
if (depth == 0) return
}
}
private fun blockComment(): Boolean {
val comment = rules.blockComment ?: return false
if (!starts(comment.open)) return false
val start = at
at += comment.open.length
var depth = 1
while (at < code.length && depth > 0) {
// The closer is tried first so that a language whose two delimiters are the same string
// -- CoffeeScript's `###` -- closes rather than nesting forever.
if (starts(comment.close)) {
depth--
at += comment.close.length
} else if (comment.nests && starts(comment.open)) {
depth++
at += comment.open.length
} else {
at++
}
}
emit(start, Kind.COMMENT)
return true
}
private fun lineComment(): Boolean {
if (rules.lineComments.none { starts(it) }) return false
if (rules.lineCommentsAtWordStart && !atWordStart()) return false
val start = at
toEndOfLine()
emit(start, Kind.COMMENT)
return true
}
/** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */
private fun rawString(): Boolean {
if (!rules.rawStrings) return false
var ahead = at
if (code.getOrNull(ahead) == 'b') ahead++
if (code.getOrNull(ahead) != 'r') return false
ahead++
var hashes = 0
while (code.getOrNull(ahead) == '#') {
ahead++
hashes++
}
if (code.getOrNull(ahead) != '"') return false
val start = at
val closer = "\"" + "#".repeat(hashes)
val closed = code.indexOf(closer, ahead + 1)
at = if (closed < 0) code.length else closed + closer.length
emit(start, Kind.STRING)
return true
}
/** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */
private fun characterOrLifetime(): Boolean {
if (!rules.lifetimes || code[at] != '\'') return false
val next = code.getOrNull(at + 1) ?: return false
if (next == '\\' || code.getOrNull(at + 2) == '\'') {
quoted(Quote("'", "'", escapes = true))
} else {
at++
}
return true
}
private fun string(): Boolean {
// Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string
// followed by a quote. A loop rather than filter/maxBy: this runs at every character of
// ordinary code, and the pair of lists that would allocate is the whole cost of the scan.
var quote: Quote? = null
for (candidate in rules.quotes) {
if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) {
quote = candidate
}
}
quoted(quote ?: return false)
return true
}
private fun quoted(quote: Quote) {
val start = at
at += quote.open.length
while (at < code.length) {
if (quote.escapes && code[at] == '\\' && at + 1 < code.length) {
at += 2
continue
}
if (starts(quote.close)) {
at += quote.close.length
break
}
at++
}
at = at.coerceAtMost(code.length)
emit(start, Kind.STRING)
}
private fun attribute(): Boolean {
val start = at
when (rules.attributes) {
Attributes.NONE -> return false
Attributes.AT_WORD -> {
if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false
at++
while (at < code.length && isWordPart(code[at])) at++
}
Attributes.HASH_BRACKET -> {
if (code[at] != '#') return false
var ahead = at + 1
if (code.getOrNull(ahead) == '!') ahead++
if (code.getOrNull(ahead) != '[') return false
at = ahead
toMatchingBracket()
}
Attributes.HASH_LINE -> {
if (code[at] != '#' || !atLineStart()) return false
toEndOfLine()
}
Attributes.LINE_BRACKET -> {
if (code[at] != '[' || !atLineStart()) return false
toMatchingBracket()
}
}
emit(start, Kind.METADATA)
return true
}
/**
* A number is a run starting with a digit and carrying on through letters, digits, `_` and `.`
* -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them.
*/
private fun number(): Boolean {
if (!code[at].isDigit()) return false
val start = at
while (
at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.')
) {
at++
}
emit(start, Kind.LITERAL)
return true
}
private fun word(): Boolean {
if (!isWordStart(code[at])) return false
val start = at
while (at < code.length && isWordPart(code[at])) at++
if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD)
return true
}
private fun singleCharacter(): Boolean {
val kind =
when (code[at]) {
in PUNCTUATION -> Kind.PUNCTUATION
in MARKS -> Kind.MARK
else -> return false
}
at++
emit(at - 1, kind)
return true
}
}
private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_')
private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_'
@@ -1,667 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** What a row says about itself while an operation is running on it. See [BusyItem]. */
private const val IMPORTING = "importing"
private const val DELETING = "deleting"
/**
* What the rows further down a batch say while they wait their turn.
*
* Its own word rather than the operation's, because nothing has been done to this session yet, so a
* batch stopped here leaves it exactly as it was. Marked from the moment the batch is handed over
* all the same -- a queued row that still looked ordinary was still tappable.
*/
private const val WAITING = "waiting"
/**
* How long a row that has just moved ignores being touched.
*
* A batch takes rows out of the list as each one lands, so everything below the one that went
* slides up -- and a tap already on its way then arrives at whichever row moved into that place. On
* this screen that means importing a session nobody chose.
*
* Swallowed silently rather than shown, because anything drawn on every row a batch passes would be
* a flicker running down the list.
*/
private const val SETTLE_MS = 500L
/**
* Continuing a Claude Code session the machine already has.
*
* The list is the machine's answer, not this app's. Choosing one sends its **id**, never a path, so
* an enrolled phone cannot turn this screen into a file reader.
*
* Holding a row selects it and puts the screen in selection mode, where the options that act on a
* selection appear along the bottom. That exists because these arrive in bulk -- a machine
* accumulates dozens of abandoned sessions -- and one confirmation dialog per row is the reason
* clearing them out was not worth doing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ImportScreen(settings: ServerSettings, reloadToken: Int, onImported: (SessionSummary) -> Unit) {
val scope = rememberCoroutineScope()
var setups by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var chosen by remember { mutableStateOf<Setup?>(null) }
var sessions by remember { mutableStateOf<LoadState<List<Importable>>>(LoadState.Loading) }
// What is happening to each row right now, as the word the row shows. A map keyed by id rather
// than a flag per row, because the rows are rebuilt from whatever the server last said and this
// belongs to the request rather than to the session.
var running by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which rows the reader has picked out. Empty means selection mode is off: a selection mode
// with nothing selected is a state with no controls in it and no way to leave except Back.
var selected by remember { mutableStateOf<Set<String>>(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<Map<String, String>>(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<List<Importable>?>(null) }
// Same default as the spawn screen: a phone is the wrong place to answer "allow Bash?" forty
// times.
var permissionMode by remember { mutableStateOf("auto") }
// When each row last slid upwards, as a plain map rather than state: nothing is drawn from it,
// so a tap reading it needs no recomposition.
val movedAt = remember { mutableMapOf<String, Long>() }
fun settling(id: String) = System.currentTimeMillis() - (movedAt[id] ?: 0L) < SETTLE_MS
/**
* Fetches the list and takes the row states from it.
*
* Taken from the answer rather than kept across the load: the server is what knows what is
* running, and this screen may be opening on work another phone started.
*/
suspend fun fetchInto(setup: Setup): LoadState<List<Importable>> =
try {
val rows = withContext(Dispatchers.IO) { fetchImportable(settings, setup.id) }
running = rows.mapNotNull { row -> row.pending?.let { row.id to it } }.toMap()
rowErrors = rows.mapNotNull { row -> row.error?.let { row.id to it } }.toMap()
LoadState.Loaded(rows)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list sessions")
}
fun loadSessions(setup: Setup) {
sessions = LoadState.Loading
selected = emptySet()
scope.launch { sessions = fetchInto(setup) }
}
/** Takes a row out of the list, once the machine no longer has it to offer. */
fun forget(id: String) {
val loaded = sessions
if (loaded is LoadState.Loaded) {
// Only this row, and only what changed -- refetching instead put every other row back
// through a loading spinner to report a change that was never in doubt.
sessions = LoadState.Loaded(loaded.value.filterNot { it.id == id })
}
}
LaunchedEffect(reloadToken) {
setups =
try {
val found = withContext(Dispatchers.IO) { fetchSetups(settings) }
found.firstOrNull()?.let {
chosen = it
loadSessions(it)
}
LoadState.Loaded(found)
} catch (err: Exception) {
LoadState.Error(err.message ?: "Couldn't list machines")
}
}
/**
* Hands [targets] to the server in one request, marking every row it covers.
*
* The request only *starts* the work -- the server runs it and says how each row went on the
* change stream, which is what lets this screen be left while a batch is still going.
*
* Marked [WAITING] rather than with the operation's own word until the server confirms. Between
* the request leaving and the `started` event coming back, "we have asked" is the truth and "it
* is importing" is a guess.
*
* The selection is dropped as the work is handed over, not when it finishes: the screen goes
* back to how it started, and what says the work is happening is the rows it is happening to.
*/
fun handOver(targets: List<Importable>, send: suspend (List<String>) -> Unit) {
selected = emptySet()
running = running + targets.associate { it.id to WAITING }
rowErrors = rowErrors - targets.map { it.id }.toSet()
val setup = chosen
val ids = targets.map { it.id }
scope.launch {
// One request for the whole batch, not one per row. Sent row by row, a handover was
// only as atomic as the network, and what came back was some rows running and some
// untouched -- indistinguishable, on the list, from rows nobody had picked.
try {
withContext(Dispatchers.IO) { send(ids) }
} catch (err: Exception) {
// The server never took it, so nothing is running and no event will arrive to say
// so. This is the one failure the screen must report itself -- and it is the whole
// batch's failure, which is the point: no row was singled out.
running = running - ids.toSet()
rowErrors = rowErrors + ids.associateWith { err.message ?: "Couldn't ask" }
return@launch
}
// Then ask what actually happened, if anything still looks outstanding.
//
// The change stream is a broadcast with no memory, so an operation that started and
// finished while it was still connecting is one nothing will ever be said about -- and
// the row sits marked for ever. That is not hypothetical: with responses held back far
// enough, one row of a pair of deletes cleared and the other stayed on "waiting".
//
// The listing is the repair, because it carries the same state the events do. Only when
// something still looks outstanding, so the ordinary case does not pay for a second
// listing, which is the most expensive call this screen makes.
if (setup != null && targets.any { running.containsKey(it.id) }) {
// Quietly: no Loading, because blanking the list to report on rows that are already
// saying what is happening to them is the flicker this screen avoids everywhere
// else.
sessions = fetchInto(setup)
}
}
}
val provider = chosen?.providers?.firstOrNull { it.kind == "claude_cli" }
/** Continues [targets] in the background, leaving the screen where it is. */
fun importAll(targets: List<Importable>) {
val setup = chosen ?: return
val useProvider = provider ?: return
handOver(targets) { ids ->
startImport(
settings,
setup = setup.id,
sessionIds = ids,
provider = useProvider.name,
permissionMode = permissionMode,
)
}
}
/**
* Continues one session and goes to it.
*
* The tap keeps waiting, because "take me there" needs the session it made and the server's
* accepted-and-running answer does not carry one. It is one session and somebody is watching
* it, which is the case where waiting is the right thing anyway.
*/
fun importAndOpen(target: Importable) {
val setup = chosen ?: return
val useProvider = provider ?: return
running = running + (target.id to IMPORTING)
rowErrors = rowErrors - target.id
scope.launch {
try {
val spawned =
withContext(Dispatchers.IO) {
spawnSession(
settings,
setup = setup.id,
provider = useProvider.name,
// Nothing to say: the server titles it from the session it continues.
title = "",
permissionMode = permissionMode,
import = target.id,
)
}
forget(target.id)
onImported(spawned)
} catch (err: Exception) {
rowErrors = rowErrors + (target.id to (err.message ?: "Couldn't import that one"))
} finally {
running = running - target.id
}
}
}
// Live changes to what the server is doing to these sessions, for as long as this screen is up.
// The listing already carried the same state when the screen opened -- this is what keeps it
// current afterwards, including for work another phone started.
//
// Failures here are deliberately quiet. There is nothing for a reader to do about a dropped
// event stream, and every state it would have carried is in the next listing.
val liveChanges = remember {
java.util.concurrent.atomic.AtomicReference<ImportableStream?>(null)
}
LaunchedEffect(chosen?.id) {
val setup = chosen?.id ?: return@LaunchedEffect
try {
while (true) {
val stream = ImportableStream(settings, setup)
liveChanges.set(stream)
try {
withContext(Dispatchers.IO) {
stream.run(onOpen = {}) { change ->
when (change.state) {
"started" ->
running =
running + (change.session to (change.operation ?: WAITING))
// Gone from the machine either way: a delete removed the
// transcript, an import made it a session.
"finished" -> {
running = running - change.session
forget(change.session)
}
"failed" -> {
running = running - change.session
rowErrors =
rowErrors +
(change.session to (change.message ?: "Didn't work"))
}
}
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
// The screen leaving, not a failure -- and swallowing it would leave this loop
// reconnecting to a stream nobody is watching.
throw e
} catch (_: Exception) {
// Retried below; the listing is the truth in the meantime. Any failure, not
// only an [ApiException]: a stream is an optimisation over the listing here,
// and catching only the expected failure means an unexpected one closes the app
// from a screen that is merely loading a list.
} finally {
stream.close()
}
delay(RECONNECT_DELAY_MS)
}
} finally {
// Cancellation cannot interrupt a blocking socket read; closing is what unblocks it.
liveChanges.getAndSet(null)?.close()
}
}
// The screen leaving the composition entirely, which the effect above does not cover.
DisposableEffect(chosen?.id) { onDispose { liveChanges.get()?.close() } }
// Back leaves selection mode rather than the tab, which is the level it is one step above.
// Nested inside MainScreen's own handler, so it wins while there is a selection.
BackHandler(enabled = selected.isNotEmpty()) { selected = emptySet() }
// Measured rather than assumed: the list reserves exactly what the bar covers, so the last row
// can still be scrolled to while it is up.
var barHeight by remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
// No heading: the tab that selected this one already says "Import". The sentence below
// stays, because it says what importing *does*, which the tab label cannot.
Text(
"Sessions Claude Code already has on the machine. Importing continues one where " +
"it left off; the transcript here shows its recent history. Hold one to " +
"select it, and several at a time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
when (val loaded = setups) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(loaded.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded -> {
// Only worth choosing when there is a choice.
if (loaded.value.size > 1) {
Row(Modifier.fillMaxWidth()) {
loaded.value.forEach { setup ->
TextButton(
onClick = {
chosen = setup
loadSessions(setup)
}
) {
Text(
setup.name,
color =
if (setup.id == chosen?.id)
MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
if (chosen != null && provider == null) {
Text(
"${chosen?.name} has no Claude CLI, so there is nothing here to " +
"continue.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
selected = permissionMode,
onSelect = { permissionMode = it },
)
Spacer(Modifier.height(8.dp))
ImportableList(
state = sessions,
running = running,
settling = ::settling,
selected = selected,
errors = rowErrors,
bottomInset = barHeight,
onToggle = { session ->
selected =
if (session.id in selected) selected - session.id
else selected + session.id
},
onOpen = { session -> importAndOpen(session) },
)
}
}
}
}
// Beside nothing in particular, because a selection is not one row: the options that act on
// it belong to the screen, and the bottom is where a thumb already is.
if (selected.isNotEmpty()) {
val picked =
(sessions as? LoadState.Loaded)?.value?.filter { it.id in selected }.orEmpty()
SelectionBar(
count = picked.size,
modifier =
Modifier.align(Alignment.BottomCenter).onSizeChanged {
barHeight = with(density) { it.height.toDp() }
},
onDelete = { confirming = picked },
onImport = { importAll(picked) },
)
}
}
confirming?.let { targets ->
AlertDialog(
onDismissRequest = { confirming = null },
title = {
Text(
if (targets.size == 1) "Delete this session?"
else "Delete ${targets.size} sessions?"
)
},
text = {
Text(
// One name is worth showing and twelve are not, so the count stands in for
// them. The sentence after it is the same either way, because what deleting
// costs does not change with how many.
(if (targets.size == 1) "\"${targets.first().title}\"\n\n" else "") +
"Claude Code keeps no copy: its transcript is the session, so this ends " +
"any chance of resuming that conversation. Sessions already imported " +
"here keep the history they replayed, but cannot be continued."
)
},
confirmButton = {
TextButton(
onClick = {
val setup = chosen ?: return@TextButton
confirming = null
handOver(targets) { ids -> deleteImportable(settings, setup.id, ids) }
}
) {
// Coloured by consequence: this takes something away, wherever it appears.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = { TextButton(onClick = { confirming = null }) { Text("Cancel") } },
)
}
}
/**
* What can be done to the rows that are selected.
*
* Delete and Import only, for now: they are the two things this screen has ever done to a session,
* and an option that appears here has to work on every row in a selection.
*/
@Composable
private fun SelectionBar(
count: Int,
modifier: Modifier = Modifier,
onDelete: () -> Unit,
onImport: () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
"$count selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onDelete) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.width(4.dp))
TextButton(onClick = onImport) { Text("Import") }
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ImportableList(
state: LoadState<List<Importable>>,
/** Rows an operation is running on, as the word each one shows. */
running: Map<String, String>,
/** Whether this row has just moved and should ignore being touched -- see [SETTLE_MS]. */
settling: (String) -> Boolean,
selected: Set<String>,
errors: Map<String, String>,
/** What the selection bar covers, so the last row can still be reached under it. */
bottomInset: Dp,
onToggle: (Importable) -> Unit,
onOpen: (Importable) -> Unit,
) {
when (state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(state.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
if (state.value.isEmpty()) {
Text(
"No Claude Code sessions on that machine.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val selecting = selected.isNotEmpty()
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
) {
uniqueItems(state.value, key = { it.id }) { session ->
val picked = session.id in selected
BusyItem(label = running[session.id]) {
Card(
colors =
if (picked)
CardDefaults.cardColors(
containerColor =
MaterialTheme.colorScheme.secondaryContainer,
contentColor =
MaterialTheme.colorScheme.onSecondaryContainer,
)
else CardDefaults.cardColors(),
modifier =
Modifier.fillMaxWidth()
.padding(vertical = 4.dp)
.combinedClickable(
// Off while something is happening to this row -- see
// [BusyItem], which draws that but leaves the gestures
// alone so the list still scrolls.
enabled = running[session.id] == null,
onClick = {
if (settling(session.id)) return@combinedClickable
// In selection mode a tap is a selection, so the
// reader is never one mis-tap away from starting a
// CLI they were only picking rows for.
//
// Outside it, a tap continues the session -- except
// on a row that cannot be continued, where it
// selects instead. That row's only remaining action
// is Delete, and a tap that did nothing at all
// would be a worse answer. Two `--resume` processes
// on one transcript each replay the other's writes,
// which is why this must not simply try.
if (selecting || session.inUse == "yes")
onToggle(session)
else onOpen(session)
},
onLongClick = {
if (!settling(session.id)) onToggle(session)
},
),
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.Top) {
Text(
session.title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
// Beside the title, because "which one was I just in" is
// the question this list answers and the order already
// reflects it.
Text(
relativeTime(session.modified),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
// The path first, and the only thing here that is cut: it is
// one long value with no natural break. Cut at the head,
// because a path is identified by its tail and these all share
// a long prefix. By the row's real width rather than a
// character count, which was one guess for every font size and
// screen.
session.cwd
.takeIf { it.isNotEmpty() }
?.let { cwd ->
Text(
cwd,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.StartEllipsis,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
statsOf(session),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Its own line and its own colour, because it differs in kind
// from the stats above rather than in degree: those describe
// the session, this says whether taking it is safe at all.
warningOf(session)?.let { warning ->
Text(
warning,
style = MaterialTheme.typography.bodySmall,
color = warningColor,
)
}
// Reported where it happened, in the server's own words.
errors[session.id]?.let { message ->
Spacer(Modifier.height(4.dp))
Text(
message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}
}
/** What this session is: the measurements, in the order they are worth knowing. */
private fun statsOf(session: Importable): String =
listOfNotNull(
// Said, because a name and a last message are different claims: one describes the
// session, the other is only what happened last in it.
if (session.named) "named" else null,
// What continuing it costs, which is the question this list is really asked. Absent
// rather than zero when nothing has been measured -- a session with no turns yet has no
// figure, not a figure of none.
session.contextTokens?.let { "${it / 1000}k context" },
"${session.lines} lines",
// Kept beside the context figure because the two disagree usefully: most of a large
// transcript is history from before a compaction, so a big file can be cheap to
// continue.
humanSize(session.bytes),
)
.joinToString(" · ")
/**
* Why this session might not be safe to take, if it isn't.
*
* Words rather than only a colour: "open somewhere else" and "we could not check" differ in kind,
* and no shade distinguishes them.
*/
private fun warningOf(session: Importable): String? =
when (session.inUse) {
// What was measured is that a live process on that machine holds this session open. Which
// process is not measured, so it isn't claimed: "a terminal -- close it there first" sent
// people looking for a window that need not exist. Naming a place the reader then can't
// find turns a correct refusal into a wrong instruction.
"yes" -> "something on that machine is running it"
"unknown" -> "can't tell if it's open"
else -> null
}
@@ -1,25 +0,0 @@
package com.example.aiapp
/**
* What a machine's Claude Code sessions are having done to them, live.
*
* The import screen starts and then leaves work behind: the server runs it, so the phone that asked
* is free to go elsewhere and the answer arrives here rather than as a reply. What the screen shows
* on arrival comes from the listing, which carries the same state for whoever was not connected
* when it changed; this is only what keeps a screen somebody is watching current.
*
* The connection and its framing belong to [Sse]. Closing is the caller's cancellation path, and
* the caller owns reconnecting -- there is no cursor to resume from, because anything missed is in
* the next listing.
*/
class ImportableStream(settings: ServerSettings, private val setup: String) {
private val stream = Sse(settings)
fun close() = stream.close()
fun run(onOpen: () -> Unit, onChange: (ImportableChange) -> Unit) {
stream.run("/setups/$setup/importable/events", onOpen) { _, data ->
if (data.isNotEmpty()) parseImportableChange(data)?.let(onChange)
}
}
}
@@ -1,453 +0,0 @@
package com.example.aiapp
/**
* A language the highlighter can colour.
*
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
*
* Nearly all of them are a row of [RULES], read by one shared scanner. [MARKDOWN] is the one that
* is not; see [spansOf].
*/
enum class Language {
C,
COFFEESCRIPT,
CPP,
CSHARP,
DART,
FISH,
GO,
JAVA,
JAVASCRIPT,
JSON,
KOTLIN,
MARKDOWN,
PERL,
PHP,
PYTHON,
RON,
RUBY,
RUST,
SHELL,
SWIFT,
TOML,
TYPESCRIPT,
}
/**
* What [scan] needs to know about one language -- data, not code, so that adding a language is a
* row in [RULES] rather than a branch anywhere.
*
* The two forms that could not be expressed as data are flags here and a few lines in the scanner:
* [rawStrings], because the closing delimiter depends on how many hashes the opener had, and
* [lifetimes], because whether `'` opens anything at all depends on what follows it.
*/
data class Rules(
/** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */
val keywords: Set<String>,
/** Tokens that open a comment running to the end of the line. */
val lineComments: List<String> = emptyList(),
/**
* Whether [lineComments] count only at the start of a word. The shells need it: `$#`, `${#x}`
* and `a#b` are not comments, and greying the rest of those lines is one of the mistakes this
* scanner exists to stop.
*/
val lineCommentsAtWordStart: Boolean = false,
val blockComment: BlockComment? = null,
/** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */
val quotes: List<Quote> = emptyList(),
val attributes: Attributes = Attributes.NONE,
/** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */
val rawStrings: Boolean = false,
/**
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
* Otherwise it is a lifetime or a label -- without this, `'a` opens a string that runs to the
* next apostrophe in the block.
*/
val lifetimes: Boolean = false,
)
data class BlockComment(val open: String, val close: String, val nests: Boolean)
/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */
data class Quote(val open: String, val close: String, val escapes: Boolean)
/** What opens a metadata span, of the shapes that exist across these languages. */
enum class Attributes {
NONE,
/** `@` and a word: Kotlin and Java annotations, Python decorators. */
AT_WORD,
/** `#[` or `#![` through the matching `]`: Rust and RON attributes. */
HASH_BRACKET,
/** `#` at the start of a line, to the end of it: the C preprocessor. */
HASH_LINE,
/** `[` at the start of a line through the matching `]`: a TOML table header. */
LINE_BRACKET,
}
/**
* The spans [language] colours in [code] -- the one way to ask, whatever the language turns out to
* be made of.
*
* Nearly every language here is tokens, which is a row of [RULES] and the one shared scanner.
* Markdown has none of those, and what a character means there depends on where on the line it
* sits, so it brings a scanner of its own. That is the whole extension point -- a new language is a
* row of rules or an entry in [SCANNERS], and no caller learns which one it got.
*/
fun spansOf(code: String, language: Language): List<Span> = SCANNERS.getValue(language)(code)
// Lazy for the same reason [RULES] is, since it reads it.
private val SCANNERS: Map<Language, (String) -> List<Span>> by lazy {
RULES.mapValues { (_, rules) -> { code: String -> scan(code, rules) } } +
mapOf(Language.MARKDOWN to ::scanMarkdown)
}
private val C_STYLE = BlockComment("/*", "*/", nests = false)
private val NESTING = BlockComment("/*", "*/", nests = true)
private val DOUBLE = Quote("\"", "\"", escapes = true)
private val SINGLE = Quote("'", "'", escapes = true)
private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true)
private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true)
// Lazy because the keyword sets below are top-level properties too, and a file's properties
// initialize in the order they are written: read eagerly here, every set would be null.
private val RULES: Map<Language, Rules> by lazy {
mapOf(
Language.C to
Rules(
keywords = KEYWORDS_C,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_LINE,
),
Language.CPP to
Rules(
keywords = KEYWORDS_CPP,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_LINE,
),
Language.CSHARP to
Rules(
keywords = KEYWORDS_CSHARP,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
),
// `###` opens and closes a block comment and `#` opens a line one, which is why the scanner
// tries the block opener first.
Language.COFFEESCRIPT to
Rules(
keywords = KEYWORDS_COFFEESCRIPT,
lineComments = listOf("#"),
blockComment = BlockComment("###", "###", nests = false),
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
),
Language.DART to
Rules(
keywords = KEYWORDS_DART,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.FISH to
Rules(
keywords = KEYWORDS_FISH,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// fish's single quotes escape only `\'` and `\\`, which is what "skip the character
// after a backslash" already does.
quotes = listOf(DOUBLE, SINGLE),
),
Language.GO to
Rules(
keywords = KEYWORDS_GO,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)),
),
Language.JAVA to
Rules(
keywords = KEYWORDS_JAVA,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.JAVASCRIPT to
Rules(
keywords = KEYWORDS_JAVASCRIPT,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
),
Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)),
Language.KOTLIN to
Rules(
keywords = KEYWORDS_KOTLIN,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.PERL to
Rules(
keywords = KEYWORDS_PERL,
lineComments = listOf("#"),
quotes = listOf(DOUBLE, SINGLE),
),
Language.PHP to
Rules(
keywords = KEYWORDS_PHP,
lineComments = listOf("//", "#"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.PYTHON to
Rules(
keywords = KEYWORDS_PYTHON,
lineComments = listOf("#"),
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
attributes = Attributes.AT_WORD,
),
Language.RON to
Rules(
keywords = KEYWORDS_RON,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(DOUBLE, SINGLE),
attributes = Attributes.HASH_BRACKET,
rawStrings = true,
),
Language.RUBY to
Rules(
keywords = KEYWORDS_RUBY,
lineComments = listOf("#"),
quotes = listOf(DOUBLE, SINGLE),
),
Language.RUST to
Rules(
keywords = KEYWORDS_RUST,
lineComments = listOf("//"),
blockComment = NESTING,
// No `'` here: [Rules.lifetimes] decides when one opens a character literal.
quotes = listOf(DOUBLE),
attributes = Attributes.HASH_BRACKET,
rawStrings = true,
lifetimes = true,
),
Language.SHELL to
Rules(
keywords = KEYWORDS_SHELL,
lineComments = listOf("#"),
lineCommentsAtWordStart = true,
// A shell's single quotes are literal: `'a\'` is not one string.
quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)),
),
Language.SWIFT to
Rules(
keywords = KEYWORDS_SWIFT,
lineComments = listOf("//"),
blockComment = NESTING,
quotes = listOf(TRIPLE_DOUBLE, DOUBLE),
attributes = Attributes.AT_WORD,
),
Language.TOML to
Rules(
keywords = KEYWORDS_TOML,
lineComments = listOf("#"),
quotes =
listOf(
TRIPLE_DOUBLE,
Quote("'''", "'''", escapes = false),
DOUBLE,
Quote("'", "'", escapes = false),
),
attributes = Attributes.LINE_BRACKET,
),
Language.TYPESCRIPT to
Rules(
keywords = KEYWORDS_TYPESCRIPT,
lineComments = listOf("//"),
blockComment = C_STYLE,
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
attributes = Attributes.AT_WORD,
),
)
}
/**
* The keyword sets.
*
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
* (Apache-2.0), the library this scanner replaced, so that no fence which is coloured today turns
* plain. Entries that are not plain words were dropped -- Kotlin's `as?`, Swift's `#if` family,
* Ruby's `defined?` -- because the word scanner cannot reach them.
*/
private fun words(list: String): Set<String> =
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
private val KEYWORDS_C =
words(
"""auto break case char const continue default do double else enum extern float for goto if
int long register return short signed sizeof static struct switch typedef union unsigned
void volatile while"""
)
private val KEYWORDS_CPP =
words(
"""asm auto bool break case catch char class const const_cast continue default delete do
double dynamic_cast else enum explicit export extern false float for friend goto if inline
int long mutable namespace new operator private protected public register reinterpret_cast
return short signed sizeof static static_cast struct switch template this throw true try
typedef typeid typename union unsigned using virtual void volatile wchar_t while"""
)
private val KEYWORDS_CSHARP =
words(
"""abstract as base bool break byte case catch char checked class const continue decimal
default delegate do double else enum event explicit extern false finally fixed float for
foreach goto if implicit in int interface internal is lock long namespace new null object
operator out override params private protected public readonly ref return sbyte sealed short
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
unsafe ushort using virtual void volatile while"""
)
private val KEYWORDS_COFFEESCRIPT =
words(
"""Infinity NaN and arguments await break by case catch class continue debugger delete defer
default do else export extends false finally for function if import in instanceof is isnt
let loop new no not null of on or package return super switch this throw true try typeof
unless undefined var wait when with yield"""
)
private val KEYWORDS_DART =
words(
"""abstract as assert async await base break case catch class const continue covariant
default deferred do dynamic else enum export extends external factory false final finally
for get if implements import in interface is late library mixin new null on operator part
required rethrow return sealed set show static super switch this throw true try var void
when with while yield"""
)
/**
* fish is not in the library at all, so its fences are drawn plain today. The list is the shell's
* own words, which is what a fish fence is mostly made of.
*/
private val KEYWORDS_FISH =
words(
"""and begin break builtin case command continue else end exec for function if in not or
return switch while set echo test string math read source"""
)
private val KEYWORDS_GO =
words(
"""break case chan const continue default defer else fallthrough false for func go goto if
import interface map package range return select struct switch true type var"""
)
private val KEYWORDS_JAVA =
words(
"""abstract assert boolean break byte case catch char class const continue default do double
else enum extends final finally float for goto if implements import instanceof int interface
long native new null package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while"""
)
private val KEYWORDS_JAVASCRIPT =
words(
"""async await boolean break case catch class const continue debugger default delete do else
enum export extends false finally for function if implements import in instanceof interface
let new null package private protected public return super switch this throw true try typeof
var void while with yield"""
)
private val KEYWORDS_JSON = words("true false null")
private val KEYWORDS_KOTLIN =
words(
"""actual abstract annotation as break by catch class companion const constructor continue
coroutine crossinline data delegate dynamic do else enum expect external false final finally
for fun get if import in infix inline interface internal is lazy lateinit native null object
open operator out override package private protected public reified return sealed set super
suspend tailrec this throw true try typealias typeof val var vararg when while yield"""
)
private val KEYWORDS_PERL =
words(
"""__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
use while xor"""
)
private val KEYWORDS_PHP =
words(
"""__halt_compiler abstract and array as break callable case catch class clone const continue
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
endwhile eval exit extends final finally fn for foreach function global goto if implements
include include_once instanceof insteadof interface isset list match new or print private
protected public require require_once return static switch throw trait try unset use var
while xor yield"""
)
private val KEYWORDS_PYTHON =
words(
"""False True and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with
yield"""
)
/** RON is not in the library either; these are the words a RON file can hold. */
private val KEYWORDS_RON = words("true false Some None inf NaN")
private val KEYWORDS_RUBY =
words(
"""__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
else elsif end ensure false for if in module next nil not or redo rescue retry return self
super then true undef unless until when while yield"""
)
private val KEYWORDS_RUST =
words(
"""as async await break const continue crate dyn else enum extern false fn for if impl in
let loop match mod move mut pub ref return Self self static struct super trait true type
union unsafe use where while abstract become box do final macro override priv try typeof
unsized virtual yield"""
)
private val KEYWORDS_SHELL =
words(
"""alias bg bind break builtin caller cd command compgen complete compopt continue declare
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
test"""
)
private val KEYWORDS_SWIFT =
words(
"""_ associatedtype class deinit enum extension fileprivate func import init inout internal
let open operator private precedencegroup protocol public rethrows static struct subscript
typealias var break case catch continue default defer do else fallthrough for guard if in
repeat return throw switch where while Any as await false is nil self Self super throws true
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
nonmutating optional override postfix precedence prefix Protocol required right set some Type
unowned weak willSet"""
)
/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */
private val KEYWORDS_TOML = words("true false inf nan")
private val KEYWORDS_TYPESCRIPT =
words(
"""abstract as asserts await break case catch class const constructor continue debugger
default delete do else enum export extends false finally for from function get if implements
import in infer instanceof interface is keyof let module namespace new null number object
package private protected public readonly require global return set static string super
switch this throw true try type typeof undefined unique unknown var void while with yield"""
)
@@ -1,28 +0,0 @@
package com.example.aiapp
/**
* What a screen knows about something it had to fetch: still finding out, got it, or couldn't.
*
* Three states rather than a value alongside a nullable error, because "we couldn't find out" must
* not share a representation with "there is nothing" -- a failed fetch would otherwise render as an
* empty list, which is the one wrong answer that looks like a right one.
*
* [Loading] and [Error] carry no payload, so they are `LoadState<Nothing>` and this is covariant in
* [T]: one `LoadState.Loading` serves every screen.
*/
sealed class LoadState<out T> {
data object Loading : LoadState<Nothing>()
data class Loaded<out T>(val value: T) : LoadState<T>()
data class Error(val message: String) : LoadState<Nothing>()
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")
}
}
@@ -1,246 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.layout
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the composition below re-reads the
// stored settings.
private var settingsVersion by mutableIntStateOf(0)
// The session a notification tap asked for, or null if nothing has. The serial is what makes a
// second tap on the same session's notification a second request: without it the two compare
// equal and the composition below has nothing to react to.
private var openRequest by mutableStateOf<SessionOpenRequest?>(null)
private var opens = 0
// What another app shared into this one, for the same reason and with the same serial.
private var shareRequest by mutableStateOf<ShareRequest?>(null)
private var shares = 0
// Registered up front since permission launchers must be registered before the activity reaches
// STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
/**
* The service starts either way, and posts nothing if this is refused.
*
* Deliberately not gated on the answer: the permission can be granted later from Android's own
* settings, and a service that only ever started at the moment it was granted would stay down
* until the app was launched again.
*/
private val requestNotificationPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before anything else that could throw, so the first crash of a launch is caught too.
installCrashLog(this)
// Transparent status bar on every version; the Surface below paints through underneath it
// and content insets itself. Same reasoning as dev-updater's MainActivity.
enableEdgeToEdge()
// The `bench` build's entire purpose (P0, docs/RUST.md): open straight onto the session
// screen against BenchFixture's in-process fake backend, with no enrollment, no network
// permission, and no notification prompt -- none of them mean anything with no server and
// no real device to notify. See BenchFixture.kt and BenchNetwork.kt for how a screen built
// to talk to a real backend is made to talk to this instead. Still needs the same
// status/navigation-bar padding the ordinary flow below applies: edge-to-edge is the
// platform's own default from Android 15 on this app's targetSdk, with or without the call
// above, so skipping the padding here put the header's own buttons under the status bar --
// there to look at, but not there for `ui-trace`'s tap-by-label to land on.
if (BuildConfig.FIXTURE_MODE) {
installFixtureNetworkOnce()
BenchFixture.ensureLoaded(this)
setContent {
MaterialTheme(colorScheme = AiAppColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
SessionScreen(
settings = BenchFixture.settings,
summary = benchSessionSummary(),
onBack = { finish() },
onFiles = {},
)
}
}
}
}
return
}
// Dark status-bar icons only over a light background, decided from the scheme rather than
// fixed. It was hardcoded to `true`, which was right against the default light surface and
// became unreadable the moment the app wore Catppuccin Mocha.
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
AiAppColors.background.luminance() > 0.5f
// Android 17+ silently drops local-network traffic without this; requested up front because
// a denial is invisible at the socket layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
handleIntent(intent)
// After enrollment, so a first launch that arrives with a token starts the service with
// something to connect to rather than stopping it and waiting for the next launch.
NotificationService.sync(this)
setContent {
// Selection colours with the theme rather than at each place text is drawn: the
// transcript is one selection container, and a selection that ran from a reply into the
// code block under it would otherwise change colour halfway.
MaterialTheme(colorScheme = AiAppColors) {
CompositionLocalProvider(LocalTextSelectionColors provides AiAppSelectionColors) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier =
// Timed like the transcript times itself, and for the same reason:
// the frame's draw phase is where Compose's measurement lands, and
// a report saying "draw is high" cannot otherwise say whether the
// cost is the transcript or the chrome around it. The keyboard is
// the case that made it matter.
Modifier.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the app root",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the app root",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: the app root",
System.nanoTime() - started,
)
}
.fillMaxSize()
.statusBarsPadding()
// The gesture strip at the bottom of most phones. Without it
// the send row sits under the swipe area, where a tap is as
// likely to navigate away as to press a button.
//
// No imePadding here, deliberately: applied at the root it
// resizes this whole box on every frame of the keyboard
// animation, which re-measures, re-places and re-records every
// screen's entire tree per frame. Each screen takes the
// keyboard itself, so the per-frame cost is scoped to what
// actually moves.
.navigationBarsPadding()
) {
AppRoot(settingsVersion, openRequest, shareRequest)
}
}
}
}
}
}
/** The one session the `bench` build ever shows -- BenchFixture's session id, nothing else. */
private fun benchSessionSummary() =
SessionSummary(
id = BenchFixture.SESSION_ID,
setup = "bench",
setupName = "bench",
provider = "bench",
title = "P0 benchmark",
model = null,
keepsOwnTranscript = false,
permissionMode = null,
effort = null,
takesEffort = false,
imported = false,
notify = false,
autoResume = false,
autoResumeMessage = "",
resumeAt = null,
cwd = null,
contextTokens = null,
maxImageEdge = null,
usageProvider = null,
status = "idle",
lastActivity = 0.0,
subagents = 0,
)
// launchMode="singleTop": an enrollment scan, or a notification tapped while the app is open,
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
/**
* The one place an incoming intent is sorted into what it means.
*
* Three things arrive this way -- a share from another app, and an `aiapp://` URI that is
* either an enrollment code or a notification naming a session. The URIs are told apart by host
* rather than by two entry points, so a further kind is a branch here.
*/
private fun handleIntent(intent: Intent?) {
intent ?: return
sharedContent(intent, shares + 1)?.let { shared ->
shares = shared.serial
shareRequest = shared
return
}
val uri = intent.data ?: return
val sessionId = notifiedSessionId(uri)
if (sessionId != null) {
opens++
openRequest = SessionOpenRequest(sessionId, opens)
return
}
val settings = parseEnrollmentUri(uri)
if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
return
}
saveServerSettings(this, settings)
settingsVersion++
// Enrolling is the moment there is a backend to watch, and re-enrolling elsewhere is the
// moment the old one stops being it.
NotificationService.sync(this)
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
}
@@ -1,153 +0,0 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* The app's root: one title, and four views of the backend behind it.
*
* These were four screens reached by four words in a row under the title, and the row was already
* full. Tabs say the same thing in less space and say one more thing besides: that these are places
* to be rather than errands to run. Sessions, the machine's importable history, the models on it
* and the machines themselves are all *the same backend*, looked at four ways, and none is a step
* down from another. Settings still is, which is why it stays a pushed screen with its own Back.
*/
private enum class MainTab(val label: String) {
Sessions("Sessions"),
Import("Import"),
Models("Models"),
Setups("Setups"),
}
@Composable
fun MainScreen(
settings: ServerSettings,
reloadToken: Int,
/** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit,
) {
var tab by remember { mutableStateOf(MainTab.Sessions) }
var refreshToken by remember { mutableIntStateOf(0) }
// Coming back to the app asks again, on whichever tab is showing.
//
// What these four draw is a snapshot of a backend they are not connected to, so it is only as
// fresh as the last answer -- and a *failed* answer is the one that outstays its welcome. A
// phone that was away while the tunnel was down came back to "Couldn't reach the server"
// sitting at the top of a list the server would now answer for perfectly well. A stale failure
// is worse than a stale list: it is a claim about right now.
//
// Through the same token the Refresh button uses, so this is one instruction the tabs already
// understand. Not on the first entry: the tab composing already asks.
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
var opening = true
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
if (!opening) refreshToken++
opening = false
}
}
// A tab the app put over the list has to step back to it rather than fall through to the system
// default, which closes the app. Nested inside AppRoot's handler, so it wins while enabled.
BackHandler(enabled = tab != MainTab.Sessions) { tab = MainTab.Sessions }
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
) {
Text(
"AI Sessions",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
// Glyphs rather than the words they replaced: neither ever changes, both are read
// faster than they are spelled, and together they take the width that let the title
// keep its own line. They sit on the title's row because they act on the whole screen.
//
// Flush against each other: a glyph button carries its own padding, so two side by side
// already have two rings between their marks.
Row {
GlyphButton(REFRESH_GLYPH, "Refresh", { refreshToken++ })
GlyphButton(SETTINGS_GLYPH, "Settings", onSettings)
}
}
// What is waiting to be attached, and what to do about it. Said here because the list below
// is where the choice is made, and a share that arrived with nothing on screen saying so
// would read as a tap that did nothing.
share?.let {
Text(
it.summary() + " -- open the session it belongs in.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.shapes.small,
)
.padding(12.dp),
)
}
// Primary rather than the plain TabRow, which is deprecated in favour of the two that say
// where they sit: these are the app's top-level destinations.
PrimaryTabRow(selectedTabIndex = tab.ordinal) {
MainTab.entries.forEach { entry ->
Tab(
selected = tab == entry,
onClick = { tab = entry },
text = { Text(entry.label) },
)
}
}
// Refreshing means "ask again about what I am looking at", so the button feeds the tab that
// is showing. The token from above means something else already changed what these show;
// the two are the same instruction, so they are summed rather than tracked apart.
val token = reloadToken + refreshToken
when (tab) {
MainTab.Sessions ->
SessionListScreen(
settings = settings,
reloadToken = token,
onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn,
)
MainTab.Import ->
ImportScreen(settings = settings, reloadToken = token, onImported = onImported)
MainTab.Models -> ModelsScreen(settings = settings, reloadToken = token)
MainTab.Setups -> SetupsScreen(settings = settings, reloadToken = token)
}
}
}
@@ -1,667 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalImageTransformer
import com.mikepenz.markdown.compose.LocalMarkdownAnimations
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.LocalReferenceLinkHandler
import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.NoOpImageTransformerImpl
import com.mikepenz.markdown.model.State
import com.mikepenz.markdown.model.markdownAnimations
import com.mikepenz.markdown.model.markdownDimens
import com.mikepenz.markdown.model.markdownPadding
import com.mikepenz.markdown.model.parseMarkdown
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* [text] drawn as its pieces, one under the other; see [Piece].
*
* [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* message. And its pieces get a layer each, so only the piece that changed is re-recorded. That is
* worth a great deal while every delta invalidates the message and worth nothing once it stops
* changing -- and it is not free: each layer is a layout node and a display list held for the life
* of the row, and live node count is what the transcript's per-frame cost scales with.
*/
@Composable
fun MarkdownText(
text: String,
replies: ParsedReplies,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
val segments =
if (live) liveSegments(text)
else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
Column(modifier.fillMaxWidth()) {
var previous: Piece? = null
var previousSegment: Segment? = null
segments.forEachIndexed { at, segment ->
val nextContinues = segments.getOrNull(at + 1)?.continues == true
// Only the tail is still being written; a frozen segment is finished text that happens
// to sit in a live reply, and it takes its colours now.
MarkdownRoot(segment.parse, replies, streaming = live && at == segments.lastIndex) {
segment.pieces.forEachIndexed { index, piece ->
val gap =
when {
previousSegment == null -> 0.dp
previousSegment !== segment ->
if (segment.continues) 0.dp else BLOCK_SPACING
else -> gapBefore(previous, piece)
}
// Keyed by where the piece starts in the message rather than by its position in
// this column, so a delta landing in the last block leaves every other piece's
// composition alone -- and a block keeps its key when it freezes.
key(segment.start, piece) {
MarkdownPiece(
segment.parse,
segment.text,
piece,
Modifier.padding(top = gap)
.then(if (live) Modifier.graphicsLayer() else Modifier)
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: one block",
System.nanoTime() - started,
)
},
continuesList = segment.continues && index == 0,
listContinues = nextContinues && index == segment.pieces.lastIndex,
)
}
previous = piece
previousSegment = segment
}
}
}
}
}
/**
* A stretch of a message with a parse of its own: the whole of a settled message, or one block, the
* finished items of one list, or the unfinished tail of a live one. [start] is where [text] begins
* in the message. [continues] says the first piece is an item of the list the segment before it
* ended with, so the two draw as one list.
*/
private class Segment(
val text: String,
val start: Int,
val parse: State,
val pieces: List<Piece>,
val continues: Boolean = false,
)
/**
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
* incrementally off it for every delta afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back -- seen with five replies on
* screen at once, the whole conversation shrunk to fit a single screen.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
val parsed = remember {
mutableStateOf(
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
)
}
LaunchedEffect(text) {
if (parsed.value.text == text) return@LaunchedEffect
val previous = parsed.value
parsed.value =
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
}
}
return parsed.value.segments
}
/**
* A reply still arriving, parsed a block at a time.
*
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
*
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it. So every block but the last is
* [frozen] with the parse that finished it, and only the tail is parsed again.
*
* A list is cut once more, at its last item, by the same reasoning one level down. Without this a
* reply that is one long list -- forty sources -- parsed the whole list per delta. The item the cut
* lands on has to have begun in earnest: a bare `-` is an empty item now and the first character of
* a paragraph line once `-x` arrives.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it. The link draws as its brackets until the reply settles and is parsed whole by [warm].
*/
private class LiveParse(
val text: String,
private val frozen: List<Segment>,
/** How much of [text] the frozen segments cover; the tail starts here. */
private val consumed: Int,
private val tail: Segment,
) {
val segments: List<Segment>
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset -- starts
// over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
val all = pieces(parse)
val open = (parse as? State.Success)?.let { openPiece(it, all) }
if (open == null) {
return LiveParse(
next,
frozen,
consumed,
Segment(tailText, consumed, parse, all, tail.continues),
)
}
val done =
all.subList(0, all.indexOf(open))
.groupBy { it.block }
.values
.mapIndexed { at, pieces ->
Segment(
tailText,
consumed,
parse,
pieces,
continues = at == 0 && tail.continues,
)
}
// Cut at the start of the open piece's line rather than at the piece, so an indented item
// or block keeps the indentation the parse of the rest reads its nesting from.
val node =
parse.node.children[open.block].let {
if (open.item == Piece.WHOLE_BLOCK) it else it.listItems()[open.item]
}
val cut = tailText.lastIndexOf('\n', node.startOffset) + 1
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse), continues = open.item > 0),
)
}
/**
* The piece of the tail still being written: the last item of a list of several, or the first
* piece of the last block when there is more than one. Null when nothing before it is finished.
*/
private fun openPiece(parse: State.Success, all: List<Piece>): Piece? {
val last = all.lastOrNull() ?: return null
val lastBlockStart = all.indexOfFirst { it.block == last.block }
return when {
last.item > 0 && parse.node.children[last.block].listItems()[last.item].hasBegun -> last
lastBlockStart > 0 -> all[lastBlockStart]
else -> null
}
}
/** Whether a list item holds anything beyond its marker yet. */
private val ASTNode.hasBegun: Boolean
get() = children.any { it.type !in MARKER_TOKENS }
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
private val MARKER_TOKENS =
setOf(
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
MarkdownTokenTypes.WHITE_SPACE,
MarkdownTokenTypes.EOL,
)
}
}
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
@Composable
fun MarkdownPiece(
text: String,
piece: Piece,
replies: ParsedReplies,
modifier: Modifier = Modifier,
) {
// Remembered so a message the flatten drew before [warm] reached it is parsed once here, not
// once per composition.
val parse = remember(text) { replies.of(text) }
MarkdownRoot(parse, replies) { MarkdownPiece(parse, text, piece, modifier) }
}
/**
* The renderer's own environment -- its colours, type scale, dimensions, component table and
* reference links -- around whatever draws pieces of [parse].
*
* The parsing is the library's: markdown is somebody else's specification, and a hand-written
* parser would get the edge cases wrong one case at a time. So is the environment: the element
* composables its dispatch reaches read these locals, and providing them once here is what lets a
* piece be drawn anywhere -- in a message's column, or as one item of the transcript list.
*
* The locals are provided directly rather than through the renderer's `Markdown()` composable,
* which was the last of its composables on the hot path and was here only to provide them. So
* nothing between a piece and the screen is the library's but the leaf composables named in the
* component table.
*
* Colours come from the theme rather than the renderer's defaults. Nothing here picks one of its
* own.
*
* [streaming] says this parse is the part of a reply still being written, which only the fences
* care about: lexing is proportional to how much code there is. Measured streaming a two-hundred-
* line Kotlin fence: **13.7 seconds** of lexing across the turn, 211 of them, the worst 177ms --
* for colours on text being replaced as fast as they were computed. So a fence still being written
* is drawn plain and takes its colours when the block freezes.
*/
@Composable
private fun MarkdownRoot(
parse: State,
replies: ParsedReplies,
streaming: Boolean = false,
content: @Composable () -> Unit,
) {
if (parse !is State.Success) {
// Nothing below needs the environment; [MarkdownPiece] draws the words plainly.
content()
return
}
val body = MaterialTheme.typography.bodyLarge
CompositionLocalProvider(
LocalReferenceLinkHandler provides parse.referenceLinkHandler,
LocalMarkdownPadding provides markdownPadding(),
// Read by the renderer's own text composable, which no paragraph reaches any more, and by
// its checkbox. Provided so a path that does reach them draws no image rather than failing
// to compose.
LocalImageTransformer provides remember { NoOpImageTransformerImpl() },
LocalMarkdownAnimations provides markdownAnimations(),
LocalMarkdownColors provides
markdownColor(
text = MaterialTheme.colorScheme.onSurface,
dividerColor = MaterialTheme.colorScheme.outlineVariant,
// The dark surface every verbatim thing in this app sits on -- and the tool call
// above this reply, which now matches. `surfaceVariant` was exactly a card's own
// fill, so a fenced block inside a tool call had no background at all.
codeBackground = rawSurface,
// The same colour. Not drawn by the renderer as a span background but by
// [LinkedText] behind the text, so a selection lands on top of it -- see
// `appendCodeChip`.
inlineCodeBackground = rawSurface,
// The same tint a code block gets, rather than the renderer's 2%-alpha default: two
// adjacent tints that differ by a fiftieth read as one flat block on a phone.
tableBackground = MaterialTheme.colorScheme.surfaceVariant,
),
LocalMarkdownTypography provides
markdownTypography(
// A ladder that starts near the body text and descends, because these are headings
// inside a chat message rather than the top of a document. The renderer's defaults
// are the Material *display* styles -- `#` came out at 57sp, bigger than this app's
// own screen titles. Every step is a different size, so two levels of nesting never
// draw the same.
h1 = MaterialTheme.typography.headlineSmall,
h2 = MaterialTheme.typography.titleLarge,
h3 = MaterialTheme.typography.titleMedium,
h4 = MaterialTheme.typography.titleSmall,
h5 = MaterialTheme.typography.labelMedium,
h6 = MaterialTheme.typography.labelSmall,
text = body,
paragraph = body,
ordered = body,
bullet = body,
list = body,
table = body,
// Code in a monospace face, in the ordinary text colour. The face and the tinted
// background are what say "this is code"; colour is not, and it used to be green --
// the palette's colour for a *literal*. A block of code is not a literal, and
// painting all of it green said the whole block was one. Where a literal really
// does appear inside code, what should colour it is a syntax highlighter.
code =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurface,
),
inlineCode =
body.copy(
fontFamily = FontFamily.Monospace,
// Unspecified so an inline span keeps the size of the line it sits in.
fontSize = TextUnit.Unspecified,
color = MaterialTheme.colorScheme.onSurface,
),
textLink =
TextLinkStyles(
style =
body
.copy(
color = linkColor,
textDecoration = TextDecoration.Underline,
)
.toSpanStyle()
),
),
LocalMarkdownDimens provides
markdownDimens(
// Half the renderer's 16dp. Padding is charged on both sides of every cell, so at
// the default a fifth of the narrowest column went on space rather than on words.
tableCellPadding = 8.dp,
// What a column narrows to before the table starts scrolling sideways instead. It
// is the floor, not the width: a table with room to spare spreads across it.
//
// Down from the renderer's 160dp, and the number is a measurement rather than a
// taste. A phone is about 410-450dp wide and a card takes some of that, so 160dp
// makes even a three-column table scroll, while 136dp fits three across the phone
// this app is read on. Four and up still scroll, which is the right answer for
// genuinely too many columns. This is the widest minimum that keeps three on
// screen.
tableCellWidth = 136.dp,
),
LocalMarkdownComponents provides
markdownComponents(
// The m3 renderer's own default, restored: supplying `components` at all replaces
// the whole set, and this is the only member the Material layer overrides.
checkbox = { MarkdownCheckBox(it.content, it.node, it.typography.text) },
// Everything that draws a run of text, so a link is a span rather than a node --
// see [LinkedText]. Setext headings take the same styles as `#` and `##`.
text = { LinkedText(it, it.typography.text) },
paragraph = { LinkedText(it, it.typography.paragraph) },
heading1 = { LinkedHeading(it, it.typography.h1) },
heading2 = { LinkedHeading(it, it.typography.h2) },
heading3 = { LinkedHeading(it, it.typography.h3) },
heading4 = { LinkedHeading(it, it.typography.h4) },
heading5 = { LinkedHeading(it, it.typography.h5) },
heading6 = { LinkedHeading(it, it.typography.h6) },
setextHeading1 = { LinkedHeading(it, it.typography.h1) },
setextHeading2 = { LinkedHeading(it, it.typography.h2) },
// Lists are ours wherever the renderer's dispatch meets one -- inside a quote -- so
// they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { LinkedTable(it.content, it.node, it.typography.table) },
// Code is highlighted the way a tool call's input is; see [CodeFence].
codeFence = {
CodeFence(it.content, it.node, it.typography.code, replies, streaming)
},
codeBlock = {
CodeBlock(it.content, it.node, it.typography.code, replies, streaming)
},
),
content = content,
)
}
/**
* A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor, so the table is at least columns-times-floor wide; narrower than the
* room it has, it spreads to fill it, and wider, it scrolls sideways rather than squeezing. The
* renderer decided that with a `BoxWithConstraints`, which is a subcomposition; here it is one
* layout modifier. `fillMaxWidth` fixes the minimum width to the room available, the horizontal
* scroll passes that minimum through while lifting the maximum to unbounded, and the modifier after
* it reads the minimum back and sizes the rows to the larger of that and the floor.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
val dimens = LocalMarkdownDimens.current
val colors = LocalMarkdownColors.current
val columns =
remember(node) {
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
it.type == GFMTokenTypes.CELL
} ?: 0
}
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
val floor = dimens.tableCellWidth * columns
Column(
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.layout { measurable, constraints ->
val width = maxOf(constraints.minWidth, floor.roundToPx())
val placeable =
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
layout(width, placeable.height) { placeable.place(0, 0) }
}
) {
var rowIndex = 1
node.children.forEach { child ->
when (child.type) {
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
}
}
}
}
/**
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
*
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: an elided cell looks like a short one, so a table of
* measurements reads as a table of plausible shorter measurements. And they draw a link in a cell
* as its own layout node, the cost [LinkedText] exists to avoid.
*
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one. What the wrapping
* does *not* do is make a wide table fit; [LinkedTable] scrolls it instead.
*
* The semantics are the renderer's: each cell is an item of the table's collection.
*/
@Composable
private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
val padding = LocalMarkdownDimens.current.tableCellPadding
val header = rowIndex == 0
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
row.children
.filter { it.type == GFMTokenTypes.CELL }
.forEachIndexed { column, cell ->
LinkedText(
content,
cell,
cellStyle,
Modifier.padding(padding).weight(1f).semantics {
if (header) heading()
collectionItemInfo =
CollectionItemInfo(
rowIndex = rowIndex,
rowSpan = 1,
columnIndex = column,
columnSpan = 1,
)
},
)
}
}
}
/**
* Replies parsed before the row that draws them is composed.
*
* Parsing is the expensive half of drawing a reply, and it is expensive in proportion to how much
* was written. Measured against a real Claude Code transcript on the emulator, one message took
* **51ms** and several took 10-25ms, against 4.6ms for the short synthetic replies this was first
* tuned on -- so a page of history landing composed several rows that each stalled the frame.
*
* Nothing here changes what a row does when it has no answer waiting: it parses inline, because a
* row measured at nothing before its real height collapses the transcript above it. The point is
* only that by the time the reader scrolls to a row, the answer is usually already made.
*
* A miss is not stored, and that is what bounds this: the map holds one entry per message a page
* warmed, so a reply still streaming cannot fill it with hundreds of copies of itself.
*/
@Stable
class ParsedReplies {
private val parsed = ConcurrentHashMap<String, State>()
/**
* How each message divides into pieces, cached beside its parse: [transcriptUnits] asks per
* fold, and walking the tree again each time is proportional to the message.
*/
private val pieces = ConcurrentHashMap<String, List<Piece>>()
/**
* How each message divides into prose and memory notes, cached for the same reason: the regex
* scan behind [messageParts] is proportional to the message.
*/
private val parts = ConcurrentHashMap<String, List<MessagePart>>()
private val chunks = ConcurrentHashMap<String, List<String>>()
/**
* Each fence's coloured text, keyed by its language and code.
*
* Beside the parses for the same reason and at the same cost: lexing is proportional to how
* much code was written -- a two-hundred-line Kotlin fence measured 174ms on the emulator --
* and a lazy list drops the composition of a block that scrolls away, so a `remember` inside
* the fence paid that again every time the reader came back to it. Six times in one scroll,
* measured.
*/
private val highlights = ConcurrentHashMap<String, AnnotatedString>()
private val ready = ConcurrentHashMap.newKeySet<String>()
/** The pieces of [text], from its parse -- made now if [warm] has not made it. */
fun piecesOf(text: String): List<Piece> =
pieces.computeIfAbsent(text) {
DebugStats.timed("markdown cut into pieces") { pieces(of(it)) }
}
/** How a long user message divides into slices; cached for the same reason as [piecesOf]. */
fun chunksOf(text: String): List<String> =
chunks.computeIfAbsent(text) {
DebugStats.timed("user message cut into slices") { userChunks(it) }
}
/**
* Whether [warm] has made everything drawing [text] as pieces will look up.
*
* What the flatten asks before drawing a reply that way. Cutting costs a parse of the whole
* message and the flatten runs on the composing thread, so a reply not marked yet stays whole
* until the screen has warmed it. An explicit mark rather than a peek into the parse cache,
* because a message with memory notes is warmed as its *parts*: nothing ever parses its full
* text, and inferring readiness from the cache left exactly that message unsplittable forever.
*/
fun splitReady(text: String): Boolean = text in ready
/** The other half of [splitReady]; [warm] calls it once a message's parses exist. */
fun markSplitReady(text: String) {
ready.add(text)
}
fun partsOf(text: String): List<MessagePart> =
parts.computeIfAbsent(text) {
DebugStats.timed("message cut into parts") { messageParts(it) }
}
/**
* [code] coloured for [language] -- the answer made ahead, or one made now. The key carries the
* language, because the same code lexes differently under two of them.
*/
fun highlighted(code: String, language: Language?): AnnotatedString =
if (language == null) AnnotatedString(code)
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
/** The parse of [text] -- the one made ahead, or one made now. */
fun of(text: String): State =
parsed[text]?.also { DebugStats.count("markdown ready") }
?: DebugStats.timed("markdown parsed while composing") { parseMarkdown(text) }
/**
* Parses whatever is not held yet. Call off the composing thread; that is the whole point.
*
* Suspending, and yielding between messages, because "off the composing thread" is not the same
* as "free". A page of history arrives as hundreds of parses at once -- 1.5 seconds of them in
* a twelve second scroll on a Pixel 9 Pro XL -- and on the default dispatcher that is every
* core busy, with the frame's own thread waiting for one: 21ms of `waited` at the 90th
* percentile.
*/
suspend fun warm(texts: List<String>) {
texts.forEach { text ->
val parse =
parsed.computeIfAbsent(text) {
DebugStats.timed("markdown warmed") { parseMarkdown(it) }
}
// The fences too, and here rather than in a pass of its own: they are found in the
// parse this just made, and lexing one is the same kind of cost as parsing the message
// it is in.
fences(parse).forEach { (code, language) -> highlighted(code, language) }
}
}
/** Everything these described is gone; see [ParsedReplies]. */
fun clear() {
parsed.clear()
pieces.clear()
parts.clear()
chunks.clear()
highlights.clear()
ready.clear()
}
}
@@ -1,320 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import com.mikepenz.markdown.annotator.AnnotatorSettings
import com.mikepenz.markdown.annotator.annotatorSettings
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import com.mikepenz.markdown.utils.resolveImageAlt
import com.mikepenz.markdown.utils.resolveImageLink
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* A paragraph, heading or bare text whose links are spans of the text rather than nodes of their
* own.
*
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one annotation
* per link. Measured on the emulator against the same paragraphs with each link replaced by its
* label and address as plain words -- *more* text, the same gestures -- the linked version cost
* five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time.
*
* Here a link is the link colour and underline, a string annotation carrying its address, and one
* tap detector for the whole text that asks the layout which character was under the finger. What
* that gives up is a link being its own accessibility node with a pressed state; the app's link
* style never defined a pressed style, so nothing visible changes.
*
* Every block the renderer dispatches through its component table comes here, and so does every
* table cell. Reference-style links are the one kind still drawn the renderer's way.
*
* An image is a link too, carrying its alt text. The app has no image loader and the renderer's
* transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the
* model put something. The link says what was there and where, and opens it.
*/
@Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle) {
LinkedText(model.content, model.node, style)
}
/**
* A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or
* `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it
* does not know, so handed the heading node itself it draws an empty line.
*/
@Composable
fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) {
val words =
model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT)
?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT)
?: model.node
LinkedText(model.content, words, style, Modifier.semantics { heading() })
}
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
@Composable
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
val settings = plainLinkSettings()
val text =
remember(content, node, style) {
content.buildMarkdownAnnotatedString(node, style, settings)
}
val uriHandler = LocalUriHandler.current
val onPlainTap = LocalMarkdownTap.current
val layout = remember { Ref<TextLayoutResult>() }
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second
// one has to be answered from inside the text rather than left to the card.
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
awaitFirstDown(requireUnconsumed = false)
// A tap and nothing else. Null when the gesture became somebody else's -- a
// scroll, or a press held past the long-press timeout, which is how a selection
// starts. The timeout is the load-bearing half: without it a press held for a
// second and released was still an up with nothing consumed, so holding a peer
// message to select from it shut the card instead.
val up =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
} ?: return@awaitEachGesture
val url = text.linkAt(layout.value, up.position)
when {
url != null -> {
up.consume()
uriHandler.openUri(url)
}
onPlainTap != null -> {
up.consume()
onPlainTap()
}
}
}
},
style = style,
color = { color },
onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
)
}
/**
* What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in,
* usually -- or null where a plain tap means nothing.
*
* A composition local because there is nowhere else to put it. The paragraphs of a message are
* composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours
* to pass a parameter through.
*
* It exists because a pointer-input node over the glyphs takes the tap and the card's own click
* handler never sees it. Measured against an opened peer message: with a handler on the text --
* consuming or not -- a tap on its words did nothing at all, and with the handler removed the same
* tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless
* the words do the shutting.
*
* Provided as a value that outlives a recomposition, since a fresh lambda per composition would
* invalidate every paragraph reading it.
*/
val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null }
/**
* [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the
* behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text
* under it on every recomposition of the card.
*/
@Composable
fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit {
val latest = rememberUpdatedState(onTap)
return remember { { latest.value() } }
}
/**
* The address under [position], if a link's glyph is there rather than merely nearest to it.
*
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
* glyph names the character after it; the glyph under the finger is the one on either side of that
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
* end of a line ending in a link opens nothing.
*/
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
layout ?: return null
val caret = layout.getOffsetForPosition(position)
val glyph =
(caret - 1..caret).firstOrNull {
it in 0 until length && layout.getBoundingBox(it).contains(position)
} ?: return null
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
}
private const val LINK_URL = "url"
/**
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* but with no background of its own -- if it is a code span; false leaves anything else to the
* renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the
* text is under both, which is where a fenced block's box already is.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn -- the same rule the selection rectangle obeys, so the two agree.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/
@Composable
private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember {
markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
}
return annotatorSettings(annotator = annotator).also { cell.value = it }
}
/**
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
* `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer.
*/
private fun appendPlainLink(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
val destination: String
/** The label's own inline nodes, when it has markup of its own to draw. */
var label: List<ASTNode>? = null
/** Plain words for the label; the address itself when there are none. */
var words: String? = null
when (node.type) {
MarkdownElementTypes.INLINE_LINK -> {
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
destination =
node
.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
?.getUnescapedTextInNode(content)
?.removeSurrounding("<", ">") ?: return false
// The brackets are the first and last children of the label.
label = text.children.drop(1).dropLast(1)
}
MarkdownElementTypes.AUTOLINK ->
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content)
MarkdownElementTypes.IMAGE -> {
destination =
node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false
words = node.resolveImageAlt(content)
}
else -> return false
}
builder.pushStringAnnotation(LINK_URL, destination)
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings)
else builder.append(words ?: destination)
builder.pop()
builder.pop()
return true
}
@@ -1,244 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.MarkdownElement
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.model.State
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
companion object {
const val WHOLE_BLOCK = -1
}
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
*/
fun pieces(parse: State): List<Piece> {
val success = parse as? State.Success ?: return listOf(Piece(0))
val out = ArrayList<Piece>()
success.node.children.forEachIndexed { at, node ->
when {
node.getTextInNode(success.content).isBlank() -> {}
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
else -> out += Piece(at)
}
}
return out
}
/**
* The room above [piece] when it follows [previous] in the same message: none between two items of
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
* of a message takes the message's gap, which is the caller's to know.
*/
fun gapBefore(previous: Piece?, piece: Piece): Dp =
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
parse: State,
text: String,
piece: Piece,
modifier: Modifier = Modifier,
continuesList: Boolean = false,
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
val node = parse.node.children[piece.block]
if (piece.item == Piece.WHOLE_BLOCK) {
Box(modifier) {
MarkdownElement(
node,
LocalMarkdownComponents.current,
parse.content,
includeSpacer = false,
)
}
} else {
val items = node.listItems()
MarkdownListItem(
content = parse.content,
list = node,
item = items[piece.item],
index = piece.item,
first = piece.item == 0 && !continuesList,
last = piece.item == items.lastIndex && !listContinues,
depth = 0,
modifier = modifier,
)
}
}
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
val items = list.listItems()
Column(modifier) {
items.forEachIndexed { index, item ->
MarkdownListItem(
content,
list,
item,
index,
first = index == 0,
last = index == items.lastIndex,
depth = depth,
)
}
}
}
/**
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
content: String,
list: ASTNode,
item: ASTNode,
index: Int,
first: Boolean,
last: Boolean,
depth: Int,
modifier: Modifier = Modifier,
) {
val padding = LocalMarkdownPadding.current
val typography = LocalMarkdownTypography.current
val components = LocalMarkdownComponents.current
// A task item's box sits right after the bullet: `- [ ] text`.
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
Row(
modifier
.semantics { isTraversalGroup = true }
.fillMaxWidth()
.padding(
start = padding.listIndent * depth,
top = padding.listItemTop + if (first) padding.list else 0.dp,
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
)
) {
if (checkbox != null) {
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
Marker("${list.startNumber(content) + index}. ", typography.ordered)
} else {
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
}
Column {
item.children.forEach { child ->
when (child.type) {
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
GFMTokenTypes.CHECK_BOX -> {}
MarkdownElementTypes.ORDERED_LIST,
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
else -> MarkdownElement(child, components, content, includeSpacer = false)
}
}
}
}
}
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
@Composable
private fun Marker(text: String, style: TextStyle) {
BasicText(text, style = style.copy(color = listMarkerColor))
}
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("", "", "")
internal val ASTNode.isList: Boolean
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
it.type == MarkdownElementTypes.LIST_ITEM
}
/** Where an ordered list counts from: the number its first item was written with. */
private fun ASTNode.startNumber(content: String): Int =
findChildOfType(MarkdownElementTypes.LIST_ITEM)
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
?.getTextInNode(content)
?.takeWhile(Char::isDigit)
?.toString()
?.toIntOrNull() ?: 1
@@ -1,450 +0,0 @@
package com.example.aiapp
/**
* Markdown read into the spans that carry a colour -- a ```markdown fence in a reply, and a `.md`
* file in the viewer.
*
* Its own scanner rather than a row of [Rules] because markdown has neither keywords nor strings:
* what a character means depends on where it sits. A `#` opens a heading at the start of a line and
* is an ordinary character three words in; a `*` opens emphasis only if something closes it on the
* same line. The token scanner cannot ask either question.
*
* Structure is read a line at a time and each line's prose left to right, so every decision is made
* inside one line -- except the two that are not. A fenced block is state carried forward, so an
* unclosed fence colours the rest of the text, which is what it looks like while somebody is
* writing it. A table is found by its delimiter row (`|---|---|`), the only line of one that cannot
* be anything else, and its header is the line before that -- the one place here that looks ahead.
*
* What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is
* one, four spaces after a bullet is a list item's second paragraph, and the two are told apart by
* what came before. Colouring the wrong one as code is a mistake the reader cannot see.
*
* Like [scan], the spans come out ordered, non-overlapping and inside the text by construction.
*/
fun scanMarkdown(code: String): List<Span> = MarkdownScanner(code).run()
/** The characters an unordered list may be bulleted with. */
private const val BULLETS = "-*+"
/** The characters a thematic break, or a setext heading's underline, can be drawn with. */
private const val RULE_MARKERS = "-*_="
/** The characters that can open emphasis, strong emphasis or a strikethrough. */
private const val EMPHASIS = "*_~"
/** Characters that end a bare URL wherever they appear, and ones only trimmed off the end. */
private const val URL_STOPS = "<>\"'`|"
private const val URL_TRAILING = ".,:;!?"
private class MarkdownScanner(private val code: String) {
private val spans = ArrayList<Span>()
fun run(): List<Span> {
var at = 0
// The delimiter run that opened the fenced block we are inside, or null between them.
var fence: String? = null
// Whether the row above was part of a table, which is what makes this one a body row.
var table = false
while (at <= code.length) {
val end = lineEnd(at)
val open = fence
if (open != null) {
// The content and the closing line alike: a fence is one block of code, and its own
// delimiters belong to it the way a string's quotes belong to the string.
emit(at, end, Kind.STRING)
if (closesFence(at, end, open)) fence = null
} else {
val opened = opensFence(at, end)
fence = opened
if (opened != null) table = false else table = row(at, end, table)
}
if (end == code.length) break
at = end + 1
}
return spans
}
/** The end of the line beginning at [at]: the newline, or the end of the text. */
private fun lineEnd(at: Int): Int {
val newline = code.indexOf('\n', at)
return if (newline < 0) code.length else newline
}
/**
* One line that is not inside a fence, and whether the table it may be part of is still open.
*
* A table is recognised by its delimiter row, the only line of one that cannot be anything
* else. That row comes *after* the header it belongs to, so the header is found by looking one
* line ahead -- the single piece of lookahead here, and cheaper than colouring every `|` in the
* document, which would mark the pipes in a shell command written in a paragraph.
*/
private fun row(start: Int, end: Int, table: Boolean): Boolean {
if (tableDelimiter(start, end)) {
emit(indented(start, end), end, Kind.MARK)
return true
}
val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1))
if ((table || header) && hasPipe(start, end)) {
tableRow(start, end)
return true
}
structure(start, end)
return false
}
/** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */
private fun tableDelimiter(start: Int, end: Int): Boolean {
var dashes = false
var pipes = false
for (at in indented(start, end) until end) {
when (code[at]) {
'-' -> dashes = true
'|' -> pipes = true
':',
' ',
'\t' -> {}
else -> return false
}
}
return dashes && pipes
}
private fun hasPipe(start: Int, end: Int): Boolean {
var at = start
while (at < end) {
if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++
}
return false
}
/** A table row: the pipes are the structure, and what is between them is prose. */
private fun tableRow(start: Int, end: Int) {
var at = indented(start, end)
var cell = at
while (at < end) {
when (code[at]) {
'\\' -> at += 2
'|' -> {
inline(cell, at)
emit(at, at + 1, Kind.MARK)
at++
cell = at
}
else -> at++
}
}
inline(cell, end)
}
/**
* Spans, coalesced with the one before when they touch and agree. Worth doing here rather than
* leaving it to the caller: the line scanner emits per marker and per word, so a heading would
* otherwise arrive as a dozen abutting spans of one colour.
*/
private fun emit(start: Int, end: Int, kind: Kind) {
if (end <= start) return
val last = spans.lastOrNull()
if (last != null && last.kind == kind && last.end == start) {
spans[spans.size - 1] = Span(last.start, end, kind)
} else {
spans.add(Span(start, end, kind))
}
}
/** The first character of the line at or after [start] that is not indentation. */
private fun indented(start: Int, end: Int): Int {
var at = start
while (at < end && (code[at] == ' ' || code[at] == '\t')) at++
return at
}
/** The run of backticks or tildes that could open or close a fence on this line, or null. */
private fun fenceRun(start: Int, end: Int): IntRange? {
val at = indented(start, end)
if (at == end) return null
val marker = code[at]
if (marker != '`' && marker != '~') return null
var run = at
while (run < end && code[run] == marker) run++
return if (run - at >= 3) at until run else null
}
/** Draws an opening fence line and answers its delimiter, or null if this is not one. */
private fun opensFence(start: Int, end: Int): String? {
val run = fenceRun(start, end) ?: return null
emit(run.first, run.last + 1, Kind.STRING)
// The info word is what the fence is a fence *of*, which is metadata about the block rather
// than part of it.
emit(indented(run.last + 1, end), end, Kind.METADATA)
return code.substring(run.first, run.last + 1)
}
/**
* Whether this line closes a fence opened by [open]: the same character, at least as many of
* them, and nothing else on the line -- so a longer run closes a shorter one and a line of
* backticks with a word after it does not close anything.
*/
private fun closesFence(start: Int, end: Int, open: String): Boolean {
val run = fenceRun(start, end) ?: return false
if (code[run.first] != open[0] || run.last + 1 - run.first < open.length) return false
return indented(run.last + 1, end) == end
}
/** One ordinary line: what its opening characters make it, and then its prose. */
private fun structure(start: Int, end: Int) {
var at = indented(start, end)
// Quote markers come before everything else and can be several deep, and what follows one
// is an ordinary line again -- a heading inside a quote is still a heading.
while (at < end && code[at] == '>') {
at++
emit(at - 1, at, Kind.MARK)
at = indented(at, end)
}
if (at == end) return
if (heading(at, end) || thematicBreak(at, end)) return
inline(bullet(at, end), end)
}
/** `#` to `######` and a space. Without the space it is a word beginning with a hash. */
private fun heading(start: Int, end: Int): Boolean {
var at = start
while (at < end && code[at] == '#') at++
val depth = at - start
if (depth !in 1..6) return false
if (at < end && code[at] != ' ' && code[at] != '\t') return false
emit(start, end, Kind.KEYWORD)
return true
}
/**
* A line made of one repeated rule character and nothing else.
*
* `---`, `***` and `___` are thematic breaks; `===` and `---` are also the underline of a
* setext heading. The two are the same line to look at and mean the same thing to a reader, so
* they get one appearance rather than a lookback. One `=` is enough because a setext underline
* may be a single character; a break needs three, which keeps a `- ` bullet out of here.
*/
private fun thematicBreak(start: Int, end: Int): Boolean {
val marker = code[start]
if (marker !in RULE_MARKERS) return false
var seen = 0
for (at in start until end) {
val character = code[at]
if (character == marker) seen++ else if (!character.isWhitespace()) return false
}
if (seen < if (marker == '=') 1 else 3) return false
emit(start, end, Kind.MARK)
return true
}
/** Draws a list marker if the line opens with one, and answers where the item's text starts. */
private fun bullet(start: Int, end: Int): Int {
val marker = code[start]
if (marker in BULLETS && spaceOrEnd(start + 1, end)) {
emit(start, start + 1, Kind.MARK)
return indented(start + 1, end)
}
var digits = start
while (digits < end && code[digits].isDigit()) digits++
val delimiter = code.getOrNull(digits)
if (
digits > start && (delimiter == '.' || delimiter == ')') && spaceOrEnd(digits + 1, end)
) {
emit(start, digits + 1, Kind.MARK)
return indented(digits + 1, end)
}
return start
}
private fun spaceOrEnd(at: Int, end: Int) = at >= end || code[at] == ' ' || code[at] == '\t'
/**
* The inline forms, left to right.
*
* Every branch answers a position strictly after [start] of its call, so this terminates
* whether or not the form it was looking at turned out to be one.
*/
private fun inline(start: Int, end: Int) {
var at = start
while (at < end) {
val character = code[at]
at =
when {
// A backslash takes the character after it out of the running entirely, which
// is how `\*` stays an asterisk rather than opening emphasis.
character == '\\' -> at + 2
character == '`' -> codeSpan(at, end)
character == '[' -> link(at, at, end)
character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end)
character == '<' -> autolink(at, end)
character in EMPHASIS -> emphasis(at, end)
else -> url(at, end) ?: (at + 1)
}
}
}
/**
* `` `code` ``, closed by a run of exactly as many backticks as opened it. That count is what
* lets a span hold a backtick of its own, and why the search skips over a shorter or longer run
* rather than stopping at the first backtick.
*/
private fun codeSpan(start: Int, end: Int): Int {
var open = start
while (open < end && code[open] == '`') open++
val ticks = open - start
var at = open
while (at < end) {
if (code[at] != '`') {
at++
continue
}
var close = at
while (close < end && code[close] == '`') close++
if (close - at == ticks) {
emit(start, close, Kind.STRING)
return close
}
at = close
}
// Nothing closes it on this line, so those were ordinary backticks.
return open
}
/**
* `[text](destination)`, and the same with a leading `!` for an image.
*
* The text is drawn as prose -- it is what the reader reads -- so only the brackets around it
* are marked, and the destination is metadata. A `[text]` with no destination after it is left
* plain, because that is what a reference link and a bracketed aside look like.
*/
private fun link(start: Int, bracket: Int, end: Int): Int {
var depth = 0
var close = bracket
while (close < end) {
when (code[close]) {
'\\' -> close++
'[' -> depth++
']' -> {
depth--
if (depth == 0) break
}
}
close++
}
if (close >= end) return start + 1
val destination = close + 1
if (code.getOrNull(destination) != '(') return start + 1
val paren = code.indexOf(')', destination)
if (paren < 0 || paren >= end) return start + 1
emit(start, bracket + 1, Kind.MARK)
inline(bracket + 1, close)
emit(close, destination, Kind.MARK)
emit(destination, paren + 1, Kind.METADATA)
return paren + 1
}
/**
* `<https://example.com>` and `<name@example.com>`, drawn as the destination they are.
*
* The angle brackets have to hold no whitespace and something that makes an address of it -- a
* scheme's colon or an at sign -- which is what keeps an HTML tag out.
*/
private fun autolink(start: Int, end: Int): Int {
var at = start + 1
var addressed = false
while (at < end) {
val character = code[at]
if (character.isWhitespace() || character == '<') return start + 1
if (character == '>') {
if (!addressed) return start + 1
emit(start, at + 1, Kind.METADATA)
return at + 1
}
if (character == ':' || character == '@') addressed = true
at++
}
return start + 1
}
/**
* A bare `scheme://…` written in prose, or null if one does not start here.
*
* A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry.
*
* Where it ends is the part worth stating: the sentence's punctuation is not the address, so a
* trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the
* URL -- otherwise a link in parentheses loses its `)`. A pipe stops it too, because a URL in a
* table cell must not swallow the cell's edge.
*/
private fun url(start: Int, end: Int): Int? {
if (start > 0 && isWord(code[start - 1])) return null
var scheme = start
while (scheme < end && code[scheme].isLetter()) scheme++
if (scheme == start || !code.startsWith("://", scheme)) return null
val body = scheme + 3
var at = body
var openers = 0
var closers = 0
while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) {
if (code[at] == '(') openers++ else if (code[at] == ')') closers++
at++
}
while (at > body) {
val last = code[at - 1]
if (last in URL_TRAILING) at--
else if (last == ')' && closers > openers) {
closers--
at--
} else break
}
if (at == body) return null
emit(start, at, Kind.METADATA)
return at
}
/**
* `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all -- which is how the
* token scanner draws a string: the quotes are part of the thing.
*
* The two guards keep this off code that happens to be in a paragraph: the opener must be
* followed by something to emphasise and the closer preceded by something emphasised, so `a * b
* * c` opens nothing and neither does the `*p = *q` of a C fragment. Underscores may not start
* or end inside a word, or every `snake_case_name` would be half emphasised.
*/
private fun emphasis(start: Int, end: Int): Int {
val marker = code[start]
var open = start
while (open < end && code[open] == marker) open++
val length = open - start
if (marker == '~' && length != 2) return open
if (length > 3) return open
if (open == end || code[open].isWhitespace()) return open
if (marker == '_' && start > 0 && isWord(code[start - 1])) return open
var at = open
while (at < end) {
if (code[at] == '\\') {
at += 2
continue
}
if (code[at] != marker) {
at++
continue
}
var close = at
while (close < end && code[close] == marker) close++
val finish = at + length
if (
close - at >= length &&
!code[at - 1].isWhitespace() &&
!(marker == '_' && finish < end && isWord(code[finish]))
) {
emit(start, finish, Kind.LITERAL)
return finish
}
at = close
}
return open
}
}
private fun isWord(character: Char) = character.isLetterOrDigit() || character == '_'
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* An assistant's reply, with anything it says it remembered drawn as a note rather than as markup.
*
* Claude Code marks a sentence that came from its stored memory by wrapping it in `<cc-memory
* filenames="...">`. Markdown has nothing to say about that, so it arrived on screen as literal
* angle brackets in the middle of a sentence -- which reads as the model having emitted broken
* HTML. It is really the opposite: a claim about where something came from, and "I was told this
* before" and "I worked this out just now" are different things the reader cannot otherwise tell
* apart.
*
* A tag that has not finished arriving is left alone: a half-written marker is not a marker yet.
*/
@Composable
fun AssistantMessage(
text: String,
replies: ParsedReplies,
/** Which notes are open, by [MessagePart.Remembered.text] -- see [MemoryNote]. */
openNotes: Set<String>,
onToggleNote: (String) -> Unit,
modifier: Modifier = Modifier,
live: Boolean = false,
) {
DebugStats.count("message composed")
val parts = remember(text) { messageParts(text) }
val only = parts.singleOrNull()
if (only is MessagePart.Prose) {
MarkdownText(only.text, replies, modifier, live)
return
}
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
parts.forEach { part ->
when (part) {
is MessagePart.Prose -> MarkdownText(part.text, replies, live = live)
is MessagePart.Remembered ->
MemoryNote(part, replies, part.text in openNotes) { onToggleNote(part.text) }
}
}
}
}
/**
* The pieces [AssistantMessage] draws, which is [splitMemoryNotes] with one correction.
*
* A reply carrying no notes is drawn from the message as it arrived rather than from the trimmed
* prose part made while looking for them -- inspecting a message must not change it. That belongs
* here rather than at the places that need the answer, because [warm] has to name the same strings
* the rows draw: a string warmed under a key no row ever looks up is a miss nothing reports.
*
* Public because [transcriptUnits] flattens settled replies into the same parts; go through
* [ParsedReplies.partsOf] on any path that runs per fold or per page.
*/
fun messageParts(text: String): List<MessagePart> {
val parts = splitMemoryNotes(text)
return if (parts.singleOrNull() is MessagePart.Prose) listOf(MessagePart.Prose(text)) else parts
}
/**
* One sentence the model attributed to a memory file, closed until somebody asks.
*
* Closed by default, like a tool call and a peer message and for the same reason: it is not part of
* what was said to the reader, it is a note about where a claim came from. Left open it breaks the
* reply in half around a card, and these arrive several to a message.
*
* What stays visible is which file it came from, because that is the whole of what the note claims
* and the part a reader scanning for "why does it think that" is looking for.
*
* Open-ness is the screen's, keyed by the note's own text: a note opened and scrolled past has to
* still be open on the way back, and a card that remembered for itself would forget the moment the
* list stopped composing it.
*/
@Composable
fun MemoryNote(
note: MessagePart.Remembered,
replies: ParsedReplies,
expanded: Boolean,
onToggle: () -> Unit,
) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Column(Modifier.padding(12.dp)) {
// Named, not just tinted: a colour can say "this one is different", but it cannot say
// what kind of different, and "recalled from a file" is a difference in kind.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (note.files.size == 1) "remembered from ${note.files[0]}"
else "remembered from ${note.files.joinToString(", ")}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!expanded) {
Spacer(Modifier.width(8.dp))
Text(
note.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a sentence is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
if (expanded) {
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
MarkdownText(note.text, replies, Modifier.padding(top = 4.dp))
}
}
}
}
}
/** One piece of a reply: ordinary prose, or a sentence attributed to a memory file. */
sealed class MessagePart {
/** The markdown this piece is drawn from. */
abstract val text: String
data class Prose(override val text: String) : MessagePart()
data class Remembered(override val text: String, val files: List<String>) : MessagePart()
}
private val MEMORY_NOTE =
Regex("""<cc-memory\s+filenames="([^"]*)"\s*>(.*?)</cc-memory>""", RegexOption.DOT_MATCHES_ALL)
/**
* Splits [text] into prose and memory notes, in order. Always returns at least one part, so a
* message with no notes is one piece of prose and costs nothing extra to draw.
*/
fun splitMemoryNotes(text: String): List<MessagePart> {
val parts = mutableListOf<MessagePart>()
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
}
@@ -1,32 +0,0 @@
package com.example.aiapp
/**
* What a session with no model of its own is called, in the button and in the list it opens.
*
* One constant rather than a literal in each place, because the two have to agree: a picker whose
* options cannot say every state its button can display is one you can leave and not get back to.
* It is also the Claude CLI's own word for "whatever is configured".
*/
const val DEFAULT_MODEL = "default"
/**
* A model's name as a person reads it.
*
* Providers answer with their own full identifier -- Claude Code resolves `haiku` to `claude-
* haiku-4-5-20251001` and reports that, which is the honest answer to "what is this session using"
* and far too long for a button in a row that also holds Stop and Send.
*
* So the two ends that identify nothing are dropped and nothing else is: the vendor prefix, which
* is the same on every model this app can show, and the release date, which distinguishes builds of
* one model rather than one model from another. Anything that does not look like that is returned
* untouched.
*
* A display decision, not a correction: the full name is what the session reports.
*/
fun modelLabel(model: String?): String {
val name = model?.takeIf { it.isNotBlank() } ?: return DEFAULT_MODEL
return name.removePrefix("claude-").replace(DATED_SUFFIX, "")
}
/** A trailing `-YYYYMMDD`, which is how these identifiers carry their release date. */
private val DATED_SUFFIX = Regex("""-\d{8}$""")
@@ -1,374 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Models on the backend, and HuggingFace to get more from.
*
* Everything here is the server's state rather than this screen's: what is downloaded, and what is
* downloading, are the same answers on every enrolled device, and a download started here keeps
* going when this screen closes.
*/
@Composable
fun ModelsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<Models>>(LoadState.Loading) }
var query by remember { mutableStateOf("") }
var results by remember { mutableStateOf<LoadState<List<RemoteRepo>>?>(null) }
var openRepo by remember { mutableStateOf<String?>(null) }
var repoFiles by remember { mutableStateOf<LoadState<List<RemoteFile>>?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchModels(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
// Polled rather than pushed: a download belongs to the machine, not to any session, so it has
// no event stream of its own. Keyed on the token as well, so the header's Refresh restarts the
// loop with a read now rather than leaving the reader watching for a second and a half.
LaunchedEffect(reloadToken) {
while (true) {
reload()
delay(1500)
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search HuggingFace") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
TextButton(
enabled = query.isNotBlank(),
onClick = {
openRepo = null
results = LoadState.Loading
scope.launch {
results =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(searchModels(settings, query))
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
},
) {
Text("Search")
}
Spacer(Modifier.height(8.dp))
LazyColumn(Modifier.fillMaxSize()) {
when (val current = state) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(current.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded -> {
if (current.value.downloads.isNotEmpty()) {
item { SectionLabel("Downloading") }
uniqueItems(current.value.downloads, key = { it.key + it.run }) { download
->
DownloadCard(download) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
cancelDownload(settings, download.key)
}
}
.exceptionOrNull()
?.message
}
}
}
}
item { SectionLabel("On the backend") }
if (current.value.local.isEmpty()) {
item {
Text(
"None yet. Search above to find one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
uniqueItems(current.value.local, key = { it.key }) { model ->
LocalModelCard(model) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
deleteModel(settings, model.key)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
results?.let { found ->
item { SectionLabel("HuggingFace") }
when (found) {
is LoadState.Loading -> item { CircularProgressIndicator() }
is LoadState.Error ->
item { Text(found.message, color = MaterialTheme.colorScheme.error) }
is LoadState.Loaded ->
uniqueItems(found.value, key = { it.id }) { repo ->
val open = openRepo == repo.id
RepoRow(repo, expanded = open) {
if (open) {
openRepo = null
} else {
openRepo = repo.id
repoFiles = LoadState.Loading
scope.launch {
repoFiles =
try {
withContext(Dispatchers.IO) {
LoadState.Loaded(
fetchRepoFiles(settings, repo.id)
)
}
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
}
// Inside the expanded repository's own item rather than as a section
// after the list: drawn after every card, a repository's files read as
// belonging to whichever card happened to be last.
if (open) {
when (val files = repoFiles) {
null -> {}
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error ->
Text(files.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
Column {
val busy =
(state as? LoadState.Loaded)
?.value
?.downloads
.orEmpty()
.filter { it.state == "running" }
.map { it.key }
.toSet()
files.value.forEach { file ->
RepoFileRow(
file,
downloading = "${repo.id}/${file.path}" in busy,
) {
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
startDownload(
settings,
repo.id,
file.path,
)
}
}
.exceptionOrNull()
?.message
reload()
}
}
}
}
}
}
}
}
}
}
}
}
@Composable
private fun SectionLabel(text: String) {
Spacer(Modifier.height(12.dp))
Text(text, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun DownloadCard(download: Download, onCancel: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(download.file, style = MaterialTheme.typography.titleSmall)
Text(
download.repo,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// A determinate bar only when the size is known. The server sends no total when it was
// never told one, and a bar drawn from a guess is worse than one that admits it is
// counting.
if (download.total != null && download.total > 0) {
LinearProgressIndicator(
progress = { download.done.toFloat() / download.total.toFloat() },
// Blue at every value, unlike a quota bar: a download nearing its end is
// nearing success, and colouring it like a limit being approached would say the
// opposite.
color = progressColor,
modifier = Modifier.fillMaxWidth(),
)
Text(
"${gigabytes(download.done)} of ${gigabytes(download.total)}",
style = MaterialTheme.typography.bodySmall,
)
} else {
LinearProgressIndicator(color = progressColor, modifier = Modifier.fillMaxWidth())
Text(
"${gigabytes(download.done)} so far, total size unknown",
style = MaterialTheme.typography.bodySmall,
)
}
download.error?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
Row {
Text(
download.state,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
if (download.state == "running") {
TextButton(onClick = onCancel) { Text("Cancel") }
}
}
}
}
}
@Composable
private fun LocalModelCard(model: LocalModel, onDelete: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(model.file, style = MaterialTheme.typography.titleSmall)
Text(
"${model.repo} · ${gigabytes(model.bytes)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onDelete) { Text("Delete") }
}
}
}
@Composable
private fun RepoRow(repo: RemoteRepo, expanded: Boolean, onToggle: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
repo.id,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
// The owner is the part that repeats; the model name at the end is what tells
// two entries apart.
overflow = TextOverflow.StartEllipsis,
)
Text(
"${repo.downloads} downloads · ${repo.likes} likes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TextButton(onClick = onToggle) { Text(if (expanded) "Hide" else "Files") }
}
}
}
@Composable
private fun RepoFileRow(file: RemoteFile, downloading: Boolean, onDownload: () -> Unit) {
Row(
Modifier.fillMaxWidth().padding(start = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(file.path, style = MaterialTheme.typography.bodyMedium)
Text(
gigabytes(file.bytes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Disabled rather than absent, so the row reads the same whether this one is absent,
// already here, or on its way. Offering "Download" for a file that is downloading would be
// a button that does nothing anyone can see.
TextButton(enabled = !file.have && !downloading, onClick = onDownload) {
Text(
when {
file.have -> "Downloaded"
downloading -> "Downloading"
else -> "Download"
}
)
}
}
}
private fun gigabytes(bytes: Long): String =
if (bytes >= 1_000_000_000) {
"%.2f GB".format(bytes / 1_000_000_000.0)
} else {
"%.0f MB".format(bytes / 1_000_000.0)
}
@@ -1,270 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
*
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
* set and kept in step by hand.
*
* This replaced a hand-drawn canvas gear, whose doc comment argued against icon fonts on the
* grounds that a system font may not have the glyph. That objection is about *relying* on a system
* font, and it is exactly right: the answer is not to avoid glyphs but to ship them. The font here
* is `app/build-icon-font.sh`'s output -- seventeen glyphs, 2.8 KB, subset out of the 3 MB symbols
* font and committed. Adding one means adding its codepoint in *both* places; a codepoint here that
* the script did not subset is a glyph that silently isn't there.
*
* The subset is the font's **Mono** face, where every glyph is exactly one em wide and one em tall.
* That is what makes two icons the same size without either being given a size: the proportional
* face's advances run from 0.46 em to 0.92 em, so a Send button and a Stop button side by side came
* out visibly different widths. [GLYPH_SIZE] carries the cost.
*
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift. The script is copied rather than shared
* because most of what looks like duplication is the `GLYPHS` list, which has to differ -- the
* point of subsetting is to ship only the codepoints one app draws.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
/** `md-cog` -- settings for the thing it sits beside. */
val SETTINGS_GLYPH = glyph(0xF0493)
/** `md-refresh` -- ask the server again for whatever is on screen. */
val REFRESH_GLYPH = glyph(0xF0450)
/** `md-send` -- the filled paper plane: submit what is in the composer. */
val SEND_GLYPH = glyph(0xF048A)
/**
* `md-stop` -- a filled square: end the process behind this session.
*
* The square is what stop has meant since tape decks, and it is spent here on the thing that
* actually stops rather than on pausing. [PAUSE_GLYPH] is the turn; this is the session.
*/
val STOP_GLYPH = glyph(0xF04DB)
/**
* `md-pause` -- two bars: take the running turn away and leave the session there.
*
* The pair with [STOP_GLYPH] and [PLAY_GLYPH] is the point: one button in the composer says what
* pressing it now would do to the process, and the three marks are the three answers. An interrupt
* ends a turn and nothing else, which is a pause, not a stop.
*/
val PAUSE_GLYPH = glyph(0xF03E4)
/** `md-play` -- start the process again, on the conversation it left. See [PAUSE_GLYPH]. */
val PLAY_GLYPH = glyph(0xF040A)
/**
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and one glyph doing both jobs would promise something immediate and do something
* that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
/** `md-close` -- take this off again: an attachment picked and not wanted. */
val CLOSE_GLYPH = glyph(0xF0156)
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
val BACK_GLYPH = glyph(0xF004D)
/** `md-bell` -- the notifications this session is allowed to raise. */
val BELL_GLYPH = glyph(0xF009A)
/**
* `fa-line_chart` -- how much of the account's rate limits is gone.
*
* Font Awesome's rather than Material's, which is the one break in the family above: it was asked
* for by name, and Material's chart glyphs are a bare line where this one has its axes.
*/
val USAGE_GLYPH = glyph(0xF201)
/**
* `md-speedometer` -- what this session is costing to draw.
*
* A speedometer rather than a bug, because what it copies is a measurement rather than a fault
* report: it is as useful on a screen that feels fine, where the answer is that nothing is slow.
*/
val SPEED_GLYPH = glyph(0xF04C5)
/**
* `md-folder` -- the files on the machine this session runs on.
*
* The same codepoint dev-updater uses, and it must not drift from it, for the reason the cog and
* the refresh arrow must not. Doubles as the mark on a directory row inside the explorer, which is
* what makes the button say where it leads.
*/
val FOLDER_GLYPH = glyph(0xF024B)
/** `md-file_outline` -- one file, in a listing beside the directories. */
val FILE_GLYPH = glyph(0xF0224)
/** `md-plus` -- make something here. dev-updater's codepoint as well. */
val PLUS_GLYPH = glyph(0xF0415)
/** `md-pencil` -- change what this file says, rather than only reading it. */
val EDIT_GLYPH = glyph(0xF03EB)
/**
* `md-content_save` -- write the edits back to the machine.
*
* The floppy disk, which is what save has meant for longer than most of the people reading it have
* been alive and is still the only mark anybody recognises for it.
*/
val SAVE_GLYPH = glyph(0xF0193)
/**
* The size an icon draws at beside a line of text.
*
* 17 rather than the 20 it was while the font was the proportional face. A glyph there filled at
* most 0.83 em of its point size, so the number was standing in for the headroom above the tallest
* one; in the Mono face every glyph fills its em exactly, and keeping 20 would have stepped every
* icon in the app up by a fifth.
*/
private val GLYPH_SIZE = 17.sp
/**
* The same measurement in dp: a glyph's em box is its point size, and a layout is laid out in dp.
*/
private val GLYPH_EXTENT = GLYPH_SIZE.value.dp
/**
* The square a glyph button occupies: the mark, plus the same ring of padding on all four sides.
*
* The ring is the whole spacing rule. Every gap around a header icon comes out of it -- one ring to
* the screen edge, two where a button meets its neighbour -- so nothing outside has to add a gap of
* its own. That is what it was: the box was the size of the mark (28dp) and the separation was
* bolted on beside it, which left the two header icons 31dp apart and the outer one 14dp from the
* edge.
*
* 48dp is the platform's minimum touch target, so the square is also the whole of what a finger has
* to find, and what the pressed-state ripple draws: at 28dp that circle was inscribed in the mark's
* own corners and beside a title it arrived at the first letter. And it is taller than any header's
* text, which is what lets the button fill a header row rather than sit in the middle of one.
*/
private val GLYPH_BUTTON_SIZE = 48.dp
/**
* The ring itself, for putting something that is *not* a glyph button next to one -- a title beside
* a back arrow.
*
* Two glyph buttons need nothing between them: each brings its own ring and the two add up. Text
* brings none, so the second ring has to be asked for -- without it the pressed-state circle
* arrives at the first letter of the title.
*/
val GLYPH_BUTTON_MARGIN = (GLYPH_BUTTON_SIZE - GLYPH_EXTENT) / 2
/**
* A glyph you can press: the icon equivalent of a `TextButton`.
*
* Its own composable so that every icon button in the app is one size and one colour without each
* caller saying so, and so the [label] none of them displays is still there for a screen reader --
* which is also the answer to "what was that button for" six months from now.
*
* [enabled] is passed through rather than left to callers hiding the button: a control that comes
* and goes makes its own absence the signal, and absence cannot say whether there was nothing to do
* or nobody checked.
*/
@Composable
fun GlyphButton(
glyph: String,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
colour: Color = MaterialTheme.colorScheme.primary,
) {
MarkButton(label, onClick, modifier, enabled) {
Glyph(glyph, colour = if (enabled) colour else MaterialTheme.colorScheme.outline)
}
}
/**
* The same square, around a mark that is not a glyph.
*
* A [Chevron] is drawn rather than set in a font, and a pair of them used as buttons has to be the
* size, spacing and touch target every other icon button already is. The caller still owes it a
* [label]: nothing here draws a word.
*/
@Composable
fun MarkButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
mark: @Composable () -> Unit,
) {
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) {
mark()
}
}
/**
* The square a glyph button occupies, with a spinner in it instead of a mark.
*
* For a button whose work is under way. It takes the button's whole box rather than the mark's, so
* swapping one for the other leaves everything in the row exactly where it was.
*/
@Composable
fun GlyphSpinner(label: String, modifier: Modifier = Modifier) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier.size(GLYPH_BUTTON_SIZE).semantics { contentDescription = label },
) {
CircularProgressIndicator(Modifier.size(GLYPH_EXTENT), strokeWidth = 2.dp)
}
}
/**
* One icon, drawn as text.
*
* Callers that are already inside something pressable use this; [GlyphButton] is the one that adds
* the press. Either way the caller owes it a description, since neither draws a word.
*/
@Composable
fun Glyph(
glyph: String,
modifier: Modifier = Modifier,
colour: Color = MaterialTheme.colorScheme.primary,
size: TextUnit = GLYPH_SIZE,
) {
// Line height of the point size, which for this font is the square the glyph draws in: its
// ascent and descent add up to exactly one em. Left to the inherited body style the line box
// was 24sp tall around a 17sp-wide mark, so a glyph took a seventh more vertical space than
// horizontal.
Text(
glyph,
fontFamily = NerdIcons,
fontSize = size,
lineHeight = size,
color = colour,
modifier = modifier,
)
}
@@ -1,352 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.net.Uri
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.json.JSONObject
/**
* Telling somebody a session wants them, when they are not looking at the app.
*
* This is a **foreground service**, which on Android is the only way to keep a connection open
* while the app is closed -- there has been no such thing as a long-lived background service since
* Android 8. It is what Syncthing does for the same reason. Discord is not a counter-example: it
* gets a push from Google's servers, which would mean this backend talking to Google about
* somebody's coding sessions, and the whole point of the tunnel is that it does not.
*
* The cost Android charges is a notification of its own that cannot be dismissed. That is made as
* quiet as the platform allows: [ONGOING_CHANNEL] is `IMPORTANCE_MIN`, so it makes no sound, shows
* no status-bar icon, and sits at the bottom of the shade. It is not hidden outright, because it
* cannot be and because it should not be: it is the honest indicator that something is holding a
* connection open.
*/
class NotificationService : Service() {
@Volatile private var stream: HttpURLConnection? = null
@Volatile private var stopping = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val settings = loadServerSettings(this)
if (settings == null) {
// Nothing to connect to. Stopping rather than idling: a service holding no connection
// still costs the ongoing notification, which would be announcing work that is not
// happening.
stopSelf()
return START_NOT_STICKY
}
// Through ServiceCompat so the type is stated once and ignored on the versions that predate
// types, rather than branching here.
ServiceCompat.startForeground(this, ONGOING_ID, ongoingNotification(), foregroundType())
thread(isDaemon = true, name = "ai-app-notifications") { follow(settings) }
// Restarted if Android kills it, which is the whole point: the window this covers is
// exactly the one where nobody is watching.
return START_STICKY
}
override fun onDestroy() {
stopping = true
stream?.disconnect()
}
/**
* Follows the backend's notification stream, reconnecting until stopped.
*
* A dropped connection is the ordinary case here rather than an error, so it retries quietly
* and forever. Nothing is shown when it cannot connect: a notification saying "I could not tell
* you whether anything happened" is noise about a condition nobody can act on, and the session
* list already says what is waiting when they next look.
*/
private fun follow(settings: ServerSettings) {
while (!stopping) {
try {
readStream(settings)
} catch (_: IOException) {
// Deliberate: see above.
}
if (stopping) return
try {
Thread.sleep(RECONNECT_DELAY_MS)
} catch (_: InterruptedException) {
return
}
}
}
private fun readStream(settings: ServerSettings) {
val connection =
URL("${settings.baseUrl}/notifications").openConnection() as HttpURLConnection
stream = connection
try {
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout, for the reason EventStream gives: between notifications there is
// nothing to read, possibly for hours.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
throw IOException("HTTP ${connection.responseCode} for the notification stream")
}
val reader = connection.inputStream.bufferedReader()
val data = StringBuilder()
while (!stopping) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) show(parseNotification(data.toString()))
data.clear()
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
else -> {} // comments (keep-alives) and ids: nothing to do
}
}
} finally {
connection.disconnect()
stream = null
}
}
/**
* One notification per session, replacing that session's previous one.
*
* Keyed by session id rather than accumulating: two sessions wanting attention are two things
* to know about, but one session that finished and then asked a question is one thing -- the
* question. A stack of stale rows is how a drawer becomes something to clear rather than read.
*/
private fun show(notification: SessionNotification) {
// Nothing to tell somebody about the session they are reading. The transcript in front of
// them is already saying it.
if (isOnScreen(notification.sessionId)) return
// The app is up: it says this itself, as a banner over whatever screen they are on. Never
// both -- one thing happened, and a drawer filling up behind an app that already showed you
// each one is a drawer nobody reads.
if (handOver(notification)) return
val manager = NotificationManagerCompat.from(this)
// Two different noes, and both are answers rather than faults: the runtime permission
// refused, and notifications switched off for the app in Android's own settings.
//
// The permission only exists from Android 13. Asking an older version about it gets
// "denied" for a name it does not know, which read as the person having said no -- so every
// notification on Android 12 and below was silently dropped.
val allowed =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!allowed || !manager.areNotificationsEnabled()) {
return
}
val open =
PendingIntent.getActivity(
this,
0,
sessionIntent(this, notification.sessionId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val built =
NotificationCompat.Builder(this, ALERT_CHANNEL)
.setContentTitle(notification.title)
.setContentText(attentionLine(notification.kind))
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentIntent(open)
.setAutoCancel(true)
.setWhen((notification.at * 1000).toLong())
.setShowWhen(true)
.build()
manager.notify(notification.sessionId, ALERT_ID, built)
}
/**
* The type Android 14+ requires a foreground service to declare, and nothing before it.
*
* Named behind a version check rather than passed as a constant: the value is inlined at
* compile time and would be handed to platforms that have no concept of it, which is what
* lint's InlinedApi exists to catch.
*/
private fun foregroundType(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
} else {
0
}
private fun ongoingNotification(): Notification =
NotificationCompat.Builder(this, ONGOING_CHANNEL)
.setContentTitle("Watching for sessions that need you")
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
companion object {
/**
* Starts the service if there is a server to connect to, and stops it otherwise.
*
* Called on every launch rather than once: a service Android killed does not restart itself
* if the process was replaced, and asking for one that is already running is free.
*/
fun sync(context: Context) {
val intent = Intent(context, NotificationService::class.java)
if (loadServerSettings(context) == null) {
context.stopService(intent)
return
}
createChannels(context)
ContextCompat.startForegroundService(context, intent)
}
/**
* Two channels, because they are two different things to be told.
*
* The alerts are what somebody turned this on for, so they get the default importance. The
* ongoing one is the platform's tax for staying connected, so it takes the lowest
* importance that exists. Both are created before the service starts, since posting to a
* channel that does not exist is silently dropped.
*/
private fun createChannels(context: Context) {
val manager = NotificationManagerCompat.from(context)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(
ALERT_CHANNEL,
NotificationManagerCompat.IMPORTANCE_DEFAULT,
)
.setName("Sessions needing attention")
.build()
)
manager.createNotificationChannel(
NotificationChannelCompat.Builder(
ONGOING_CHANNEL,
NotificationManagerCompat.IMPORTANCE_MIN,
)
.setName("Staying connected")
.build()
)
}
/**
* The session somebody is looking at, or null when no screen is showing one.
*
* Process-wide state, which the rest of this app does without: Android constructs the
* service and the composition draws the screen, so the two have no common owner. Clearing
* names the session rather than setting null outright, because moving from one session to
* another composes the new screen before the old one's coroutine is cancelled -- an
* unconditional clear would throw away the new screen's claim.
*/
@Volatile private var onScreen: String? = null
private fun isOnScreen(sessionId: String) = onScreen == sessionId
/**
* The way a notification reaches the app instead of Android's drawer.
*
* Whether there is an app to reach is the subscriber count rather than a flag of its own:
* [SessionAlerts] collects this exactly while it is on screen. `tryEmit` neither suspends
* nor blocks the thread reading the stream, and the buffer is there so a handful of
* sessions finishing together all land rather than the last one winning.
*/
private val toApp = MutableSharedFlow<SessionNotification>(extraBufferCapacity = 8)
/** Everything meant for the screen rather than the drawer; see [toApp]. */
val forTheScreen: SharedFlow<SessionNotification> = toApp.asSharedFlow()
private fun handOver(notification: SessionNotification) =
toApp.subscriptionCount.value > 0 && toApp.tryEmit(notification)
/** Somebody is looking at [sessionId]; nothing is posted about it until they stop. */
fun showing(context: Context, sessionId: String) {
onScreen = sessionId
// Whatever was posted about it before is about to be read, so it has nothing left to
// say.
NotificationManagerCompat.from(context).cancel(sessionId, ALERT_ID)
}
/** They have stopped, unless another screen has claimed it since. */
fun stoppedShowing(sessionId: String) {
if (onScreen == sessionId) onScreen = null
}
private const val ALERT_CHANNEL = "sessions"
private const val ONGOING_CHANNEL = "connection"
private const val ONGOING_ID = 1
/** Shared by every alert; the session id is the tag that separates them. */
private const val ALERT_ID = 2
private const val RECONNECT_DELAY_MS = 5_000L
}
}
/**
* The intent that opens one session, and the id it carries back out.
*
* The two halves are written together so neither can be changed without the other, and the scheme
* is enrollment's `aiapp://` under a different host so that [MainActivity] has one thing to look
* at.
*
* The id rides in the intent's **data** rather than in an extra, which is not a style choice:
* PendingIntent identity is `Intent.filterEquals`, and that compares the data while ignoring
* extras. Carried as an extra, every session's notification would update one shared PendingIntent
* and every tap would open whichever session was notified last.
*/
fun sessionIntent(context: Context, sessionId: String): Intent =
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(
// Built rather than concatenated so an id needing escaping survives the round trip;
// lastPathSegment below decodes what appendPath encoded.
Uri.Builder().scheme("aiapp").authority("session").appendPath(sessionId).build()
)
/** The session [sessionIntent] named, or null for any other URI -- enrollment's included. */
fun notifiedSessionId(uri: Uri): String? =
if (uri.scheme == "aiapp" && uri.host == "session") uri.lastPathSegment else null
/** One frame of `GET /notifications`. */
data class SessionNotification(
val sessionId: String,
val title: String,
/** The wire's word: "awaitingInput" or "finished". */
val kind: String,
val at: Double,
)
/**
* What a notification asks of the reader, in the words they see.
*
* What they have to do, not what the session did: "awaitingInput" is the wire's word and says
* nothing to somebody reading a lock screen. One function because the same fact is shown in two
* places -- Android's drawer and the app's own banner -- and two mappings of one word drift.
*/
fun attentionLine(kind: String): String =
when (kind) {
"awaitingInput" -> "Waiting for you"
else -> "Finished"
}
fun parseNotification(json: String): SessionNotification {
val body = JSONObject(json)
return SessionNotification(
sessionId = body.getString("sessionId"),
title = body.getString("title"),
kind = body.getString("kind"),
at = body.optDouble("at", 0.0),
)
}
@@ -1,141 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/**
* A message another agent sent this session, closed until somebody asks.
*
* Closed by default, like a tool call and for the same reason: these are long, there can be several
* in a row, and what a reader scanning the transcript needs from one is that it happened and who
* sent it. The first line comes with the heading because a name alone does not say which message
* this was.
*
* Drawn as its own kind rather than as the reader's own bubble. They did not say this, and a
* transcript that puts it in their voice is making a claim about who asked for the work that
* follows.
*
* Opened, the card is drawn in *pieces* -- this heading and one [PeerBlockRow] per markdown block,
* each its own item of the transcript list. See [TranscriptUnit.PeerHead] for what that bought;
* what matters here is that the pieces have to add up to the card that was there before, so the
* fill, the corner radius and the padding all live in [peerSurface].
*/
@Composable
fun PeerHeadRow(
item: TranscriptItem.PeerNote,
open: Boolean,
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier.cardPiece(
top = true,
bottom = !open,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!open) {
Spacer(Modifier.width(8.dp))
Text(
item.text.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
// The head, not the tail: a message is identified by how it opens.
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* One block of an opened peer message, on the same card the heading started.
*
* Clickable like the heading, so the card still shuts wherever it is pressed -- it was one control
* before it was several items, and which piece the finger lands on is not something the reader
* chose.
*/
@Composable
fun PeerBlockRow(unit: TranscriptUnit.PeerBlock, replies: ParsedReplies, onToggle: () -> Unit) {
Column(
Modifier.cardPiece(
top = false,
bottom = unit.last,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
// The words shut the card too, and have to do it themselves -- see [LocalMarkdownTap].
// Without this the card closes everywhere except on the text, which is most of it.
CompositionLocalProvider(LocalMarkdownTap provides rememberMarkdownTap(onToggle)) {
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next -- inside the piece, so the card's fill runs through
// it.
MarkdownPiece(unit.text, unit.piece, replies, Modifier.padding(top = unit.spacing))
}
}
}
/**
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero, so there is no shadow that a seam would show through --
* which is the whole reason a card can be cut up at all. Each piece paints the caller's container
* colour and rounds only the corners at the ends of the message, so the pieces abut into one
* continuous card. Shared by the two rows cut this way -- an opened peer message and a long user
* message -- because two copies of the corner logic is how one of them grows a seam.
*
* The padding is the other half: 12dp all round was the card's own, so the top piece keeps the top
* of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
fun Modifier.cardPiece(
top: Boolean,
bottom: Boolean,
fill: Color,
onPress: (() -> Unit)? = null,
): Modifier {
val square = CornerSize(0.dp)
val shape =
MaterialTheme.shapes.medium.copy(
topStart = if (top) MaterialTheme.shapes.medium.topStart else square,
topEnd = if (top) MaterialTheme.shapes.medium.topEnd else square,
bottomStart = if (bottom) MaterialTheme.shapes.medium.bottomStart else square,
bottomEnd = if (bottom) MaterialTheme.shapes.medium.bottomEnd else square,
)
return fillMaxWidth()
.clip(shape)
.background(fill)
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
.padding(
start = CARD_PADDING,
end = CARD_PADDING,
top = if (top) CARD_PADDING else 0.dp,
bottom = if (bottom) CARD_PADDING else 0.dp,
)
}
/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
private val CARD_PADDING = 12.dp
@@ -1,162 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* What is about to be sent, directly above the box it will be sent from.
*
* The count on the "+" button was the whole of what said an image was attached, so the only way to
* find out *which* image was to send it. A control belongs with the thing it acts on.
*
* Scrolls sideways rather than wrapping or shrinking: the row keeps one thumbnail size whatever is
* in it, so four attachments look like four of the same thing rather than four smaller ones.
*/
@Composable
fun PendingAttachments(
settings: ServerSettings,
sessionId: String,
refs: List<String>,
onRemove: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (refs.isEmpty()) return
Row(
modifier = modifier.horizontalScroll(rememberScrollState()).padding(bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
refs.forEach { ref ->
if (isImageRef(ref)) PendingThumbnail(settings, sessionId, ref) { onRemove(ref) }
else PendingFile(ref) { onRemove(ref) }
}
}
}
/**
* One attachment, square, tap to take it back off.
*
* Removal is here because there is nowhere else it could be: an image picked by mistake could
* otherwise only be dealt with by sending it. The whole thumbnail is the target rather than a
* corner cross -- a cross small enough to sit on a 64dp square is smaller than a fingertip.
*/
@Composable
private fun PendingThumbnail(
settings: ServerSettings,
sessionId: String,
ref: String,
onRemove: () -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val shape = RoundedCornerShape(8.dp)
Box(
Modifier.size(THUMBNAIL)
.clip(shape)
// An outline as well as a fill. Most of what gets attached here is a screenshot of a
// dark app, and cropped to a square its middle is often near-black -- against this
// background the tile then had no edge at all.
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
// Behind the picture as well as under a missing one, so the tile is a tile before
// anything has arrived to fill it.
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRemove)
.semantics { contentDescription = "Attached image, tap to remove" },
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// The two are told apart for the same reason the transcript's images are: one of them
// is worth waiting for and the other never resolves.
null ->
if (failed) {
Text(
"!",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
// A spinner, as the transcript's images have: one appearance for "a picture is
// on its way", learned once. An ellipsis had to be read as a spinner not
// moving.
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
}
else ->
Image(
bitmap = image,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(THUMBNAIL),
)
}
// The whole square removes it, and this only says so. A cross small enough to sit in the
// corner of a 64dp thumbnail is smaller than a fingertip.
//
// The disc is sized here and the mark centred inside it, rather than the glyph being
// aligned directly: a glyph's box is wider than the cross it draws, so aligning the box to
// the corner hung the visible mark over the edge.
Box(
Modifier.align(Alignment.TopEnd)
.padding(2.dp)
.size(20.dp)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.75f), CircleShape),
contentAlignment = Alignment.Center,
) {
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
}
}
}
/**
* One attached file: its name, tap to take it back off. The same height and removal as a thumbnail,
* so a row of mixed attachments is one row; the cross sits after the name because a tile this wide
* has no corner the eye goes to.
*/
@Composable
private fun PendingFile(ref: String, onRemove: () -> Unit) {
val name = attachmentName(ref)
val shape = RoundedCornerShape(8.dp)
Row(
Modifier.height(THUMBNAIL)
.clip(shape)
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onRemove)
.semantics { contentDescription = "Attached file $name, tap to remove" }
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
FileName(name, Modifier.widthIn(max = FILE_TILE_WIDTH))
Spacer(Modifier.width(6.dp))
Glyph(CLOSE_GLYPH, colour = MaterialTheme.colorScheme.onSurface, size = 12.sp)
}
}
private val THUMBNAIL = 64.dp
/** Wide enough for most names whole; longer ones lose their middle, keeping both ends. */
private val FILE_TILE_WIDTH = 200.dp
@@ -1,16 +0,0 @@
package com.example.aiapp
import com.example.wgapplink.PinnedTls
import java.net.HttpURLConnection
// PINNED_CA_PEM is generated at build time from the CA on the machine doing the build -- see the
// generatePinnedCert task in build.gradle.kts. It is deliberately not a checked-in constant: the
// private key that signs against it must never be anywhere this repo is, and an APK should pin
// whatever CA the backend it was built for actually serves.
//
// The pinning itself lives in wg-app-link, since dev-updater needs exactly the same thing. What
// stays here is which certificate this app pins.
private val pinned = PinnedTls(PINNED_CA_PEM)
/** Every request this app makes goes through this -- there is no unpinned path. */
fun HttpURLConnection.applyPinnedTls() = pinned.applyTo(this)
@@ -1,35 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
/**
* Verbatim text, on the surface that says so: a command about to be run, what a tool printed.
*
* A composable rather than a modifier repeated at each site, because the inset is part of it --
* monospace text drawn hard against the edge of a tinted block reads as a clipping fault, and three
* copies of "clip, fill, pad" drift apart the first time one is adjusted.
*
* The colour is [rawSurface], which is also what a code block inside a reply is given.
*/
@Composable
fun RawBlock(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Column(
modifier
.fillMaxWidth()
// Smaller than a card's radius, and deliberately: this sits *inside* one, and a rounded
// rectangle drawn at the same radius as the one behind it reads as a misprint.
.clip(MaterialTheme.shapes.extraSmall)
.background(rawSurface)
.padding(horizontal = 8.dp, vertical = 6.dp),
content = content,
)
}
@@ -1,64 +0,0 @@
package com.example.aiapp
import java.time.Duration
import java.time.OffsetDateTime
// How long is left in a usage window. Shared by the session bar and the usage screen: the
// arithmetic is the same in both, so everything here returns the span or the state on its own and
// leaves the wording to the caller.
/**
* "1d 4h", "3h 12m", "12m" -- the span alone, with no leading or trailing words.
*
* Rounded **up** to the whole minute, rather than truncated as it was. A window with 3h 12m 50s
* left is nearer four minutes past the twelve than it is to twelve, and truncating also parks the
* figure on a minute it has already spent. One rule, so the session bar and the usage dialog cannot
* round a shared measurement two different ways.
*/
fun formatSpan(until: Duration): String {
val up = if (until.seconds % 60 == 0L && until.nano == 0) until else until.plusMinutes(1)
return when {
up.toHours() >= 24 -> "${up.toDays()}d ${up.toHours() % 24}h"
up.toHours() > 0 -> "${up.toHours()}h ${up.toMinutes() % 60}m"
else -> "${up.toMinutes()}m"
}
}
/**
* What is known about when a usage window ends.
*
* Three answers rather than a nullable duration, because two of them shared `null` and they are not
* the same thing at all. A window the server sent no reset time for is one that is **not running**:
* the five-hour window is anchored to the block it started in, so between sessions there is nothing
* counting down and the API says so by omitting the field. A timestamp that did arrive and could
* not be read is the genuinely unknown case.
*
* Collapsing them put "reset time unknown" on the session bar for a machine behaving perfectly, on
* the one row somebody reads before starting something big -- and the usage dialog, looking at the
* same field, quietly drew nothing.
*/
sealed class WindowEnd {
/** No reset time was sent, so nothing is running in this window. Not a failure to find out. */
data object NotRunning : WindowEnd()
/** A timestamp arrived and could not be read. The one case that is actually unknown. */
data object Unreadable : WindowEnd()
/** How long is left. Negative once the window is past, which each caller words for itself. */
data class Ends(val until: Duration) : WindowEnd()
}
/**
* [resetsAt] as the server sent it -- absent, unreadable, or a moment -- against [now].
*
* [now] is a parameter rather than read here so a caller can drive it from state and have the
* countdown recompute on its own schedule.
*/
fun windowEnd(resetsAt: String?, now: OffsetDateTime): WindowEnd {
if (resetsAt == null) return WindowEnd.NotRunning
return try {
WindowEnd.Ends(Duration.between(now, OffsetDateTime.parse(resetsAt)))
} catch (_: Exception) {
WindowEnd.Unreadable
}
}
@@ -1,53 +0,0 @@
package com.example.aiapp
import android.content.Context
import androidx.core.content.edit
private const val ANCHORS = "session-scroll"
/**
* Where a session's transcript was left, so reopening it lands where reading stopped.
*
* Named by a **sequence number** -- see [TranscriptRow.startSeq] -- rather than by an index or by
* the row key the list draws with. An index means nothing across a reopen, since the transcript is
* fetched newest-first. The row key looks stable and is not: a tool row is named after its run,
* `joinPages` gives a run the name of its newest half, and the newest half is whatever the newest
* page started with -- so an active session renames its tool runs every time it is reopened. A seq
* is the server's own numbering, assigned once and never moved.
*
* [unit] is which unit of the row the viewport started at and [offset] how far that unit was
* scrolled past the viewport's newest edge. A seq alone is not a place: a reply is one seq and can
* be forty blocks long.
*/
data class ScrollAnchor(val seq: Long, val offset: Int, val unit: Int = 0)
/**
* On this device rather than on the backend, which is where this app otherwise keeps state so every
* device sees it. Scroll position is the same exception a draft is: it is where the phone in
* somebody's hand is pointed.
*/
fun loadScrollAnchor(context: Context, sessionId: String): ScrollAnchor? {
val stored =
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).getString(sessionId, null)
?: return null
val fields = stored.split(':')
val seq = fields.getOrNull(0)?.toLongOrNull() ?: return null
val offset = fields.getOrNull(1)?.toIntOrNull() ?: return null
// Positions saved before the unit was recorded name the row's oldest unit, which is the closest
// older place -- the same choice [unitIndexFor] makes when a unit is gone.
return ScrollAnchor(seq, offset, fields.getOrNull(2)?.toIntOrNull() ?: 0)
}
/**
* Records where [sessionId] is being read, or forgets it when [anchor] is null.
*
* The path out is reading to the newest end, which is what the caller passes null for: a session
* left at the bottom has nothing to restore. A session *deleted* while it held an anchor leaves its
* key behind, for the reason and at the cost `Drafts.kt` describes.
*/
fun saveScrollAnchor(context: Context, sessionId: String, anchor: ScrollAnchor?) {
context.getSharedPreferences(ANCHORS, Context.MODE_PRIVATE).edit {
if (anchor == null) remove(sessionId)
else putString(sessionId, "${anchor.seq}:${anchor.offset}:${anchor.unit}")
}
}
@@ -1,27 +0,0 @@
package com.example.aiapp
import android.content.Context
import android.net.Uri
import com.example.wgapplink.ServerStore
/**
* Where the backend is and how to authenticate to it. Absent until the phone is enrolled -- by
* scanning the server's terminal QR (an `aiapp://enroll` URI the camera app hands to MainActivity)
* or by typing the fields into the settings screen.
*/
typealias ServerSettings = com.example.wgapplink.ServerSettings
/**
* This app's enrollment, which is the whole of what is product-specific about it.
*
* Both values are load-bearing. The scheme is what routes a scanned QR here rather than to Dev
* Updater, and the key alias names the Android Keystore key the token is already sealed under on
* every enrolled phone -- changing it would leave those phones reading as not enrolled.
*/
private val store = ServerStore(scheme = "aiapp", keyAlias = "aiapp-token-key")
fun loadServerSettings(context: Context): ServerSettings? = store.load(context)
fun saveServerSettings(context: Context, settings: ServerSettings) = store.save(context, settings)
fun parseEnrollmentUri(uri: Uri): ServerSettings? = store.parseEnrollmentUri(uri)
@@ -1,176 +0,0 @@
package com.example.aiapp
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
/**
* A session wanting attention, said over the app rather than through Android's drawer.
*
* Two places can carry the same fact and only one is right at a time. A row in the shade is for
* somebody looking at something else: it makes a sound, it waits however long it has to, and acting
* on it means leaving whatever they were doing. Somebody with this app open needs none of that. So
* while these are on screen the stream is delivered here instead, which is arranged by the
* collection below and nothing else.
*
* A banner can go three ways, each somebody deciding something different: tapped, which opens the
* session; pushed off either side; or left alone, in which case it goes when the bar runs out.
*/
@Composable
fun SessionAlerts(onOpen: (SessionOpenRequest) -> Unit, modifier: Modifier = Modifier) {
val queue = remember { mutableStateListOf<SessionAlert>() }
// What tells two notifications about one session apart, and what a replaced banner gets a new
// one of so its timer starts again rather than inheriting the remains of the last one's.
var arrivals by remember { mutableIntStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
try {
NotificationService.forTheScreen.collect { notification ->
arrivals++
val alert = SessionAlert(notification, arrivals)
// One banner per session, replacing that session's own -- the same rule the
// drawer follows: a session that finished and then asked a question is one
// thing to know about, the question. It keeps its place in the queue rather
// than moving to the end, because the reader may already be reaching for it.
val already = queue.indexOfFirst {
it.notification.sessionId == notification.sessionId
}
if (already >= 0) queue[already] = alert else queue.add(alert)
}
} finally {
// Leaving the app hands the job back to the drawer, so nothing arriving while it is
// away is lost. What would be lost is the truth of what is already up: these say a
// session wants somebody *now*, and one still sitting here on a return several
// minutes later is a claim nobody checked. Frozen, too -- Compose stops the clock
// with the window.
queue.clear()
}
}
}
// Oldest at the top, so a new one appears below the ones already being read instead of shoving
// them down the screen mid-reach.
Column(modifier.fillMaxWidth().padding(8.dp)) {
queue.forEach { alert ->
key(alert.arrival) {
AlertBanner(
alert = alert,
onOpen = {
queue.remove(alert)
onOpen(SessionOpenRequest(alert.notification.sessionId, alert.arrival))
},
onGone = { queue.remove(alert) },
)
}
}
}
}
/** One notification queued for the screen, with the arrival that tells it from its predecessor. */
private data class SessionAlert(val notification: SessionNotification, val arrival: Int)
/**
* One banner: what wants attention, and how long this has left to say so.
*
* The bar and the going away are one value rather than a bar beside a timer, because two of them
* would be two accounts of the same countdown and only one can be the one that fires.
*/
@Composable
private fun AlertBanner(alert: SessionAlert, onOpen: () -> Unit, onGone: () -> Unit) {
val swipe = rememberSwipeToDismissBoxState()
val life = remember { Animatable(1f) }
LaunchedEffect(Unit) {
life.animateTo(0f, animationSpec = tween(ALERT_LIFE_MS, easing = LinearEasing))
onGone()
}
// Settled is "still where it started"; anything else is a push that carried far enough for the
// gesture to commit, which the platform decides rather than this screen.
LaunchedEffect(swipe.currentValue) {
if (swipe.currentValue != SwipeToDismissBoxValue.Settled) onGone()
}
SwipeToDismissBox(
state = swipe,
// Nothing behind it. Pushing one of these away means the same thing whichever way it went,
// so a coloured ground with an icon would be drawing a distinction that isn't there.
backgroundContent = {},
modifier = Modifier.padding(bottom = 8.dp),
) {
Card(
onClick = onOpen,
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
// Outlined, because the step it needs to make is not one this palette can make with a
// tint: the card under a banner on the session list is the same surface, so a banner
// relying on colour alone reads as one more row in the way. The border is the one cue.
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
) {
Column(Modifier.padding(start = 12.dp, end = 12.dp, top = 12.dp, bottom = 10.dp)) {
Text(
alert.notification.title,
style = MaterialTheme.typography.titleSmall,
// One line, cut at the tail: a session is identified by the start of its name,
// and a banner that grew with the name would move the one below it.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
attentionLine(alert.notification.kind),
style = MaterialTheme.typography.labelLarge,
// The list's own colour for a session waiting on a person, so the banner and
// the row behind it are saying one thing rather than two.
color =
if (alert.notification.kind == "awaitingInput") awaitingColor
else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
LinearProgressIndicator(
progress = { life.value },
// Blue because it is reporting how much of something is left rather than passing
// judgement on it. Stated beside the track, which is the card's own colour so that
// the spent part reads as empty rather than as a second bar.
color = progressColor,
trackColor = MaterialTheme.colorScheme.surfaceContainerHigh,
drawStopIndicator = {},
gapSize = 0.dp,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
/**
* How long a banner stays if nobody touches it.
*
* Long enough to read a session name and a line, short enough that a stack of them clears itself
* while somebody is still on the screen that produced them. The bar makes the number visible.
*/
private const val ALERT_LIFE_MS = 6_000
@@ -1,265 +0,0 @@
package com.example.aiapp
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
*
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
* and a reader can act on the second; each caller supplies its own words for them.
*/
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
/**
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
* does not refetch. Shared by the transcript's images and the composer's pending attachments,
* because the fetch, the decode and the two-state answer are one block of logic that had been
* written twice.
*/
@Composable
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
LaunchedEffect(ref) {
state =
try {
val bytes =
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
SessionBitmap(decoded, failed = decoded == null)
} catch (_: ApiException) {
SessionBitmap(null, failed = true)
}
}
return state
}
/**
* An image in the transcript: a fixed-height thumbnail that opens full screen.
*
* The height is decided before the bytes arrive and never changes. An image row that grew when it
* finished loading pushed everything below it, so a transcript being read scrolled itself -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@Composable
fun SessionImage(
settings: ServerSettings,
sessionId: String,
ref: String,
onOpen: (String) -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val height = thumbnailHeight()
val heightPx = with(LocalDensity.current) { height.roundToPx() }
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
when (val image = bitmap) {
// Two states, not one: an image still arriving and an image that will never arrive look
// nothing alike to a reader who can do something about the second.
null ->
if (failed) {
Text(
"[image $ref unavailable]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LoadingImage(height)
}
else ->
Image(
bitmap = image,
contentDescription = "Attached image, tap to view full screen",
contentScale = ContentScale.Fit,
filterQuality = enlargingFilter(image.height, heightPx),
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
alignment = Alignment.CenterStart,
)
}
}
}
/**
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
*
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
* group -- a different composable in a different part of the tree, so everything the old subtree
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
* transcript because the session made another tool call.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
settings: ServerSettings,
sessionId: String,
ref: String,
onClose: () -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them. Stated in white because
// this box paints its own black behind them and a theme colour would be picked
// against a surface that is not there.
null ->
if (failed) {
Text(
"Image $ref is unavailable",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> ZoomableImage(image)
}
}
}
}
/**
* The room a picture is about to take, with a spinner in the middle of it.
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
Box(
Modifier.size(height)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
}
}
/** Small enough to sit inside the thumbnail's square without filling it. */
private val LOADING_SPINNER = 24.dp
/**
* Four lines of the body style the transcript is set in.
*
* Measured from the type rather than written as a dp, so it stays four lines when the text size
* changes -- including when the reader has scaled fonts up, which is when a hardcoded height is
* wrong.
*/
@Composable
private fun thumbnailHeight(): Dp {
val line = MaterialTheme.typography.bodyLarge.lineHeight
val density = LocalDensity.current
return remember(line, density) {
with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp }
}
}
/**
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
*
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
* image with hard pixel edges stays readable. Shrinking wants the opposite.
*/
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
/**
* The image on its own, as large as it fits, with pinch to zoom.
*
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
* visible.
*/
@Composable
private fun ZoomableImage(image: ImageBitmap) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
Image(
bitmap = image,
contentDescription = "Attached image",
contentScale = ContentScale.Fit,
// Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None,
modifier =
Modifier.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
// already the whole of it; a ceiling so it cannot be lost off-screen.
scale = (scale * zoom).coerceIn(1f, 8f)
if (scale > 1f) {
offsetX += pan.x
offsetY += pan.y
} else {
offsetX = 0f
offsetY = 0f
}
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
},
)
}
@@ -1,530 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The sessions tab: sessions awaiting an answer sort to the top, which is the "your turn" inbox.
*
* No title and no Back of its own -- [MainScreen] owns the header and the tab that names this one.
* What stays here is the button that adds a session, because that acts on this list and nothing
* else.
*/
@Composable
fun SessionListScreen(
settings: ServerSettings,
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit,
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows.
//
// Cleared on the next successful load below -- an entry outlives its session otherwise.
var deleteErrors by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
// Which sessions have a delete in flight. A set of ids rather than a flag on the row, because
// the rows are rebuilt from whatever the server last said and this belongs to the request.
var deleting by remember { mutableStateOf<Set<String>>(emptySet()) }
// This phone's copies of these sessions' transcripts, pruned from here because this is where a
// session stops existing. See TranscriptCache.
val context = LocalContext.current
val transcriptCache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
fun refresh() {
listState = LoadState.Loading
scope.launch {
listState =
try {
val loaded =
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSessions(settings)) }
deleteErrors = emptyMap()
// The path out for a cached transcript whose session was deleted somewhere
// else. This list is the only place that ever learns the full set. On the
// answer rather than in `finally`: a list that failed to arrive says nothing
// about which sessions exist.
withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
}
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded
} catch (e: ApiException) {
LoadState.failed(e)
}
}
}
LaunchedEffect(reloadToken) { refresh() }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
when (val state = listState) {
is LoadState.Loading -> CircularProgressIndicator()
// The message as Api.kt wrote it, with nothing added: it is already a whole
// sentence naming the address and what to check, so a prefix here read "Couldn't
// reach the server: Couldn't reach the server at ...".
is LoadState.Error ->
Text(
state.message,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded -> {
if (state.value.isEmpty()) {
Text(
"No sessions. Tap + to spawn one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then most recently active.
val ordered =
state.value.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity }
)
LazyColumn {
uniqueItems(ordered, key = { it.id }) { session ->
SessionCard(
session = session,
error = deleteErrors[session.id],
deleting = session.id in deleting,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
)
Spacer(Modifier.height(12.dp))
}
}
}
}
}
FloatingActionButton(
onClick = onSpawn,
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
) {
Text("+", style = MaterialTheme.typography.headlineMedium)
}
}
confirmingDelete?.let { session ->
// Reset per session, so a toggle turned on for one conversation is not still on for the
// next. Off to begin with: see [deleteSession].
var alsoDeleteForeign by remember(session.id) { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = {
// Two different acts behind one button, so it says which one this is. What
// separates them is whether the *driver* keeps its own record of the conversation
// -- the Claude Code CLI does, whether this app spawned the session or imported it;
// echo and llama.cpp do not.
//
// This used to branch on `imported`, above a comment asserting that "a session
// started here has no copy anywhere". That was false for every claude-cli session
// this app spawned, and getting it wrong in that direction is the expensive one:
// "this can't be undone", said of something that can, spends the credibility the
// sentence needs.
//
// Neither branch promises a restore. The recoverable one says what is known -- the
// driver keeps its own record -- rather than that the file is still there, and it
// names what goes either way, because this app's transcript holds images, peer
// messages and commands the CLI's own record never had.
Column {
Text(
when {
!session.keepsOwnTranscript ->
"Kills the process and deletes the conversation. Nothing else " +
"keeps a copy, so this can't be undone."
// The sentence below is the one the toggle makes false, which is why it
// is written twice rather than appended to: leaving "should still be
// there to import again" on screen beside a switch that removes it is
// the reassurance being read at the moment it stops being true.
alsoDeleteForeign ->
"Kills the process and deletes both copies of the conversation: " +
"this app's, and Claude Code's own transcript on the " +
"machine. Nothing keeps another, so this can't be undone."
else ->
"Stops the process and deletes this app's copy of the " +
"conversation, including any images, peer messages and " +
"commands recorded only here. Claude Code keeps its own " +
"transcript on the machine, so the conversation itself " +
"should still be there to import again."
}
)
// Only where there is a second copy to decide about. Absent rather than
// disabled, because this is not a capability being withheld: for echo and
// llama.cpp there is no other transcript, and a switch offering to delete one
// would be asking about something that does not exist.
if (session.keepsOwnTranscript) {
Spacer(Modifier.height(16.dp))
// Its own row rather than beside the paragraph: a switch is taller than a
// line of text and re-centres whatever shares a row with it.
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"Delete Claude Code's transcript too",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(12.dp))
Switch(
checked = alsoDeleteForeign,
onCheckedChange = { alsoDeleteForeign = it },
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
// Marked here rather than after the request returns: the row has to say
// something is happening to it from the moment it is asked for.
deleting = deleting + session.id
deleteErrors = deleteErrors - session.id
scope.launch {
try {
withContext(Dispatchers.IO) {
deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it.
transcriptCache.session(TranscriptAddress(session.id)).purge()
}
// Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the
// reader an empty screen, to report on something never in doubt.
val loaded = listState
if (loaded is LoadState.Loaded) {
listState =
LoadState.Loaded(
loaded.value.filterNot { it.id == session.id }
)
}
} catch (e: ApiException) {
// Kept, because it is still there: the server refused, so the
// session it refused about is exactly as it was.
deleteErrors =
deleteErrors + (session.id to (e.message ?: "Delete failed"))
} finally {
deleting = deleting - session.id
}
}
}
) {
// Coloured by consequence: this takes something away, and does so wherever it
// appears -- the same rule the import screen's Delete follows.
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SessionCard(
session: SessionSummary,
/** What went wrong acting on *this* session, if anything has. */
error: String?,
/**
* Whether this session is being deleted right now.
*
* Suspended rather than removed while it is -- see [BusyItem] -- which says the row is on its
* way out without claiming it has gone: a row removed the moment Delete is pressed is a promise
* about a request that has not been answered yet.
*/
deleting: Boolean,
onOpen: () -> Unit,
onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) {
BusyItem(label = if (deleting) "deleting" else null) {
Card(
// Off while the delete is in flight: a card that still opens a session it is deleting
// is a race the reader can start by tapping. On the card rather than in [BusyItem],
// which leaves gestures alone so the list still scrolls.
Modifier.fillMaxWidth()
.combinedClickable(
enabled = !deleting,
onClick = onOpen,
onLongClick = onLongPress,
)
) {
Column(Modifier.padding(16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
session.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
}
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
// Machine, then what runs on it, then what it is set to: the same order and
// separator as the session screen's header and the usage dialog, so one
// pair of facts is not written three ways.
listOfNotNull(
session.setupName,
session.provider,
session.model?.let { modelLabel(it) },
)
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(session.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
error?.let {
Spacer(Modifier.height(8.dp))
// The server's own words, unprefixed, the way every other failure is shown.
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
// The platform's minimum touch height, not the chevron's own ten or so dp:
// at the chevron's height a tap meant for it landed on the first subcard
// beneath and opened a subagent instead.
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
}
}
}
}
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see docs/SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable
fun StatusText(status: String) {
val (label, color) =
when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the
// theme's accent says the state is something other than what the label says.
CircularProgressIndicator(
modifier = Modifier.width(14.dp).height(14.dp),
strokeWidth = 2.dp,
color = color,
)
Spacer(Modifier.width(6.dp))
}
Text(label, style = MaterialTheme.typography.labelLarge, color = color)
}
}
fun relativeTime(epochSeconds: Double): String {
val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong()
return when {
seconds < 60 -> "just now"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
}
}
File diff suppressed because it is too large. Load diff
@@ -1,562 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* What can be changed about one session, as opposed to about this app.
*
* Over the session rather than a step down from it: everything here is about the conversation
* behind it, and a dialog keeps that conversation on screen while it is being adjusted. It was a
* screen of its own until 2026-08-30, which put a page transition and a back stack around two
* controls and hid the thing they act on.
*
* The model and the permission mode are deliberately still on the session's own bar, because those
* are changed *while* reading a turn -- "not this model, try that one".
*
* Captions are for what a control costs rather than for what it is. A paragraph under every control
* made the dialog longer than the conversation it covers -- so Notifications has none, while Move
* and Reload do, because what those two take away is not visible from here.
*/
@Composable
fun SessionSettingsDialog(
settings: ServerSettings,
sessionId: String,
/**
* What the session is called now, as the screen behind this knows it -- see the rename below.
*/
title: String,
onRenamed: (String) -> Unit,
/**
* How hard the model thinks, as the session reports it, or null for the CLI's own default.
*
* Taken from the row this dialog was opened over rather than fetched, because unlike the
* notification switch there is nothing else that changes it: the level is this app's to set and
* the server does not resolve it into something else.
*/
effort: String?,
/** Whether a level does anything here; the row is left out entirely where it does not. */
takesEffort: Boolean,
/**
* What this phone is holding of the conversation, or null while that is being measured -- see
* the Reload row below, which is what would discard it.
*/
cachedBytes: Long?,
onReload: () -> Unit,
onDismiss: () -> Unit,
/**
* Copies what this session costs to draw. Built by the session screen, because everything it
* measures is that screen's own state.
*/
onCopyRenderReport: () -> Unit,
/**
* Runs P0's scripted scroll-and-stream benchmark and copies the extended report, or null on
* every build but `bench` -- see [BuildConfig.FIXTURE_MODE] and BenchRun.kt. Null rather than
* always-present-but-disabled: this has no meaning at all outside the bench build, and a
* control with nothing behind it on every other build is not a state worth drawing.
*/
onRunBenchmark: (() -> Unit)? = null,
) {
val scope = rememberCoroutineScope()
var name by remember(sessionId) { mutableStateOf(title) }
var level by remember(sessionId) { mutableStateOf(effort) }
var effortError by remember { mutableStateOf<String?>(null) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Null until the server has been asked. The row this dialog was opened over is a snapshot of
// whenever the list was last fetched, so drawing the switch straight from it would show a
// position that may have been changed since. Until the answer arrives the switch is disabled
// and a spinner sits beside it, which is what not knowing looks like.
var notify by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var notifyError by remember { mutableStateOf<String?>(null) }
// The same three-state shape the notification switch has, for the same reason: until the
// server has answered, the switch is disabled rather than showing a position nothing confirmed.
var autoResume by remember(sessionId) { mutableStateOf<Boolean?>(null) }
var resumeMessage by remember(sessionId) { mutableStateOf(DEFAULT_RESUME_MESSAGE) }
// When the server next intends to ask whether the limit has lifted, or null when nothing is
// waiting. Read once with everything else: it moves on the server's schedule, not this
// screen's, and a figure that redrew itself here would be this app re-measuring what it was
// told.
var resumeAt by remember(sessionId) { mutableStateOf<Double?>(null) }
var resumeError by remember { mutableStateOf<String?>(null) }
// Where the session works. Null until the server has been asked, for the same reason the switch
// above is. An empty answer is a session that was never given a directory, which is not the
// same as one whose directory is unknown -- the field is only enabled once one of those is
// settled.
var cwd by remember(sessionId) { mutableStateOf<String?>(null) }
var typedCwd by remember(sessionId) { mutableStateOf("") }
var cwdError by remember { mutableStateOf<String?>(null) }
var movingCwd by remember { mutableStateOf(false) }
LaunchedEffect(sessionId) {
try {
val fresh = withContext(Dispatchers.IO) { fetchSession(settings, sessionId) }
notify = fresh.notify
autoResume = fresh.autoResume
resumeMessage = fresh.autoResumeMessage
resumeAt = fresh.resumeAt
cwd = fresh.cwd.orEmpty()
typedCwd = fresh.cwd.orEmpty()
} catch (e: ApiException) {
// Left unknown rather than falling back to the stale row: the switch stays disabled,
// instead of offering a position nothing confirmed.
notifyError = e.message
notify = null
resumeError = e.message
autoResume = null
}
}
/**
* Moves the session, which ends the process that is in the old directory.
*
* Said plainly beside the field rather than confirmed in a second dialog: what it costs is a
* process, and a stopped session is a state this app already has a word and a button for.
*/
fun moveCwd() {
val chosen = typedCwd.trim()
if (movingCwd || chosen.isEmpty() || chosen == cwd) return
movingCwd = true
cwdError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionCwd(settings, sessionId, chosen) }
cwd = chosen
} catch (e: ApiException) {
// Where it happened: this field is the only thing on screen that knows a move was
// asked for, and the reason is usually the path itself.
cwdError = e.message
} finally {
movingCwd = false
}
}
}
/**
* Chooses a thinking level, which ends the process the old level was launched with.
*
* Put back if the request is refused, for the reason the notification switch below gives: a
* control that stays where it was put after a refusal is stating something untrue.
*/
fun setEffort(chosen: String?) {
val was = level
level = chosen
effortError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionEffort(settings, sessionId, chosen) }
} catch (e: ApiException) {
level = was
effortError = e.message
}
}
}
// Moved optimistically so the switch answers the finger that moved it, and put back if the
// request is refused -- a switch that waits for a round trip reads as broken on a slow tunnel,
// and one that stays moved after a refusal lies.
fun setNotify(wanted: Boolean) {
val was = notify
notify = wanted
notifyError = null
scope.launch {
try {
withContext(Dispatchers.IO) { setSessionNotify(settings, sessionId, wanted) }
} catch (e: ApiException) {
notify = was
notifyError = e.message
}
}
}
/**
* Turns auto-resume on or off, or changes what it would say.
*
* One request for both, because the server takes one: switching it on and typing the message
* are two halves of the same decision, and sending them separately would leave a moment where
* the session is armed with the old words.
*
* Put back if refused, like the notification switch. Turning it off also clears what was
* scheduled -- said here rather than only on the server, or the row would go on naming a time
* that no longer exists.
*/
fun setAutoResume(on: Boolean, message: String) {
val wasOn = autoResume
val wasMessage = resumeMessage
val wasAt = resumeAt
autoResume = on
resumeMessage = message
if (!on) resumeAt = null
resumeError = null
scope.launch {
try {
withContext(Dispatchers.IO) {
setSessionAutoResume(settings, sessionId, on, message)
}
} catch (e: ApiException) {
autoResume = wasOn
resumeMessage = wasMessage
resumeAt = wasAt
resumeError = e.message
}
}
}
// Nothing to do when the name has not changed, so the button says so rather than sending a
// request whose success would look exactly like the failure of having typed nothing.
val changed = name.trim().isNotEmpty() && name.trim() != title
fun save() {
if (!changed || saving) return
val chosen = name.trim()
saving = true
error = null
scope.launch {
try {
withContext(Dispatchers.IO) { renameSession(settings, sessionId, chosen) }
onRenamed(chosen)
} catch (e: ApiException) {
// Reported here, where it happened, because this dialog is the only place that
// knows a rename was attempted.
error = e.message
saving = false
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Session settings") },
text = {
// Scrollable, because this dialog grew past a screenful: a Material dialog constrains
// its own height and clips what does not fit, so the last control on the list is one
// large system font away from being unreachable with nothing on screen to say so.
Column(Modifier.verticalScroll(rememberScrollState())) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth(),
// The keyboard's own action does what the button does: a one-field form where
// the return key does nothing is a form people press return at anyway.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { save() }),
)
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(BELL_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Notifications", modifier = Modifier.weight(1f))
if (notify == null && notifyError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = notify == true,
onCheckedChange = { setNotify(it) },
enabled = notify != null,
)
}
// Beside the switch that failed, not with the rename's error: they are two requests
// and a reader has to be able to tell which one the server refused.
notifyError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resume after a usage limit", modifier = Modifier.weight(1f))
if (autoResume == null && resumeError == null) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
Spacer(Modifier.width(8.dp))
}
Switch(
checked = autoResume == true,
onCheckedChange = { setAutoResume(it, resumeMessage) },
enabled = autoResume != null,
)
}
// Disabled rather than hidden while the switch is off: a field that comes and goes
// makes its own presence the signal, and a visible one teaches what the switch will
// do. Committed on the keyboard's Done rather than on every keystroke, so typing a
// sentence is one request instead of one per letter.
OutlinedTextField(
value = resumeMessage,
onValueChange = { resumeMessage = it },
label = { Text("Message to send") },
// What an empty field means, in the field: the server's own word rather than a
// session poked with nothing to read.
placeholder = { Text(DEFAULT_RESUME_MESSAGE) },
singleLine = true,
enabled = autoResume == true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = { setAutoResume(true, resumeMessage) }),
)
// What it does and what it costs, in the order it happens. The last sentence is the
// one that matters: the time below is when the server will *ask*, not a promise
// about when the session speaks.
Text(
"When this session stops because the account is out of quota, the server " +
"checks the limit and sends this message once it has lifted. It checks " +
"again if the limit is still on.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Only where something is actually waiting. Absent is not a state worth a row: a
// session that has not hit a limit has nothing scheduled, which the reader can see
// from the switch.
resumeAt?.let { at ->
Text(
"Waiting now -- next check ${formatCheckTime(at)}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
resumeError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = typedCwd,
onValueChange = { typedCwd = it },
label = { Text("Working directory") },
// What the field cannot say by being empty: a session that was never given
// one starts wherever its launcher does, and this names that rather than
// showing a path nobody chose.
placeholder = { Text("wherever the session was started") },
singleLine = true,
enabled = cwd != null && !movingCwd,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { moveCwd() }),
)
TextButton(
onClick = { moveCwd() },
enabled =
cwd != null &&
!movingCwd &&
typedCwd.trim().isNotEmpty() &&
typedCwd.trim() != cwd,
) {
Text(if (movingCwd) "Moving..." else "Move")
}
}
// The whole of what pressing Move does, where it is about to be pressed. A
// directory is settled when the process is spawned, so it is ended and the next
// thing said to the session starts it in the new place.
Text(
"Moving stops the session's process. It starts again in the new directory " +
"with the next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
cwdError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
// Left out rather than disabled, the one place this dialog does that: a disabled
// control teaches what the thing can do, and a llama session cannot do this at all
// -- the row would be teaching something false about it.
if (takesEffort) {
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Thinking", modifier = Modifier.weight(1f))
PickerButton(
current = level ?: DEFAULT_EFFORT,
// The level the CLI picks for itself is in the list as well as in the
// button, so leaving a level is not a one-way trip -- the same
// correction the model picker carries.
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
onPick = { chosen ->
setEffort(chosen.takeIf { it != DEFAULT_EFFORT })
},
)
}
// What it costs, said where it is about to be pressed, like Move above: the
// CLI reads the level when it launches and has no control request for
// changing one.
Text(
"Changing this stops the session's process. It starts again with the " +
"next message, or with Start.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
effortError?.let {
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Transcript", modifier = Modifier.weight(1f))
// The size is what the button discards, and the unknown state is drawn rather
// than guessed: a spinner while the directory is being measured, and words when
// there is nothing there, because "nothing cached" and "0 B" read as different
// claims.
when {
cachedBytes == null ->
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
else ->
Text(
humanSize(cachedBytes)?.let { "$it cached" } ?: "nothing cached",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(12.dp))
// Enabled whether or not anything is cached: "what I see disagrees with the
// machine" is a state an empty cache can be in too, and a control that comes
// and goes makes its own presence the signal.
TextButton(onClick = onReload) { Text("Reload") }
}
// Captioned, unlike the controls above it, for the same reason Move is: what it
// costs is not visible, and neither is the case it exists for.
Text(
"Reload throws away this phone's copy and fetches the transcript from the " +
"server again. Use it when what is shown here disagrees with the file " +
"on the machine.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(8.dp))
// About this session, which is what everything in here is -- and it was on the
// header until 2026-09-03, where the folder button now is. It copies rather than
// opening anything, so it says so and then says it happened: a row that looks like
// a control and gives no sign of having run is one people press twice.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("Render timings", modifier = Modifier.weight(1f))
TextButton(onClick = onCopyRenderReport) { Text("Copy") }
}
// Bench-build only: see [onRunBenchmark]. Named exactly "Run benchmark" because
// ui-trace and the emulator smoke run find it by that label, the same way every
// other control here is found -- see AGENTS.md's "Driving the UI".
onRunBenchmark?.let { run ->
Spacer(Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Glyph(SPEED_GLYPH, colour = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.width(8.dp))
Text("P0 benchmark", modifier = Modifier.weight(1f))
TextButton(onClick = run) { Text("Run benchmark") }
}
}
}
},
// Disabled rather than absent while there is nothing to save: a button that comes and goes
// makes its own presence the signal, and its absence cannot say why.
confirmButton = {
TextButton(onClick = { save() }, enabled = changed && !saving) {
Text(if (saving) "Saving..." else "Save")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
}
/**
* When the server will next look, as a local time.
*
* A time rather than a countdown, for the reason the transcript's own limit row gives: this screen
* reads the figure once, and a span drawn from a value nothing refreshes goes stale while somebody
* is looking at it.
*/
private fun formatCheckTime(epochSeconds: Double): String =
try {
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
.format(Instant.ofEpochSecond(epochSeconds.toLong()))
} catch (_: Exception) {
// A time that cannot be read is not a time to show: the sentence above still says a check
// is coming, which is the part the reader can act on.
"soon"
}
@@ -1,273 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import java.time.Duration
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
/** What one machine's rate limits came back as, or why they didn't. */
sealed class SessionUsage {
/** Nothing has come back yet. Distinct from every answer, including an empty one. */
data object Waiting : SessionUsage()
/** Every window the machine reported, in the order it reported them. */
data class Known(val windows: List<UsageWindow>) : SessionUsage()
/**
* This machine meters nothing, so there is no window to show.
*
* Separate from [Unavailable], and the distinction is the point: a session on `echo` or on a
* local llama.cpp has no paid quota at all, which is a fact about how it was set up and not a
* failure to find something out. The backend never asks such a machine, and reading that
* silence as "couldn't find out" is answering with the nearest available word.
*/
data object NotMetered : SessionUsage()
/**
* The question could not be answered, and why.
*
* Its own state because "we couldn't find out" and "none of it is used" must never share an
* appearance: a bar sitting at zero because a machine is unreachable reads as plenty of
* headroom.
*/
data class Unavailable(val why: String) : SessionUsage()
}
/** How often to ask again. The backend caches, so this re-reads its cache rather than the API. */
private const val REFRESH_MS = 60_000L
/**
* One poll of every machine's limits, and the handle to ask again.
*
* A screen shows this answer in more than one place -- the bar under the session header, the colour
* of the button beside it, and the dialog that button opens -- and each used to fetch for itself.
* Two fetches say one thing twice and then disagree: the bar's copy can be a whole refresh interval
* old when the dialog opens with a fresh one, so the header read 42% while the screen over it read
* 47%.
*/
class UsageFeed(
val snapshots: LoadState<List<UsageSnapshot>>,
/** A fetch is outstanding. Only ever true over an answer already shown. */
val refreshing: Boolean,
/** Ask the backend again now. The dialog's refresh button; the poll does it on its own. */
val refresh: () -> Unit,
) {
/**
* What meters [session], and what that meter came back as. See [usageFor] for the states.
*
* A session rather than a machine, because a machine is not what is metered: one machine runs
* the Claude CLI and an echo session side by side, and only the first of them spends anything.
*/
fun forSession(session: SessionSummary): SessionUsage {
// Settled without asking anybody: a session nothing meters has nothing to check, and
// "checking" is what the fetch's own states would say about it for as long as one is out.
val provider = session.usageProvider ?: return SessionUsage.NotMetered
return when (val state = snapshots) {
is LoadState.Loading -> SessionUsage.Waiting
is LoadState.Error -> SessionUsage.Unavailable(state.message)
is LoadState.Loaded -> usageFor(state.value, session.setup, provider)
}
}
}
/**
* The one poll of the machines' rate limits, polled and refreshable.
*
* Hoisted out of [SessionUsageBar] because everything on a session's screen that reports on usage
* has to be reporting the same measurement; see [UsageFeed].
*/
@Composable
fun rememberUsageFeed(settings: ServerSettings): UsageFeed {
var snapshots by remember { mutableStateOf<LoadState<List<UsageSnapshot>>>(LoadState.Loading) }
var refreshing by remember { mutableStateOf(true) }
// Bumped to ask again now. The poll below restarts from the new value, so a manual refresh also
// resets the countdown rather than leaving one due immediately after.
var asked by remember { mutableIntStateOf(0) }
LaunchedEffect(asked) {
while (true) {
refreshing = true
// Replaces the answer only once the next one is in hand: dropping back to Loading would
// blank a bar somebody is reading for the length of a round trip, and what was on
// screen is still the last thing the machine actually said.
snapshots =
try {
LoadState.Loaded(withContext(Dispatchers.IO) { fetchUsage(settings) })
} catch (e: ApiException) {
LoadState.failed(e)
}
refreshing = false
delay(REFRESH_MS)
}
}
return remember(snapshots, refreshing) { UsageFeed(snapshots, refreshing) { asked++ } }
}
/**
* The colour for a control that reports on [usage] as a whole: the worst window's.
*
* Worst rather than the five-hour one, because the button it colours opens *all* of them, and a
* blue icon over a weekly quota at 97% would be the interface answering a question nobody asked.
* Taken over however many windows came back rather than the three Claude sends today -- the backend
* passes windows it does not recognise straight through.
*
* Every state that is not a measurement takes the ordinary control colour instead. That is the
* point where colour stops being able to help: blue is the low end of a scale here, so colouring an
* unknown blue would say "measured, and fine" about a machine nobody could reach.
*/
@Composable
fun usageGlyphColour(usage: SessionUsage): Color =
when (usage) {
is SessionUsage.Known ->
usage.windows.maxOfOrNull { it.percent }?.let { quotaColor(it) }
?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.primary
}
/**
* The five-hour window for the machine this session runs on, under the session's own header.
*
* Here rather than only in the usage dialog because it is the number that decides whether to keep
* going, and it was a screen away from the place that decision gets made. It reports on this
* session's machine alone -- the dialog is still where every machine is compared.
*
* What it shows is the paid service's own metering, never derived from what this app has watched go
* past: the transcript's token counts are a different quantity, measured differently, and a bar
* built out of them would be a guess wearing a measurement's clothes.
*/
@Composable
fun SessionUsageBar(usage: SessionUsage, modifier: Modifier = Modifier) {
DebugStats.count("usage bar recomposed")
// The countdown moves even when the numbers do not, so it is driven by a clock of its own
// rather than recomputed at draw time: a percentage that comes back unchanged is an equal
// value, Compose skips the recomposition, and a "left" that only ticked when the quota moved
// would sit at a stale figure for hours.
var now by remember { mutableStateOf(OffsetDateTime.now()) }
LaunchedEffect(Unit) {
while (true) {
delay(REFRESH_MS)
now = OffsetDateTime.now()
}
}
// Nothing at all for a session that meters nothing: a row saying "unknown" there would report
// a problem about a setup somebody chose, on every screen, forever.
//
// And nothing while the first fetch is out, which is a different silence. A request in flight
// is not a state to report -- and the session that meters nothing is exactly the one this
// cannot yet tell apart, so "5-hour usage: checking" appeared under an echo session for half a
// second and was then taken away. A row that has to be withdrawn is worse than one that
// arrives late.
if (usage is SessionUsage.NotMetered || usage is SessionUsage.Waiting) {
return
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 2.dp),
) {
// Words, not a colour and not an empty bar: every one of these is a different kind of
// answer from "this much is used", and only words carry a difference in kind.
when (val state = usage) {
// Both handled above, before the row exists at all.
SessionUsage.NotMetered,
SessionUsage.Waiting -> Unit
is SessionUsage.Unavailable -> UsageNote("5-hour usage unknown -- ${state.why}")
is SessionUsage.Known -> {
val window = state.windows.firstOrNull { it.kind == "session" }
if (window == null) {
UsageNote("5-hour usage unknown -- no five-hour window reported")
} else {
LinearProgressIndicator(
progress = { (window.percent / 100.0).toFloat().coerceIn(0f, 1f) },
// The same step at the same percentages as the dialog's bars: this is the
// same measurement, and a reader who learned the colour there has to be
// able to read it here without checking which screen they are on.
color = quotaColor(window.percent),
modifier = Modifier.weight(1f),
)
Text(
fiveHourLabel(window, now),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
}
}
}
/** Anything this row says instead of drawing a bar, so all of them look the same. */
@Composable
private fun UsageNote(text: String) {
Text(
text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/**
* "42% -- 2h 15m left": how much is gone, then how long what is left has to last.
*
* The percentage on its own does not answer the question it gets asked, which is whether to start
* something now; 80% with twenty minutes to go and 80% with four hours to go are opposite answers.
*
* The window's end has two missing cases, worded differently on purpose; see [WindowEnd]. A window
* that is not running gets the percentage and nothing else.
*/
private fun fiveHourLabel(window: UsageWindow, now: OffsetDateTime): String {
val percent = "${window.percent.toInt()}%"
return when (val end = windowEnd(window.resetsAt, now)) {
// Between blocks the five-hour window has no reset time, and saying so is a fact about
// nothing: there is no window to run out. The percentage is the whole answer.
WindowEnd.NotRunning -> percent
WindowEnd.Unreadable -> "$percent · reset time unreadable"
is WindowEnd.Ends ->
// Under a minute, including past the end: the number would round to "0m left", which
// reads as a measurement rather than as the window having run out.
if (end.until < Duration.ofMinutes(1)) "$percent · refresh soon"
else "$percent · ${formatSpan(end.until)} left"
}
}
/**
* One meter's snapshot, out of every machine's: [setup]'s row for [provider].
*
* Both halves are needed to pick it. A machine can hold more than one meter -- the Claude CLI's
* account and, while a test has one set, an echo session's invented one -- and a snapshot is one
* service on one machine.
*
* Every way of having *failed* to get numbers is [SessionUsage.Unavailable] with the reason in it.
* None of them may look like zero, and none may look like [SessionUsage.NotMetered], which is the
* machine having no quota rather than the question going unanswered.
*/
fun usageFor(snapshots: List<UsageSnapshot>, setup: String, provider: String): SessionUsage {
// No snapshot at all means the backend never asked, which it only does where there is nothing
// to ask about. That is a different answer from having asked and failed.
val mine =
snapshots.firstOrNull { it.setup == setup && it.provider == provider }
?: return SessionUsage.NotMetered
if (mine.state != "ok") {
return SessionUsage.Unavailable(mine.detail ?: mine.state)
}
return SessionUsage.Known(mine.windows)
}
@@ -1,197 +0,0 @@
package com.example.aiapp
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.example.wgapplink.EnrollmentScanActivity
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanIntentResult
import com.journeyapps.barcodescanner.ScanOptions
/**
* Server address and token. The normal path is the "Scan QR code" button below, which decodes the
* server's terminal QR itself; these fields are the fallback for typing the same three values by
* hand. [onBack] is null on first run, when there is nothing to go back to.
*/
@Composable
fun SettingsScreen(
existing: ServerSettings?,
onSaved: (ServerSettings) -> Unit,
onBack: (() -> Unit)?,
) {
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a way to read the credential
// back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scanLauncher =
rememberLauncherForActivityResult(ScanContract()) { result: ScanIntentResult ->
// Null contents means the user backed out of the scanner -- not an error.
val contents = result.contents ?: return@rememberLauncherForActivityResult
val settings = parseEnrollmentUri(contents.toUri())
if (settings == null) {
error = "Not a valid enrollment code"
} else {
saveServerSettings(context, settings)
onSaved(settings)
}
}
val requestCamera =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
scanLauncher.launch(enrollmentScanOptions())
} else {
error =
"Scanning needs the camera. Grant it in the system settings, " +
"or type the host, port and token in below."
}
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
// Leading, where a back arrow points at what it returns to. Trailing it would put a
// left-pointing arrow at the right edge, aimed across the title it sits beside.
//
// Absent rather than disabled on first run, which is the one place this app lets a
// control come and go: there is no screen underneath yet, so a Back here would not be a
// capability being withheld but a promise it could not keep.
if (onBack != null) {
GlyphButton(BACK_GLYPH, "Back", onBack)
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
}
Text(
"Server",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(8.dp))
Text(
"The easy way: run ai-server on the backend and scan the QR it prints. " +
"Or type the same values here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = {
// Hold the camera permission before the scanner starts. Letting its activity ask on
// our behalf is what the library does by default, and it opens the camera without
// waiting for the answer: the first-ever scan comes up as a live preview with
// "Sorry, the Android camera encountered a problem" over it, and works on the
// second try.
if (
context.checkSelfPermission(Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
) {
scanLauncher.launch(enrollmentScanOptions())
} else {
requestCamera.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text("Scan QR code")
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() ->
error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
}
}
}
) {
Text("Save")
}
}
}
/**
* How the enrollment QR is scanned, in one place because two callers reach it -- straight from the
* button when the camera permission is already held, and from the permission result when it has
* just been granted.
*
* MIXED_SCAN is the load-bearing part: ZXing otherwise looks only for a dark code on a light
* ground, and ai-server's QR is block characters in the terminal's foreground colour, so on a dark-
* themed terminal it comes out as a photographic negative the scanner silently never matches. The
* mixed decoder alternates normal and inverted frames, costing half the frame rate at each
* polarity.
*/
private fun enrollmentScanOptions(): ScanOptions =
ScanOptions()
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
.setCaptureActivity(EnrollmentScanActivity::class.java)
// Follow the phone, not the library's landscape pin.
.setOrientationLocked(false)
.addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
@@ -1,410 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The machines this backend can run things on.
*
* Note what this screen cannot do: name a program. Providers are what the server found when it
* asked the machine, so adding one is "here is how to reach it" and never "here is what to run" --
* which is what keeps the enrolled token from being able to introduce commands.
*/
@Composable
fun SetupsScreen(settings: ServerSettings, reloadToken: Int) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf<LoadState<List<Setup>>>(LoadState.Loading) }
var adding by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf<Setup?>(null) }
var confirmingDelete by remember { mutableStateOf<Setup?>(null) }
var busy by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
suspend fun reload() {
state =
try {
withContext(Dispatchers.IO) { LoadState.Loaded(fetchSetups(settings)) }
} catch (e: ApiException) {
LoadState.failed(e)
}
}
LaunchedEffect(reloadToken) { reload() }
Column(Modifier.fillMaxSize().padding(16.dp)) {
// The heading and Back are the tab row's now; adding a machine is this tab's own work and
// stays with the list it adds to.
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { adding = true }) { Text("Add machine") }
}
Spacer(Modifier.height(8.dp))
actionError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
busy?.let {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.height(16.dp).padding(end = 8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(8.dp))
}
when (val current = state) {
is LoadState.Loading -> CircularProgressIndicator()
is LoadState.Error -> Text(current.message, color = MaterialTheme.colorScheme.error)
is LoadState.Loaded ->
LazyColumn(Modifier.fillMaxSize()) {
uniqueItems(current.value, key = { it.id }) { setup ->
SetupCard(
setup = setup,
onRename = { renaming = setup },
onRediscover = {
scope.launch {
busy = "Asking ${setup.name} what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(
settings,
setup.id,
rediscover = true,
)
}
}
.exceptionOrNull()
?.message
busy = null
reload()
}
},
onDelete = { confirmingDelete = setup },
)
}
}
}
}
if (adding) {
AddSetupDialog(
onDismiss = { adding = false },
onAdd = { name, ssh ->
adding = false
scope.launch {
busy = "Asking $name what it has…"
actionError =
runCatching {
withContext(Dispatchers.IO) { addSetup(settings, name, ssh) }
}
.exceptionOrNull()
?.message
busy = null
reload()
}
},
onTest = { ssh -> withContext(Dispatchers.IO) { probeSetup(settings, ssh) } },
)
}
renaming?.let { setup ->
RenameDialog(
setup = setup,
onDismiss = { renaming = null },
onRename = { name ->
renaming = null
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) {
updateSetup(settings, setup.id, name = name)
}
}
.exceptionOrNull()
?.message
reload()
}
},
)
}
confirmingDelete?.let { setup ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Remove \"${setup.name}\"?") },
text = {
Text(
"The machine is left alone -- this only stops this app offering it. " +
"Sessions still running on it must be deleted first."
)
},
confirmButton = {
TextButton(
onClick = {
confirmingDelete = null
scope.launch {
actionError =
runCatching {
withContext(Dispatchers.IO) { deleteSetup(settings, setup.id) }
}
.exceptionOrNull()
?.message
reload()
}
}
) {
Text("Remove")
}
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
}
@Composable
private fun SetupCard(
setup: Setup,
onRename: () -> Unit,
onRediscover: () -> Unit,
onDelete: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(12.dp)) {
Text(setup.name, style = MaterialTheme.typography.titleSmall)
Text(
// Not "this machine": the seeded setup is *called* that, and the card read "this
// machine / this machine".
setup.address ?: "runs where the backend does",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
Text(
if (setup.providers.isEmpty()) {
"Nothing found on it. Install something and rediscover."
} else {
setup.providers.joinToString(" · ") { it.name }
},
style = MaterialTheme.typography.bodySmall,
)
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onRename) { Text("Rename") }
TextButton(onClick = onRediscover) { Text("Rediscover") }
Spacer(Modifier.weight(1f))
TextButton(onClick = onDelete) { Text("Remove") }
}
}
}
}
@Composable
private fun AddSetupDialog(
onDismiss: () -> Unit,
onAdd: (String, SshDetails?) -> Unit,
onTest: suspend (SshDetails?) -> List<Provider>,
) {
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var identity by remember { mutableStateOf("") }
var attachmentsDir by remember { mutableStateOf("") }
var modelsDir by remember { mutableStateOf("") }
var tested by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
fun details(): SshDetails? =
address
.trim()
.takeIf { it.isNotEmpty() }
?.let { typed ->
val (host, typedPort) = splitHostAndPort(typed)
SshDetails(
address = host,
port = typedPort,
identityFile = identity.trim().ifEmpty { null },
attachmentsDir = attachmentsDir.trim().ifEmpty { null },
modelsDir = modelsDir.trim().ifEmpty { null },
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add a machine") },
text = {
Column {
Text(
"Leave the address blank for the machine the backend runs on. " +
"What it can run is discovered, not typed.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
// Just the shape. What a blank one means is said once, in the text above this
// form -- repeating it here wrapped the label onto a second line.
label = { Text("user@host[:port]") },
singleLine = true,
)
OutlinedTextField(
value = identity,
onValueChange = { identity = it },
label = { Text("Key path on the backend") },
singleLine = true,
)
// Where a file attached from the phone lands on that machine. Blank means the
// session's own directory, which is what most people want.
OutlinedTextField(
value = attachmentsDir,
onValueChange = { attachmentsDir = it },
label = { Text("Folder for attached files (optional)") },
singleLine = true,
)
// Where that machine's GGUFs are, for a llama.cpp session on it. Blank means
// the same place this backend keeps its own downloads, read on that machine.
OutlinedTextField(
value = modelsDir,
onValueChange = { modelsDir = it },
label = { Text("Folder for models (optional)") },
singleLine = true,
)
tested?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = { onAdd(name.trim(), details()) }) {
Text("Add")
}
},
dismissButton = {
Row {
// Tried before saving, so a wrong address or an unauthorised key is caught while
// this form is still on screen rather than at the first spawn.
TextButton(
enabled = !testing,
onClick = {
testing = true
tested = "Asking…"
scope.launch {
tested =
runCatching { onTest(details()) }
.fold(
onSuccess = { found ->
if (found.isEmpty()) {
"Reached it, but found nothing it can run."
} else {
"Found ${found.joinToString(", ") { it.name }}"
}
},
onFailure = { it.message ?: "Couldn't reach it" },
)
testing = false
}
},
) {
Text("Test")
}
TextButton(onClick = onDismiss) { Text("Cancel") }
}
},
)
}
@Composable
private fun RenameDialog(setup: Setup, onDismiss: () -> Unit, onRename: (String) -> Unit) {
var name by remember { mutableStateOf(setup.name) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename") },
text = {
Column {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
)
Spacer(Modifier.height(8.dp))
Text(
"Sessions already running on it keep working -- they refer to the machine, " +
"not to what it is called.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(enabled = name.isNotBlank(), onClick = { onRename(name.trim()) }) {
Text("Rename")
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
/**
* Splits `user@host:port` into its two halves, with the port left null when none was typed.
*
* One field rather than two because that is how an address is written and read everywhere else, and
* because a port that is almost always 22 does not deserve a box of its own on a phone keyboard.
* Null rather than 22: the backend already decides the default.
*
* A colon only means "port" when it can. A bracketed IPv6 literal is unwrapped as ssh writes it,
* `[::1]:22`; a bare `::1` keeps every colon. So the rule is: brackets, or exactly one colon
* followed by digits.
*/
private fun splitHostAndPort(typed: String): Pair<String, Int?> {
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
}
@@ -1,44 +0,0 @@
package com.example.aiapp
import android.content.Intent
import android.net.Uri
import androidx.core.content.IntentCompat
/**
* What another app handed this one through the share sheet, waiting to be attached to a session.
*
* Held as the URIs rather than uploaded on arrival, because an upload belongs to a session and the
* share arrives before anyone has said which. [serial] makes two shares of the same thing two
* requests, for the reason [SessionOpenRequest] carries one.
*/
data class ShareRequest(val uris: List<Uri>, val text: String?, val serial: Int)
/** The share in [intent], or null when it is some other intent. */
fun sharedContent(intent: Intent, serial: Int): ShareRequest? {
val uris =
when (intent.action) {
Intent.ACTION_SEND ->
listOfNotNull(
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
)
Intent.ACTION_SEND_MULTIPLE ->
IntentCompat.getParcelableArrayListExtra(
intent,
Intent.EXTRA_STREAM,
Uri::class.java,
)
.orEmpty()
else -> return null
}
val text = intent.getStringExtra(Intent.EXTRA_TEXT)?.takeIf { it.isNotBlank() }
if (uris.isEmpty() && text == null) return null
return ShareRequest(uris, text, serial)
}
/** What is waiting, for the banner that says so. */
fun ShareRequest.summary(): String =
when {
uris.size == 1 -> "1 file to attach"
uris.isNotEmpty() -> "${uris.size} files to attach"
else -> "Text to attach"
}
@@ -1,20 +0,0 @@
package com.example.aiapp
/**
* A byte count at the coarsest unit that still says something, so rows stay comparable.
*
* Null at zero and below, because the screens that ask disagree about what nothing means and only
* the caller knows: a transcript of no bytes is a measurement that has not happened; a file of no
* bytes is a file with nothing in it, and the explorer says `0 B`; a session with no cached
* transcript says "nothing cached", because a figure of none would read as a measurement.
*
* Its own file rather than the import screen's, where it started: three screens now say a size, and
* a second copy of these thresholds is how one list comes to call 4 kB what the other calls 4096 B.
*/
fun humanSize(bytes: Long): String? =
when {
bytes <= 0L -> null
bytes >= 1_000_000L -> "${bytes / 1_000_000L} MB"
bytes >= 1_000L -> "${bytes / 1_000L} kB"
else -> "$bytes B"
}
@@ -1,386 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The spawn screen: what to run, where to run it, and the per-kind fields.
*
* Providers and hosts both come from the server, so adding either to its config.ron shows up here
* with no app rebuild.
*/
@Composable
fun SpawnScreen(
settings: ServerSettings,
onSpawned: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
// What the form is made of, and whether we have it yet. A failure here is not the same as a
// server with nothing to offer, so it must not reach the pickers as empty lists.
var options by remember { mutableStateOf<LoadState<List<Setup>>>(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<String?>(null) }
var providerName by remember { mutableStateOf<String?>(null) }
var title by remember { mutableStateOf("") }
var model by remember { mutableStateOf("") }
var cwd by remember { mutableStateOf("") }
// "auto" rather than "manual": on a phone every ask is a round trip to a question card, and
// answering "allow Bash?" dozens of times per task is what this app exists to avoid.
var permissionMode by remember { mutableStateOf("auto") }
// Null until the server has been asked, and null again if it answers "no level chosen" -- the
// two are told apart by [defaultsAsked], because a picker that shows a level before the answer
// arrives is one you can spawn at without having chosen it.
var effort by remember { mutableStateOf<String?>(null) }
var defaultsAsked by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
// Only the spawn's own failure. The fetch's lives in `options`: this one leaves a filled-in
// form worth keeping, and that one leaves nothing to fill in.
var spawnError by remember { mutableStateOf<String?>(null) }
// The models on the *chosen machine*, for a llama provider to choose between. Kept separate
// from the setups: a Claude session needs none, so failing to list them must not stop the
// screen rendering. Refetched when the machine changes, because a model is a file on one
// machine -- see [fetchSetupModels].
var models by remember { mutableStateOf<List<LocalModel>>(emptyList()) }
var modelKey by remember { mutableStateOf<String?>(null) }
var contextSize by remember { mutableStateOf("") }
var temperature by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
// Separate from the setups fetch below and deliberately not fatal: failing to learn the
// default must leave a screen you can still spawn from, so the picker stays on "default"
// and says so rather than the whole form refusing to draw.
runCatching { withContext(Dispatchers.IO) { fetchDefaultEffort(settings) } }
.onSuccess { effort = it }
defaultsAsked = true
options =
try {
val fetched = withContext(Dispatchers.IO) { fetchSetups(settings) }
val first = fetched.firstOrNull()
setupName = first?.name
providerName = first?.providers?.firstOrNull()?.name
LoadState.Loaded(fetched)
} catch (e: ApiException) {
LoadState.failed(e)
}
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"New session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Cancel") }
}
Spacer(Modifier.height(16.dp))
// Nothing below is fillable until the options are here, and a failure to fetch them leaves
// no form worth showing -- so this reports and stops, rather than offering empty pickers
// under an error message.
val setups =
when (val state = options) {
is LoadState.Loading -> {
CircularProgressIndicator()
return@Column
}
is LoadState.Error -> {
Text(state.message, color = MaterialTheme.colorScheme.error)
return@Column
}
is LoadState.Loaded -> state.value
}
val setup = setups.firstOrNull { it.name == setupName }
// Whichever machine is chosen now, asked again when that changes. The old machine's list
// is dropped first rather than left on screen: a file name from another machine looks
// exactly like one from this one.
LaunchedEffect(setup?.id) {
models = emptyList()
modelKey = null
val id = setup?.id ?: return@LaunchedEffect
models =
runCatching { withContext(Dispatchers.IO) { fetchSetupModels(settings, id) } }
.getOrDefault(emptyList())
}
val current = setup?.providers?.firstOrNull { it.name == providerName }
// Only the Claude CLI has models, a working directory and permission modes; keying the
// extra fields on the kind rather than the provider name keeps a second Claude provider
// from needing anything here.
val isClaude = current?.kind == "claude_cli"
val isLlama = current?.kind == "llama_cpp"
// The machine first, because it decides what can be run at all.
ChipGroup(
label = "Setup",
options = setups.map { it.name },
selected = setupName,
onSelect = { name ->
setupName = name
// The provider list changes with the machine, so a name carried over from the
// previous one would be a selection that isn't in the picker. Take that machine's
// first.
providerName =
setups.firstOrNull { it.name == name }?.providers?.firstOrNull()?.name
},
)
setup?.address?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// The address belongs to the setup above it, not to the provider label below; without
// this they read as one block.
Spacer(Modifier.height(8.dp))
}
// Only what this machine actually has. A setup with none says so rather than showing an
// empty row that reads as a failure.
if (setup != null && setup.providers.isEmpty()) {
Text(
"\"${setup.name}\" has no providers configured.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Provider",
options = setup?.providers?.map { it.name }.orEmpty(),
selected = providerName,
onSelect = { providerName = it },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (isLlama) {
// A llama session names one of the models on the machine it will run on, so the
// choice is that list rather than free text -- a name that is not on that machine's
// disk is a session that cannot start.
if (models.isEmpty()) {
Text(
"No models on ${setup?.name ?: "this machine"}. The Models screen downloads " +
"to the backend; another machine needs the file put there itself.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
ChipGroup(
label = "Model",
// The file, not the whole key: the repository is the same for every
// quantisation of a model, so the file name is what tells two of them apart.
options = models.map { it.file },
selected = models.firstOrNull { it.key == modelKey }?.file,
onSelect = { file -> modelKey = models.first { it.file == file }.key },
)
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = contextSize,
onValueChange = { contextSize = it },
label = { Text("Context size (blank = the model's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = temperature,
onValueChange = { temperature = it },
label = { Text("Temperature (blank = llama.cpp's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
}
if (isClaude) {
if (current.models.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Model",
options = current.models,
selected = model.ifEmpty { null },
onSelect = { chosen -> model = if (model == chosen) "" else chosen },
)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = model,
onValueChange = { model = it },
label = { Text("Model (blank = the CLI's default)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = cwd,
onValueChange = { cwd = it },
label = { Text("Working directory") },
placeholder = { Text("/home/…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
ChipGroup(
label = "Permissions",
options = PERMISSION_MODES,
selected = permissionMode,
onSelect = { permissionMode = it },
)
Spacer(Modifier.height(16.dp))
// Says what it does to *later* spawns as well, because it does: the level chosen here
// is stored as the default, which is the whole way that default is set. A picker that
// quietly changed a global would be the same control with the fact left out.
ChipGroup(
label = "Thinking (kept as the default for new sessions)",
options = listOf(DEFAULT_EFFORT) + EFFORT_LEVELS,
// The CLI's own default is a level in the list, so this cannot be a one-way trip.
// Disabled-looking until the server has answered, for the reason above.
selected = if (defaultsAsked) effort ?: DEFAULT_EFFORT else null,
onSelect = { chosen -> effort = chosen.takeIf { it != DEFAULT_EFFORT } },
)
}
Spacer(Modifier.height(24.dp))
// Beside the button that produced it.
spawnError?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
val chosen = current ?: return@Button
busy = true
scope.launch {
try {
val spawned =
withContext(Dispatchers.IO) {
// Stored before the spawn and not after it: choosing a level is
// an intent about new sessions in general, so a spawn that then
// fails must not also lose the choice. Non-fatal for the same
// reason the fetch above is -- the session is what was asked for.
if (isClaude) {
runCatching { setDefaultEffort(settings, effort) }
}
spawnSession(
settings,
// The id, not the label: labels are editable and the server
// resolves by id. Non-null here, since `chosen` came from
// `setup`'s own provider list.
setup = setup.id,
provider = chosen.name,
title = title.trim(),
model =
if (isLlama) modelKey else model.trim().takeIf { isClaude },
cwd = cwd.trim().takeIf { isClaude },
permissionMode = permissionMode.takeIf { isClaude },
effort = effort.takeIf { isClaude },
// Sent only when set, so blank means "whatever llama.cpp does
// by default" rather than a zero.
params =
buildMap {
if (isLlama) {
contextSize
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("contextSize", it) }
temperature
.trim()
.takeIf { it.isNotEmpty() }
?.let { put("temperature", it) }
}
},
)
}
onSpawned(spawned)
} catch (e: ApiException) {
spawnError = e.message
busy = false
}
}
},
enabled = !busy && current != null && !(isLlama && modelKey == null),
) {
Text(if (busy) "Spawning..." else "Spawn")
}
}
}
/**
* A labeled row of choices that wraps onto as many lines as it needs.
*
* FlowRow rather than Row: a plain Row gives every chip an equal share of a single line, so once
* the options don't fit, the text inside each one wraps to one character per line instead of the
* row wrapping.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ChipGroup(
label: String,
options: List<String>,
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) },
)
}
}
}
@@ -1,100 +0,0 @@
package com.example.aiapp
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* How long to wait before opening a dropped stream again.
*
* Shared by every screen that follows one, so a reconnect is not paced differently depending on
* which stream dropped. Short enough that a tunnel coming back is not noticed, long enough that a
* server which is genuinely down is not being asked several times a second.
*/
const val RECONNECT_DELAY_MS = 1500L
/**
* One server-sent-events connection, framed.
*
* The framing is the part worth having once: `data:` and `event:` lines accumulate until a blank
* line ends the frame, comments start with `:`, and a frame is either named with no payload or a
* payload with no name. Two screens follow two different streams and neither should re-derive that.
*
* Blocking: [run] occupies its thread until the stream ends. [close], from any thread, is the
* cancellation path -- it disconnects the socket, which unblocks the read, and [run] then returns
* rather than throwing. Reconnecting belongs to the caller, which is the only one that knows where
* to resume from.
*/
class Sse(private val settings: ServerSettings) {
@Volatile private var connection: HttpURLConnection? = null
@Volatile private var closed = false
fun close() {
closed = true
connection?.disconnect()
}
/**
* Follows the stream at [path], handing each frame to [onFrame] as its name (null for an
* ordinary data frame) and its payload. The path is given here rather than at construction
* because a caller that reconnects usually resumes from somewhere new.
*
* [onOpen] fires once the server has accepted the connection. That is the measured moment the
* stream is live, and the only honest thing to clear a previous failure on: clearing on the
* first *event* instead left an idle stream displaying an error it had already recovered from.
*/
fun run(path: String, onOpen: () -> Unit, onFrame: (name: String?, data: String) -> Unit) {
// Opening is inside the try, not before it. Everything this method can fail at owes the
// caller the same kind of failure, and a connection that could not even be constructed used
// to escape as a raw `IOException` from a line no `catch` covered.
var connection: HttpURLConnection? = null
try {
connection =
(URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection).also {
this.connection = it
}
connection.applyPinnedTls()
connection.connectTimeout = CONNECT_TIMEOUT_MS
// No read timeout: between events there is nothing to read for as long as the thing
// being followed is idle; the server's keep-alives and a dead socket erroring out are
// the liveness story.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
}
onOpen()
val reader = connection.inputStream.bufferedReader()
val data = StringBuilder()
var name: String? = null
while (true) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (name != null || data.isNotEmpty()) onFrame(name, data.toString())
data.clear()
name = null
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
line.startsWith("event:") -> name = line.removePrefix("event:").trim()
else -> {} // id:, comments -- nothing to do
}
}
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
if (!closed) {
throw ApiException(
"Can't reach the server -- retrying. (${e.message ?: e::class.simpleName})",
cause = e,
)
}
} finally {
connection?.disconnect()
this.connection = null
}
}
}
@@ -1,108 +0,0 @@
package com.example.aiapp
/**
* How much of a long thing the transcript draws before offering the rest behind a tap.
*
* One rule, four surfaces: a tool call's input, its output, and a user or assistant message. Kept
* in one file because four copies would eventually disagree about what "too long" is -- and because
* the Rust app answers the same question with the same numbers (`client-core`'s `text_cap.rs`, the
* other half of this). The two are deliberately identical so that a benchmark comparing the apps is
* comparing renderers rather than policies.
*
* **Lines and bytes both, whichever runs out first**, because they run out on different things: a
* diff is thousands of short lines, a minified file or a base64 blob is one enormous one, and a cap
* counting only one of them draws the whole of the other.
*
* **Cut at the head, keeping the beginning.** A tool's output is read from the top and the line
* saying what went wrong is nearly always the first; a message is read from the top for the obvious
* reason. (A path is identified by its other end -- none of these is a path.)
*/
object TextCap {
/**
* The bound on a verbatim block -- a tool call's input or its output. Short, because this text
* is a machine's and the reader is looking for one line of it.
*/
const val VERBATIM_LINES = 80
const val VERBATIM_BYTES = 4096
/**
* The bound on a message. Larger than a verbatim block's in bytes and smaller in lines: prose
* is read whole and wraps, so a screenful of it is far fewer lines than a screenful of a log,
* and cutting a reply at 80 lines would cut most long answers that nobody would call long.
*/
const val MESSAGE_LINES = 200
const val MESSAGE_BYTES = 16 * 1024
}
/** [text] cut down to a bound, with the line count of the whole of it. See [cutText]. */
data class CutText(
/** What to draw. */
val shown: String,
/**
* The line count of the **whole** text, not of [shown] -- it is what the "Show all N lines"
* offer says, and a reader deciding whether to ask for the rest wants to know how much the rest
* is.
*/
val lines: Int,
)
/**
* [text] cut to [maxLines] lines and [maxBytes] bytes, or `null` when the whole of it fits.
*
* Bytes rather than characters, so that this and the Rust half cut a multi-byte character at the
* same place. UTF-8 is what the wire carries and what `client-core` measures.
*/
fun cutText(text: String, maxLines: Int, maxBytes: Int): CutText? {
require(maxLines > 0 && maxBytes > 0) {
"a cap of nothing shows an empty block and a 'Show all' for every value there is"
}
val bytes = text.toByteArray(Charsets.UTF_8)
var byLines = -1
var seen = 0
for (i in text.indices) {
if (text[i] == '\n') {
seen++
if (seen == maxLines) {
byLines = i
break
}
}
}
val byBytes =
if (bytes.size > maxBytes) {
// Back up to a character boundary. A UTF-8 continuation byte is `10xxxxxx`; cutting on
// one would split a character in half and `String(bytes)` would draw a replacement mark
// where an em dash was.
var end = maxBytes
while (end > 0 && (bytes[end].toInt() and 0xC0) == 0x80) end--
String(bytes, 0, end, Charsets.UTF_8).length
} else {
-1
}
val cut =
when {
byLines >= 0 && byBytes >= 0 -> minOf(byLines, byBytes)
byLines >= 0 -> byLines
byBytes >= 0 -> byBytes
else -> return null
}
return CutText(text.take(cut), lineCount(text))
}
/**
* How many lines [text] holds, counted the way Rust's `str::lines` counts them -- a trailing
* newline ends the last line rather than starting an empty one.
*
* Said here rather than left to `lineSequence().count()`, which disagrees on exactly that case: the
* two apps have to offer "Show all N lines" with the same N for the same message, and a count that
* is one out on every text ending in a newline (which is most tool output) would show it.
*/
fun lineCount(text: String): Int =
when {
text.isEmpty() -> 0
text.endsWith("\n") -> text.count { it == '\n' }
else -> text.count { it == '\n' } + 1
}
/** What a "Show all" offer says, so the wording is one string rather than one per surface. */
fun showAllLabel(lines: Int): String = "Show all $lines lines"
@@ -1,336 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Catppuccin Mocha, as published in `catppuccin/palette`.
*
* Named rather than used as literals at the point of need, so the mapping below reads as the
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
* palette without reading the layout that uses it.
*/
private object Mocha {
val Rosewater = Color(0xFFF5E0DC)
val Mauve = Color(0xFFCBA6F7)
val Red = Color(0xFFF38BA8)
val Peach = Color(0xFFFAB387)
val Yellow = Color(0xFFF9E2AF)
val Green = Color(0xFFA6E3A1)
val Teal = Color(0xFF94E2D5)
val Sky = Color(0xFF89DCEB)
val Blue = Color(0xFF89B4FA)
val Lavender = Color(0xFFB4BEFE)
val Pink = Color(0xFFF5C2E7)
val Text = Color(0xFFCDD6F4)
val Subtext1 = Color(0xFFBAC2DE)
val Subtext0 = Color(0xFFA6ADC8)
val Overlay0 = Color(0xFF6C7086)
val Surface2 = Color(0xFF585B70)
val Surface1 = Color(0xFF45475A)
val Surface0 = Color(0xFF313244)
val Base = Color(0xFF1E1E2E)
val Mantle = Color(0xFF181825)
val Crust = Color(0xFF11111B)
}
/**
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
*
* Copied from dev-updater rather than shared, which is a deliberate line: wg-app-link is the *link*
* -- the tunnel, the pinned CA, enrollment -- and a palette is not that. The two apps looking alike
* is a preference, not a contract.
*
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
* Base, Surface 0, Surface 1 -- so the page is Base, a component's outlined card stays Base beside
* it, and a project's card is Surface 0: one visible step up, which is the whole of what the
* nesting has to say.
*
* Accents on this palette are light, so anything filled with one takes Crust for its text.
*/
val AiAppColors =
darkColorScheme(
primary = Mocha.Mauve,
onPrimary = Mocha.Crust,
primaryContainer = Mocha.Surface1,
onPrimaryContainer = Mocha.Mauve,
secondary = Mocha.Lavender,
onSecondary = Mocha.Crust,
secondaryContainer = Mocha.Surface1,
onSecondaryContainer = Mocha.Lavender,
tertiary = Mocha.Rosewater,
onTertiary = Mocha.Crust,
background = Mocha.Base,
onBackground = Mocha.Text,
surface = Mocha.Base,
onSurface = Mocha.Text,
surfaceVariant = Mocha.Surface0,
onSurfaceVariant = Mocha.Subtext0,
surfaceContainerLowest = Mocha.Crust,
surfaceContainerLow = Mocha.Mantle,
surfaceContainer = Mocha.Base,
surfaceContainerHigh = Mocha.Surface0,
surfaceContainerHighest = Mocha.Surface0,
inverseSurface = Mocha.Text,
inverseOnSurface = Mocha.Base,
inversePrimary = Mocha.Mauve,
outline = Mocha.Overlay0,
outlineVariant = Mocha.Surface2,
error = Mocha.Red,
onError = Mocha.Crust,
errorContainer = Mocha.Surface1,
onErrorContainer = Mocha.Red,
scrim = Mocha.Crust,
)
/**
* What a session is doing, said in colour.
*
* Here rather than beside each screen that shows a status. These were separate literals in two
* other files, so the same state was a slightly different colour depending which screen you looked
* at. A colour that carries meaning is part of the scheme, not a value typed where it was needed.
*/
val runningColor: Color
@Composable get() = Mocha.Green
/**
* "This went wrong on its own": a session that fell over.
*
* The scheme's error colour, and deliberately not "the same red as a destructive button" even
* though it is the same red. They are the same red for different reasons, and a state is not an
* action.
*/
val failedColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* About the session rather than about the task: a command, and the compaction one of them starts.
*
* Its own colour because it is its own kind of work. Everything else a session does is progress
* through what was asked of it; this is the session acting on itself, and none of it appears in the
* transcript as an answer to anything. A reader who has learned that blue means "not stuck, but not
* replying to you either" has learned what distinguishes it from a session that has hung.
*/
val commandColor: Color
@Composable get() = Mocha.Blue
/**
* A clear: the conversation taken out of what the session is given.
*
* Red because of what it does, not because anything went wrong -- somebody asked for this, and a
* deliberate choice is not a problem to report. The same red as [failedColor] and [stopColor] for a
* third reason: this is neither a fault nor a button, it is the mark left where something was taken
* away. No two of the three can appear as the same kind of thing.
*/
val clearedColor: Color
@Composable get() = Mocha.Red
/** Waiting on a person: a question, a permission, a turn that is theirs. */
val awaitingColor: Color
@Composable get() = Mocha.Peach
/** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color
@Composable get() = Mocha.Yellow
/**
* The fill of a progress bar that is only reporting how far along something is.
*
* Blue because a bar like this reports a quantity rather than a verdict, and the scheme's primary
* made it the loudest thing on a screen the reader opened to do something else. A download has no
* limit to be near: it finishes. Only a bar measuring a *quota* escalates -- that one is
* [quotaColor].
*/
val progressColor: Color
@Composable get() = Mocha.Blue
/**
* The fill of a bar measuring how much of a quota is gone: blue, then yellow, then red.
*
* One function rather than the same `when` written beside each bar, because the point of colouring
* by consequence is that the reader learns the step once. It reads as a difference in degree, which
* is all colour can carry: the states that differ in *kind* -- a window nobody could read, a
* machine that meters nothing -- are said in words elsewhere.
*
* [percent] is the API's own 0-100 rather than a fraction, so callers pass what the server sent
* without one of them getting it wrong by a factor of a hundred.
*/
@Composable
fun quotaColor(percent: Double): Color =
when {
percent >= OVER_LIMIT_PERCENT -> overLimitColor
percent >= WARNING_PERCENT -> warningColor
else -> progressColor
}
/** Close enough to the limit to be worth seeing before starting something big. */
private const val WARNING_PERCENT = 75.0
/** Close enough that the next turn may be the one that is refused. */
private const val OVER_LIMIT_PERCENT = 90.0
/**
* The surface verbatim text sits on: a command, a tool's output, a code block in a reply.
*
* The darkest value in the palette rather than a step up from the page, and that is the point --
* everything else on this screen is somebody's prose, and this is what a machine was handed and
* what it said back, character for character. Crust sits *below* Base, so the same colour reads as
* one clear step down both on the page and on a card; a tint chosen upwards has to be picked twice
* and still collides with the card it lands on.
*
* One colour for all three, so "this is verbatim" is learnable once.
*/
val rawSurface: Color
@Composable get() = Mocha.Crust
/**
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
*
* Here with the rest of the palette rather than beside the code that highlights: the colours a
* fence is drawn in are the same accents every other coloured thing already uses.
*
* Not a composable, because [highlight] runs off the drawing thread; these never vary with the
* theme.
*/
fun catppuccinSyntax(): SyntaxPalette =
SyntaxPalette(
keyword = Mocha.Mauve,
string = Mocha.Green,
literal = Mocha.Peach,
comment = Mocha.Overlay0,
metadata = Mocha.Yellow,
punctuation = Mocha.Subtext0,
mark = Mocha.Sky,
)
/**
* The sixteen terminal colours, for what a Bash tool call printed; see [AnsiPalette].
*
* Catppuccin publishes its own ANSI mapping and this is it, rather than the eight accents picked by
* eye: a program printing in "colour 4" means blue, and which blue is a decision the palette has
* already made for every other blue on the screen.
*
* Mocha's bright half is the same accents as its normal half -- only the two greys differ -- which
* is upstream's choice and not an omission here.
*
* The background is [rawSurface] because that is what a tool's output is drawn on, and reverse
* video needs to know what it is reversing against.
*/
fun ansiPalette(): AnsiPalette =
AnsiPalette(
colours =
listOf(
Mocha.Surface1,
Mocha.Red,
Mocha.Green,
Mocha.Yellow,
Mocha.Blue,
Mocha.Pink,
Mocha.Teal,
Mocha.Subtext1,
Mocha.Surface2,
Mocha.Red,
Mocha.Green,
Mocha.Yellow,
Mocha.Blue,
Mocha.Pink,
Mocha.Teal,
Mocha.Subtext0,
),
foreground = Mocha.Text,
background = Mocha.Crust,
)
/**
* What a selection looks like, stated rather than left to Material's default.
*
* The default is `primary` at 40% alpha, which is a tint of whatever is behind it -- and this app
* draws text on surfaces two full steps apart. Over a reply, on Base, that reads clearly. Over a
* code block, on Crust, the same 40% composites to a barely-there smudge, so selecting a line of
* code looks like nothing happened even though it copies correctly.
*
* Fixed and stronger, because "this is selected" is a meaning rather than decoration. Raised only
* as far as it takes to read on the darkest of them -- past this the fill starts competing with the
* syntax colours it sits behind.
*/
val AiAppSelectionColors =
TextSelectionColors(
handleColor = Mocha.Mauve,
backgroundColor = Mocha.Mauve.copy(alpha = 0.55f),
)
/**
* A link. Blue is what a link is on every Catppuccin surface, and the one colour to leave alone.
*/
val linkColor: Color
@Composable get() = Mocha.Blue
/**
* A list's markers: the bullets and numbers down its left edge.
*
* The scheme's secondary accent rather than the text colour, because a marker is structure rather
* than words: coloured, the items of a list can be counted without reading them. Lavender is not
* one of the colours that mean something here, and it is the same at every depth, since depth is
* said by the glyph and the indent -- a colour per depth would make a difference in degree look
* like one in kind.
*/
val listMarkerColor: Color
@Composable get() = Mocha.Lavender
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* The composer's buttons, coloured by what pressing one does rather than by where it sits.
*
* Green makes something happen now, blue makes it happen later, orange takes back what is in
* flight, red ends the process. The near-collisions with the states above are deliberate: those are
* *states*, and these are *actions*. A reader never has to tell them apart, because nothing here is
* a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */
val queueColor: Color
@Composable get() = Mocha.Blue
/**
* Interrupting the running turn: the work stops and the session stays.
*
* Orange rather than red because of how much it takes: only what is in flight. The process is still
* there holding the conversation. Red is spent on [stopColor], which is the same button in the same
* place when what it would end is the session's process.
*/
val pauseColor: Color
@Composable get() = Mocha.Peach
/** Ending the session's process -- the one button here that takes something away. */
val stopColor: Color
@Composable get() = Mocha.Red
/**
* Starting the process again, on the conversation it left.
*
* The same green as [sendColor] on purpose: both mean "this happens now", and they are never the
* same button -- the process button only offers to start when there is nothing running to stop.
*/
val startColor: Color
@Composable get() = Mocha.Green
/**
* A filled button in one of the action colours above.
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
@@ -1,219 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import org.json.JSONObject
/**
* A tool call's input, read rather than dumped.
*
* Every tool's input arrives as JSON, and showing it raw makes the reader parse
* `{"command":"…","timeout":120000}` themselves to find the one line they care about. So the fields
* that carry the meaning are pulled out, and anything left over is still shown, because dropping a
* field would be claiming the tool has no other input when it might.
*/
data class ToolInput(
/** The thing that will actually be run or read, if this tool has one. */
val subject: String?,
/** The language [subject] is written in, for highlighting. */
val language: Language?,
/** The tool's own one-line summary, when it wrote one. */
val description: String?,
/**
* How long the call may take, in the largest units it fits. Shown apart because it is a limit
* on the call rather than part of what the call does.
*/
val timeout: String?,
/** Everything else, as `name: value` lines. Never dropped. */
val rest: List<String>,
) {
/** The one line to show when there is only room for one: what this call is for. */
val title: String?
get() = description ?: subject
}
/**
* Which field of which tool is the subject.
*
* A table rather than a chain of `if`s: adding a tool is a row, and the shape stops any of them
* from being the special case that gets its own code path. Unknown tools fall through to "no
* subject, everything is rest".
*/
private val SUBJECTS: Map<String, Pair<String, Language?>> =
mapOf(
"Bash" to ("command" to Language.SHELL),
"Read" to ("file_path" to null),
"Write" to ("file_path" to null),
"Edit" to ("file_path" to null),
"Glob" to ("pattern" to null),
"Grep" to ("pattern" to null),
"WebFetch" to ("url" to null),
)
/** Fields that are the tool's own prose about itself rather than input to it. */
private val DESCRIPTIONS = listOf("description", "prompt")
fun parseToolInput(tool: String, input: String): ToolInput {
val json =
try {
JSONObject(input)
} catch (_: org.json.JSONException) {
// Not an object: older transcripts and some tools send a bare string. It is still the
// input, so it is still shown.
return ToolInput(
null,
null,
null,
null,
input.takeIf { it.isNotBlank() }?.let { listOf(it) }.orEmpty(),
)
}
val (subjectKey, language) = SUBJECTS[tool] ?: (null to null)
val subject = subjectKey?.let { json.optString(it) }?.takeIf { it.isNotBlank() }
val description = DESCRIPTIONS.firstNotNullOfOrNull {
json.optString(it).takeIf { v -> v.isNotBlank() }
}
val timeout = json.optString("timeout").takeIf { it.isNotBlank() }?.let { formatMillisText(it) }
val rest =
json
.keys()
.asSequence()
.filter { it != subjectKey || subject == null }
.filter { it !in DESCRIPTIONS || description == null }
.filter { it != "timeout" || timeout == null }
.sorted()
.map { key -> "$key: ${json.get(key)}" }
.toList()
return ToolInput(subject, language, description, timeout, rest)
}
/**
* A tool call's input: its subject highlighted, then whatever else it carried.
*
* On the dark surface every verbatim thing in the app sits on. Drawn as nothing at all when the
* call carried neither, rather than as an empty block: a tinted rectangle with nothing in it is a
* rendering fault.
*
* The description is *not* here. It is the tool's own prose about what it is doing, so it belongs
* with the reader's text rather than inside the machine's; [ToolCard] draws it above this.
*
* **Capped.** An `Edit`'s `old_string` and `new_string` arrive here whole and are routinely the
* largest text on the screen, so the input is cut to [TextCap.VERBATIM_LINES] /
* [TextCap. VERBATIM_BYTES] with a "Show all" under it -- one control for both blocks, because the
* subject and the leftover fields are two halves of the same answer to "what was this call given",
* and two would make the reader ask twice. [whole] is the reader having already asked.
*/
@Composable
fun ToolInputView(
tool: String,
input: String,
modifier: Modifier = Modifier,
whole: Boolean = false,
onShowAll: () -> Unit = {},
) {
val parsed = remember(tool, input) { parseToolInput(tool, input) }
if (parsed.subject == null && parsed.rest.isEmpty()) return
val subject = remember(parsed.subject, whole) { capped(parsed.subject, whole) }
val rest =
remember(parsed.rest, whole) {
capped(parsed.rest.takeIf { it.isNotEmpty() }?.joinToString("\n"), whole)
}
RawBlock(modifier) {
subject.shown?.let { shown ->
// Not wrapped: a wrapped command hides where its arguments end, and the long one is the
// one being read closely.
Text(
// Highlighted over what is *drawn* rather than over the whole subject, so a cut
// cannot leave a span pointing past the end of the text it styles.
//
// Not cached: a tool's subject is one command line, which lexes in microseconds --
// the cache exists for a fence with two hundred lines in it.
remember(shown, parsed.language) { highlight(shown, parsed.language) },
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
}
rest.shown?.let { shown ->
// Never dropped: a field left out would be claiming the tool has no other input when it
// might. Not wrapped, for the subject's reason -- Iris, 2026-09-08: "for 'raw' text
// like
// tool results I think it should not be wrapped".
Text(
shown,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
modifier =
Modifier.padding(top = 2.dp)
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
)
}
// The count is the whole input's, both blocks together, because that is what the one
// control reveals.
if (subject.cut || rest.cut) {
ShowAllRow(lines = subject.lines + rest.lines, onClick = onShowAll)
}
}
}
/** One verbatim block as it will be drawn; see [capped]. */
data class CappedBlock(
/** The text to draw, or `null` when there was none to begin with. */
val shown: String?,
/** The line count of the whole of it. */
val lines: Int,
/** Whether anything was left out. */
val cut: Boolean,
)
/**
* [text] as an open card draws it: the whole of it when [whole], or [TextCap]'s worth otherwise.
*
* `null` in, `null` out, so a caller with nothing to draw reads the same three fields as one with
* something.
*/
private fun capped(text: String?, whole: Boolean): CappedBlock {
if (text == null) return CappedBlock(null, 0, false)
val cut = if (whole) null else cutText(text, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
return when (cut) {
null -> CappedBlock(text, lineCount(text), false)
else -> CappedBlock(cut.shown, cut.lines, true)
}
}
/**
* The "Show all N lines" under a capped block.
*
* It says the count rather than "more" because the reader is deciding whether to ask for it: "Show
* all 4,000 lines" and "Show all 12 lines" are different decisions, and "more" tells them apart not
* at all.
*/
@Composable
fun ShowAllRow(lines: Int, onClick: () -> Unit) {
val label = showAllLabel(lines)
Text(
label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(top = 4.dp).clickable(onClick = onClick).semantics {
contentDescription = label
},
)
}
@@ -1,494 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One row as the transcript draws it: a run of consecutive tool calls, or anything else.
*
* Grouping is decided here rather than when events are folded, because it is a display decision:
* the transcript's own order is what paging and the event stream depend on, and one screen's idea
* of "these belong together" must not reach back into it.
*
* Immutable, and said so, because Compose cannot tell: a row is rebuilt from the transcript rather
* than edited, and two rows describing the same events are equal. Compose infers stability from a
* class's fields, and a `List` field -- which several of these carry -- makes it assume the worst,
* so a page of history landing recomposed all 148 loaded rows including the markdown inside them,
* measured as 701 compositions for 148 rows in one scroll.
*
* The promise this makes is real and has to stay true: nothing here is mutated after it is built.
*/
@Immutable
sealed class TranscriptRow {
/**
* This row's identity in the list, which must survive everything that can happen to the row.
*
* The list is keyed by this so that inserting a new message at one end, or a page of history at
* the other, moves the rows and not the reader. When a key changes, the list loses its anchor
* and the transcript steps under whoever is reading it.
*
* A tool row therefore keys on [TranscriptItem.ToolRun.runId] rather than on a sequence number,
* and it is the *same* value whether the run is drawn as one card or as a group. Which value
* that is belongs to the item ([TranscriptItem.key]), not to a `when` here.
*/
abstract val key: Any
/**
* Where this row starts in the transcript: the sequence number of the oldest event behind it.
*
* Separate from [key], and deliberately so. [key] is the list's identity and is a display
* decision; a seq is the server's own numbering, assigned once and meaning the same thing to
* every device. So anything that has to point at a place in the conversation and still find it
* later -- a saved scroll position -- points with this.
*/
abstract val startSeq: Long
data class Single(val item: TranscriptItem) : TranscriptRow() {
override val key: Any
get() = item.key
override val startSeq: Long
get() = item.seq
}
/** Two or more calls with nothing between them; drawn as one collapsed card. */
data class Tools(val calls: List<TranscriptItem.ToolRun>) : 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<TranscriptItem>): List<TranscriptRow> =
DebugStats.timed("grouped tool runs") { groupRuns(items) }
private fun groupRuns(items: List<TranscriptItem>): List<TranscriptRow> {
val rows = mutableListOf<TranscriptRow>()
var run = mutableListOf<TranscriptItem.ToolRun>()
fun flush() {
when (run.size) {
0 -> {}
1 -> rows += TranscriptRow.Single(run.first())
else -> rows += TranscriptRow.Tools(run.toList())
}
run = mutableListOf()
}
items.forEach { item ->
// Grouped by the run each call says it belongs to, not by adjacency worked out here.
// Adjacency is the same answer most of the time and a worse one at the edges: a call
// arriving next to an existing run, or a page of history arriving in front of one, both
// change which call is *first*.
if (item is TranscriptItem.ToolRun && (run.isEmpty() || run.first().runId == item.runId)) {
run += item
} else {
flush()
if (item is TranscriptItem.ToolRun) run += item else rows += TranscriptRow.Single(item)
}
}
flush()
return rows
}
/**
* Several calls under one heading, closed until somebody asks.
*
* What says the calls belong together is the surface behind them, which is the one cue rather than
* two half-cues -- rounded to the same corner every other card in the app has, so a group reads as
* one object rather than as a square patch behind round things. The calls sit on it inset by
* [GROUP_INSET], which is the container's own padding rather than an indent.
*
* Inside, the calls are a connected stack. Facing corners are square and the outer ones are not, so
* the run reads as one thing broken into its parts; see [connectedShape].
*
* It closes from either end. A long group's header scrolls off while its last call is still on
* screen, and the reader who wants it shut is looking at the bottom. The bar at the foot is the
* same height as the heading at the top.
*/
@Composable
fun ToolGroup(
group: TranscriptRow.Tools,
expanded: Boolean,
/**
* Where it was pressed is the row's business rather than the control's -- a group has a control
* at each end, and only the row knows where its own ends are.
*/
onToggle: () -> Unit,
isToolExpanded: (String) -> Boolean,
onToolToggle: (String) -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit,
/** Which capped blocks the reader has asked to see whole; see [Capped]. */
isWhole: (Capped) -> Boolean = { false },
onShowAll: (Capped) -> Unit = {},
) {
val heading = "Called ${group.calls.size} tools"
if (!expanded) {
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(GROUP_INSET_LARGE),
)
}
return
}
Column(
Modifier.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
val barHeight = groupBarHeight()
Row(
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
heading,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(horizontal = GROUP_INSET_LARGE),
)
}
Column(
Modifier.padding(horizontal = GROUP_INSET),
verticalArrangement = Arrangement.spacedBy(GROUP_GAP),
) {
group.calls.forEachIndexed { index, call ->
ToolCard(
tool = call,
expanded = isToolExpanded(call.id),
onToggle = { onToolToggle(call.id) },
onAnswer = onAnswer,
image = image,
isWhole = { part -> isWhole(Capped(call.id, part)) },
onShowAll = { part -> onShowAll(Capped(call.id, part)) },
shape = connectedShape(index, group.calls.size),
)
}
}
// Shutting it from here anchors the other end: the reader is at the bottom of a long group,
// and what they are looking at is what follows it.
CollapseBar(barHeight, onToggle)
}
}
/**
* The height of a group's heading, and so of the bar at its foot.
*
* Derived from the type the heading is set in rather than written down, because the two have to
* match and a pair of numbers chosen to look equal stops being equal the moment the density
* changes.
*/
@Composable
private fun groupBarHeight(): Dp {
val line = MaterialTheme.typography.titleSmall.lineHeight
return with(LocalDensity.current) { line.toDp() } + GROUP_INSET_LARGE * 2
}
/**
* The bottom half of a group's toggle: an arrow back up to its heading. Given the heading's height
* rather than padded to something that looks close, so the surface the calls sit on is the same
* thickness at both ends.
*/
@Composable
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
contentDescription = "Collapse these tool calls"
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Chevron(Pointing.Up, colour = colour)
}
}
/**
* The shape of one card in a stack of [count]: square where it faces a neighbour, rounded where it
* does not.
*
* Written once and given an index rather than branched at each end, because a stack has three cases
* that are one rule -- and the middle one is what a hand-written first/last pair gets wrong.
*/
@Composable
private fun connectedShape(index: Int, count: Int): CornerBasedShape {
val shape = MaterialTheme.shapes.medium
val square = CornerSize(0.dp)
return shape.copy(
topStart = if (index == 0) shape.topStart else square,
topEnd = if (index == 0) shape.topEnd else square,
bottomStart = if (index == count - 1) shape.bottomStart else square,
bottomEnd = if (index == count - 1) shape.bottomEnd else square,
)
}
/** The padding inside a card, and so the height a bar of one line of text comes to. */
private val GROUP_INSET_LARGE = 12.dp
/** How far the stack of calls is held off the edge of the surface it sits on. */
private val GROUP_INSET = 4.dp
/** Enough to read the join as a join rather than as one tall card. */
private val GROUP_GAP = 2.dp
/**
* Which half of an open card a cap and its "Show all" belong to.
*
* The two are capped and revealed independently: opening the whole of a call's input says nothing
* about wanting the whole of its output, and one control revealing both would make the card jump by
* the sum of two things when it was asked about one.
*/
enum class ToolPart {
INPUT,
OUTPUT,
}
/** One capped thing on the screen that the reader may ask to see whole. */
data class Capped(val call: String, val part: ToolPart)
/**
* One tool call.
*
* Closed, it is a single line: the tool's name and what the call is for. The command itself is not
* on it, because a wrapped command turns one row into four and a run of them into a wall.
*
* Open, it shows the command, whatever else the input carried, and the output. The timeout sits at
* the top right: it is a limit on the call rather than part of what the call does.
*
* A call waiting on permission is shown open whatever the reader last chose, since the command is
* the thing being decided and a row saying only "Bash" cannot be decided on.
*/
@Composable
fun ToolCard(
tool: TranscriptItem.ToolRun,
expanded: Boolean,
onToggle: () -> Unit,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
image: @Composable (String) -> Unit = {},
/**
* Whether the reader has asked for the whole of this call's input or output; see [ToolPart].
*/
isWhole: (ToolPart) -> Boolean = { false },
onShowAll: (ToolPart) -> Unit = {},
/** Square where this card faces another in a group; see [connectedShape]. */
shape: Shape = CardDefaults.shape,
) {
val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) }
val deciding = tool.asks.any { it.answers.isEmpty() }
val open = expanded || deciding
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
Column(Modifier.padding(GROUP_INSET_LARGE)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
if (open) {
Spacer(Modifier.weight(1f))
parsed.timeout?.let {
Text(
"timeout $it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
parsed.title?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(start = 8.dp),
)
} ?: Spacer(Modifier.weight(1f))
}
// A spinner says the machine is working. While this call is waiting on an answer
// the machine is doing nothing at all -- the turn is stopped on the person reading
// it -- so it says whose move it is instead.
if (deciding) {
Spacer(Modifier.width(8.dp))
Text(
"your turn",
style = MaterialTheme.typography.labelLarge,
color = awaitingColor,
)
} else if (!tool.done) {
Spacer(Modifier.width(8.dp))
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (open) {
parsed.description?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
// Everything AskUserQuestion carries is the questions, and those are drawn below as
// something answerable; dumping the same JSON above them would be the decision
// stated twice, once unreadably.
if (tool.tool != ASK_USER_QUESTION) {
ToolInputView(
tool.tool,
tool.input,
Modifier.padding(top = 4.dp),
whole = isWhole(ToolPart.INPUT),
onShowAll = { onShowAll(ToolPart.INPUT) },
)
}
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
// Capped like the input, and revealed separately from it: a reader who wants
// the whole of a 900-line `new_string` rarely also wants the whole of the build
// log underneath it.
val wholeOutput = isWhole(ToolPart.OUTPUT)
val cut =
remember(tool.output, wholeOutput) {
if (wholeOutput) null
else
cutText(tool.output, TextCap.VERBATIM_LINES, TextCap.VERBATIM_BYTES)
}
val shown = cut?.shown ?: tool.output
// What the tool printed, on the surface everything verbatim gets and in the
// face it was written for: this is column-aligned far more often than it is
// prose, and a proportional font silently destroys the alignment that carried
// the meaning.
//
// Its terminal styling applied and the rest of the escapes taken out: colour is
// often the whole of what a diff or a test run is saying. Remembered against
// the text, so a card that is open through a scroll parses once.
val palette = remember { ansiPalette() }
val styled = remember(shown, palette) { ansiStyled(shown, palette) }
RawBlock(Modifier.padding(top = 2.dp)) {
// Not wrapped, and panning sideways instead -- Iris, 2026-09-08: "for 'raw'
// text like tool results I think it should not be wrapped". Wrapping a
// column-aligned log is what destroys the alignment that carried its
// meaning, one line at a time and only on the long lines.
Text(
styled,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
softWrap = false,
modifier =
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
)
if (cut != null) {
ShowAllRow(cut.lines) { onShowAll(ToolPart.OUTPUT) }
}
}
}
}
// Shown open or closed. A call that produced a picture is one whose result *is* the
// picture, and a row that hides it says less than the one line it replaced.
tool.images.forEach { ref -> image(ref) }
if (tool.asks.isNotEmpty()) {
if (tool.tool == ASK_USER_QUESTION) {
AskUserQuestionBody(tool.asks, onAnswer)
} else {
tool.asks.forEach { ask -> PermissionAsk(ask, onAnswer) }
}
}
}
}
}
/**
* The permission ask on the call it is about.
*
* Only the question, not the prompt's second half: the backend sends the tool's input with it so
* the ask can stand alone, and here it does not have to -- the card above is showing exactly that.
*/
@Composable
private fun PermissionAsk(
ask: TranscriptItem.QuestionCard,
onAnswer: (List<QuestionAnswer>, onSettled: () -> Unit) -> Unit,
) {
// What was pressed, before the answer has been round-tripped. Two bare words with no submit
// step -- unlike a question card, where the answer is worth reviewing -- so the press has to be
// its own acknowledgement or the row sits unchanged for a round trip. Cleared when the request
// settles: by then either the answer is in `ask.answers`, or it failed and the buttons come
// back.
var pressed by remember(ask.id) { mutableStateOf<String?>(null) }
Spacer(Modifier.height(8.dp))
Text(
ask.prompt.substringBefore('\n'),
style = MaterialTheme.typography.bodyMedium,
color = awaitingColor,
)
// Answered or not, the options stay and the one that was taken is marked -- see
// [AskedQuestion], which is the same rule on the question card. A permission is where it
// matters most: "Answered: Deny" alone does not say that Allow was the alternative.
val settled = ask.answers.isNotEmpty()
AnswerOptions(
ask.options,
if (settled) ask.answers else listOfNotNull(pressed),
onPick =
if (settled || pressed != null) null
else
{ label ->
pressed = label
onAnswer(listOf(QuestionAnswer(ask.id, listOf(label)))) { pressed = null }
},
)
}
/**
* The tool whose input is a question rather than a command; see [AskUserQuestionBody].
*
* Also what [runIdFor] breaks a run of calls on, so the row a reader answered is never folded
* inside a collapsed group.
*/
const val ASK_USER_QUESTION = "AskUserQuestion"
@@ -1,27 +0,0 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see docs/SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -1,596 +0,0 @@
package com.example.aiapp
import android.util.Log
import java.io.BufferedWriter
import java.io.File
import java.io.FileWriter
import java.io.IOException
import java.io.RandomAccessFile
/**
* This phone's copy of the transcripts it has already been sent, so reopening a session does not
* download it again.
*
* What is stored is the server's own JSON for one event per line, in transcript order. Reading the
* cache means running the same [parseSeqEvent] the network path runs, so a cached transcript and a
* fetched one cannot draw differently, and an event type this build does not know keeps every field
* it arrived with for the build that will. Rows are deliberately *not* what is stored: a row is a
* rendering, and a cache of rows would need throwing away on every update that touched `foldEvent`.
*
* See TRANSCRIPT_CACHE.md for the design. Four rules run through all of it:
* 1. what is on screen is what the server's transcript says, in order, with nothing missing -- the
* cache is a copy and is never inferred, folded or edited here;
* 2. a cached line is never ahead of the live cursor, and the cursor never ahead of the cache;
* 3. the cache is never load-bearing -- missing, evicted, damaged or unwritable all degrade to a
* cold open, never to a blank or a wrong screen; 4. a line already on the phone is not fetched
* again.
*
* A plain [File] root and no Compose, `Context` or network, so the whole of the file logic runs
* under the JVM unit tests. That is also why there is no JSON parser here: what it needs off a line
* is the sequence number and whether the line is a streamed delta, both read with a regex. A line
* it cannot read that way is treated as damage. [warn] is where failures are said for the same
* reason.
*/
class TranscriptCache(
private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/**
* The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/**
* Deletes every session directory not in [ids], called after a successful list fetch. The path
* out for a session deleted on another device: nothing here would otherwise hear about it, and
* unlike a draft's few bytes what it leaves behind is megabytes.
*/
fun retainOnly(ids: Set<String>) =
guardIo(Unit, warn) {
sessionDirs().forEach { if (it.name !in ids) it.deleteRecursively() }
}
/**
* Deletes least-recently-touched session directories, never [keep], until the whole of this
* server's cache is under [budget]. Least-recently-touched rather than largest: what a reader
* is likely to open again is what they opened last, and evicting the big ones first would empty
* the cache for exactly the conversations it exists for.
*/
fun evictToBudget(keep: String, budget: Long = CACHE_BUDGET_BYTES) =
guardIo(Unit, warn) {
val dirs = sessionDirs().sortedBy { it.lastModified() }
var total = dirs.sumOf { sizeOf(it) }
for (dir in dirs) {
if (total <= budget) break
if (dir.name == keep) continue
val was = sizeOf(dir)
if (dir.deleteRecursively()) total -= was
}
}
fun purgeAll() = guardIo(Unit, warn) { root.deleteRecursively() }
private fun sessionDirs(): List<File> = root.listFiles()?.filter { it.isDirectory }.orEmpty()
}
/**
* How much of this phone's cache directory all of one server's transcripts may take. A dozen of the
* largest transcripts seen in the dev VM (21 MB for 24,000 events) and a small fraction of a phone.
* A number to revisit against real use rather than a measurement of anything.
*/
const val CACHE_BUDGET_BYTES: Long = 256L * 1000 * 1000
/**
* What the newest cached line says, which is what the probe checks against the server. Both halves
* are wanted together: the seq is what the request asks about, and the line is what its answer is
* compared with.
*/
data class CachedTail(val seq: Long, val line: String)
/**
* One session's cached lines, as a directory of chunks.
*
* A chunk is a set of lines *and a claim about what they cover*, and the two are not the same
* thing: a coalesced page joins each run of streamed deltas into one event carrying the seq of the
* run's oldest delta, so a page whose newest event is seq 1,200 may cover everything up to the
* 1,650 it was fetched with, and nothing in the lines says so. So coverage is the half-open range
* in the file's name:
* ```
* <first>-<end>.rows.jsonl a coalesced page; end is the `before` it was fetched with
* <first>-<end>.raw.jsonl an uncoalesced page, or a closed live run <first>-open.raw.jsonl the
* live run; end is its last line's seq + 1
* ```
*
* Two chunks are adjacent when one's `end` is the other's `first`. Only the contiguous run ending
* at the newest chunk -- the **suffix** -- is ever served: chunks behind a gap are kept, because
* the gap is usually closed by paging back through it, but nothing is served across one.
*
* **The newest chunk is always raw**, which is what makes the stream cursor and the probe well
* defined. It holds by construction (the opening window and every stream frame are raw) and is
* checked on read: a `.rows` chunk at the newest end can only mean this app died between closing
* one live run and opening the next, and it discards the session.
*
* Nothing here is load-bearing. Every operation that touches the disk answers as though the cache
* were empty when it cannot, and a write failure disables writing for the rest of this instance's
* life so that a full disk costs one log line rather than one per delta.
*
* Every operation is synchronized, because two of them really do run at once: the stream appends
* live events from its own IO thread while a reader scrolling back reads pages from another. What
* it buys is that the open chunk's name, its end and its writer are never read half-rotated.
*/
class SessionCache(
private val dir: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) {
/** Set by the first write that fails: a second would fail the same way, once per delta. */
private var disabled = false
/**
* The open chunk's writer, its file, and the seq that chunk now ends at.
*
* Buffered, and flushed on [flush], because a delta is a hundred bytes and arrives dozens of
* times a second while a reply streams. What that costs is the unflushed tail on a crash, which
* is safe: a shorter cache is a longer catch-up, never a wrong one.
*/
private var writer: BufferedWriter? = null
private var openFile: File? = null
private var openEnd: Long = 0
/**
* The newest line of the suffix, or null when there is none or the newest chunk is not raw.
*
* This is the cursor the live stream would resume from, so it is also what has to be shown to
* still be the server's own line before anything is resumed from it.
*/
@Synchronized
fun tail(): CachedTail? =
guard(null) {
val newest = suffix().lastOrNull() ?: return@guard null
var found: CachedTail? = null
eachLine(newest) { line ->
found = CachedTail(seqOf(line)!!, line)
false
}
found
}
/** The newest [limit] lines of the suffix, oldest first -- the opening window. */
@Synchronized
fun newest(limit: Int): List<String> =
guard(emptyList()) {
val taken = ArrayDeque<String>()
for (chunk in suffix().asReversed()) {
if (taken.size >= limit) break
eachLine(chunk) { line ->
taken.addFirst(line)
taken.size < limit
}
}
taken.toList()
}
/**
* The page of lines before [before], oldest first, or null when the cache cannot answer.
*
* Null is a miss -- the suffix does not cover the ground immediately below [before] -- and
* means the server has to be asked. Deliberately not an empty list: an empty page is how the
* screen is told it has reached the start of the conversation, and a cache saying that of
* history it merely does not hold would stop the transcript scrolling back for good.
*
* [before] is anywhere inside the suffix, not only at a chunk boundary. The cursor a warm open
* leaves behind is in the middle of the live run, so a cache that could only answer at a
* boundary would send the very first backwards page to the server and, since that page would
* overlap the run, keep none of it.
*
* With [rows] the count is rows rather than lines, mirroring the server's `parse_coalesced`.
* The deltas are not joined here -- `foldEvent` does that, and the joined row keeps the seq of
* its first delta either way.
*/
@Synchronized
fun page(before: Long, limit: Int, rows: Boolean): List<String>? =
guard(null) {
val suffix = suffix()
val newest = suffix.lastOrNull() ?: return@guard null
// Above what is held, or at or below where it starts: either way the run the caller is
// scrolling into is not continuous with this one, and only the server has it.
if (before > newest.end || before <= suffix.first().first) return@guard null
val taken = ArrayDeque<String>()
var counted = 0
var inRun = false
var wanting = true
for (chunk in suffix.asReversed()) {
if (!wanting) break
if (chunk.first >= before) continue
eachLine(chunk) { line ->
// The page is what is *before* the cursor; the rows at or above it are already
// on screen.
if (seqOf(line)!! >= before) return@eachLine true
if (rows) {
val delta = isDelta(line)
// Stop only between rows: a delta continuing the run being gathered is part
// of a row already counted, and breaking on it would drop the half of that
// row already taken.
if (counted >= limit && !(delta && inRun)) wanting = false
else {
if (!delta || !inRun) counted++
inRun = delta
}
} else if (taken.size >= limit) {
wanting = false
}
if (wanting) taken.addFirst(line)
wanting
}
}
taken.toList()
}
/**
* The `end` of the nearest chunk at or below [before], which is the floor a fetched page is
* asked with so that it stops where this phone's copy starts. Null when there is no such chunk.
*
* Any chunk, not only the suffix's: the whole point is to reach the run behind a gap, so that
* the gap is closed with exactly the bytes it is wide.
*/
@Synchronized
fun coveredUpTo(before: Long): Long? =
guard(null) { chunks().map { it.end }.filter { it <= before }.maxOrNull() }
/**
* Stores a fetched page covering `[first, end)`; false when it was not stored.
*
* Refused when it overlaps a chunk already here, because there is no clean cut: a coalesced
* event cannot be split at a seq inside its own delta run. `TranscriptSource` keeps that from
* arising by bounding what it fetches, and this is the guard for a page that arrives anyway.
* Such a page is still drawn; it is only not kept.
*
* The newest chunk is never stored through here: the opening window and every live frame go
* through [append], which is what keeps the newest chunk raw and open.
*/
@Synchronized
fun storePage(lines: List<String>, first: Long, end: Long, rows: Boolean): Boolean =
guard(false) {
if (disabled || lines.isEmpty() || end <= first) return@guard false
if (chunks().any { first < it.end && it.first < end }) return@guard false
dir.mkdirs()
val kind = if (rows) "rows" else "raw"
File(dir, "$first-$end.$kind.jsonl").writeText(lines.joinToString("\n", postfix = "\n"))
true
}
/**
* Appends one live event, which is also how a freshly fetched opening window is stored.
*
* A seq equal to the open chunk's end extends it. A larger one is a gap -- which is what a
* `reset` looks like from here -- and closes the open chunk under the end it turned out to
* have. A smaller one is already covered and is ignored; the SSE contract is `seq > after`.
*/
@Synchronized
fun append(line: String, seq: Long) =
guard(Unit) {
if (disabled) return@guard
val writer = writerFor(seq) ?: return@guard
// Written as it arrived. A newline inside it would split one event into two unreadable
// halves, but neither source can produce one: SSE framing forbids it, and a page's
// elements are re-serialized compactly, which escapes it.
writer.write(line)
writer.write("\n")
openEnd = seq + 1
}
/**
* Flushes what [append] has buffered. Called on each `Status` event -- the boundaries of a
* turn, which is the granularity a crash may as well lose -- and when the stream closes.
*/
@Synchronized fun flush() = guard(Unit) { writer?.flush() }
/** What [purge] would discard, for the reload row in session settings. */
@Synchronized fun bytes(): Long = guard(0L) { sizeOf(dir) }
/** Marks this session as visited, which is what eviction ranks by. */
@Synchronized
fun touch() =
guard(Unit) { if (dir.isDirectory) dir.setLastModified(System.currentTimeMillis()) }
@Synchronized
fun purge() =
guard(Unit) {
closeWriter()
dir.deleteRecursively()
}
// -- chunks ------------------------------------------------------------------------------
private data class Chunk(val file: File, val first: Long, val end: Long, val open: Boolean) {
val rows: Boolean
get() = file.name.endsWith(".rows.jsonl")
}
/**
* Every chunk on disk, oldest first. A name this does not recognise is not ours and is ignored.
* Recomputed per operation rather than kept: another operation may have changed the directory.
*/
private fun chunks(): List<Chunk> {
writer?.flush()
return dir.listFiles()
.orEmpty()
.mapNotNull { file ->
val match = CHUNK_NAME.matchEntire(file.name) ?: return@mapNotNull null
val first = match.groupValues[1].toLongOrNull() ?: return@mapNotNull null
val open = match.groupValues[2] == "open"
val end = if (open) openEndOf(file, first) else match.groupValues[2].toLongOrNull()
// A chunk covering nothing is one that was created and never written to -- an
// append whose very first write failed. It says nothing, so it is not a chunk.
if (end == null || end <= first) null else Chunk(file, first, end, open)
}
.sortedBy { it.first }
}
/**
* The open chunk's end: its last line's seq plus one, or the in-memory end while this instance
* is the one writing it.
*
* An open chunk whose last line cannot be read is this app having died mid-write. That line is
* dropped and the file truncated to the last good one, which is the one place damage is
* repaired rather than discarded: the tail of an append-only file is the only place a partial
* line can be.
*/
private fun openEndOf(file: File, first: Long): Long {
if (openFile == file && openEnd > 0) return openEnd
repairTail(file)
var end = first
eachLineBackwards(file) { _, line ->
seqOf(line)?.let { end = it + 1 }
false
}
return end
}
/**
* The contiguous run of adjacent chunks ending at the newest one, oldest first.
*
* A newest chunk that is not raw cannot happen while this code is the only writer, and means
* the directory is not to be trusted -- so the session is discarded.
*/
private fun suffix(): List<Chunk> {
val all = chunks()
var index = all.size - 1
val newest = all.lastOrNull() ?: return emptyList()
if (newest.rows) throw Damaged(newest.file)
val run = ArrayDeque<Chunk>()
run.addFirst(newest)
while (index > 0 && all[index - 1].end == run.first().first) {
index--
run.addFirst(all[index])
}
return run.toList()
}
/**
* Each line of [chunk], newest first, until [take] says stop.
*
* Backwards and lazily, because every question this cache is asked is about the newest end and
* a live run grows to the size of the conversation. Reading the file whole to answer with
* eighty lines of it is the cost the server's own reader was rewritten to stop paying.
*
* Damage anywhere but at the tail of the open chunk was not written by this code, and there is
* no honest way to say what a chunk covers with a line of it unreadable -- so it discards the
* session rather than serving what it can read.
*/
private fun eachLine(chunk: Chunk, take: (String) -> Boolean) {
eachLineBackwards(chunk.file) { _, line ->
if (seqOf(line) == null) throw Damaged(chunk.file)
take(line)
}
}
// -- writing -----------------------------------------------------------------------------
/** The writer for the chunk [seq] belongs in, opening or rotating one as it has to. */
private fun writerFor(seq: Long): BufferedWriter? {
writer?.let { held ->
if (seq == openEnd) return held
if (seq < openEnd) return null
// A gap: what this instance has written covers up to `openEnd`, and that is the name
// the chunk gets before a new one starts at the arriving seq.
closeOpenChunk(openEnd)
}
dir.mkdirs()
// An open chunk left by an earlier instance, or by an earlier screen.
chunks()
.lastOrNull { it.open }
?.let { existing ->
if (seq < existing.end) return null
if (seq == existing.end) {
openFile = existing.file
openEnd = existing.end
return FileWriter(existing.file, true).buffered().also { writer = it }
}
rename(existing.file, existing.first, existing.end)
}
// A chunk that was created and never written to would otherwise be left behind under a name
// a second one is about to want; it covers nothing, so nothing is lost with it.
dir.listFiles().orEmpty().forEach {
if (CHUNK_NAME.matchEntire(it.name)?.groupValues?.get(2) == "open" && it.length() == 0L)
it.delete()
}
val file = File(dir, "$seq-open.raw.jsonl")
openFile = file
openEnd = seq
return FileWriter(file, false).buffered().also { writer = it }
}
/** Renames the open chunk to the range it turned out to cover, so it stops being open. */
private fun closeOpenChunk(end: Long) {
val file = openFile
closeWriter()
if (file == null) return
val first = CHUNK_NAME.matchEntire(file.name)?.groupValues?.get(1)?.toLongOrNull()
if (first != null) rename(file, first, end)
}
private fun rename(file: File, first: Long, end: Long) {
file.renameTo(File(dir, "$first-$end.raw.jsonl"))
}
private fun closeWriter() {
try {
writer?.close()
} catch (_: IOException) {
// Nothing left to do about it: the file is what it is, and the read path repairs a
// half-written tail.
}
writer = null
openFile = null
openEnd = 0
}
// -- failure -----------------------------------------------------------------------------
/** A chunk that cannot be read as what its name claims. */
private class Damaged(val file: File) : RuntimeException()
/**
* Runs [body], answering [ifBroken] when the directory cannot give a real answer.
*
* None of this is reported on screen: none of it changes what the screen shows -- every read
* here has a network path beside it producing the same result -- and the reader has nothing to
* do about it. Damage discards this session's cache, which makes the next open an ordinary cold
* one.
*/
private fun <T> guard(ifBroken: T, body: () -> T): T =
// A disk that refused once will refuse again, once per delta, so the first refusal is also
// the last: this instance stops writing rather than logging a line a token.
guardIo(
ifBroken,
warn,
onFailure = {
disabled = true
closeWriter()
},
) {
try {
body()
} catch (e: Damaged) {
warn("transcript cache damaged at ${e.file}; discarding ${dir.name}")
closeWriter()
dir.deleteRecursively()
ifBroken
}
}
}
/** `<first>-<end|open>.<rows|raw>.jsonl`; anything else in the directory is not ours. */
private val CHUNK_NAME = Regex("""^(\d+)-(\d+|open)\.(rows|raw)\.jsonl$""")
private val SEQ_IN_LINE = Regex(""""seq"\s*:\s*(\d+)""")
private val TYPE_IN_LINE = Regex(""""type"\s*:\s*"([^"]*)"""")
/**
* One line's sequence number, or null when the line is not one of ours.
*
* A regex rather than a JSON parse, so that this file carries no parser and runs under the JVM
* tests: the seq is the first field the server writes, so the first match is the top-level one.
*/
private fun seqOf(line: String): Long? = SEQ_IN_LINE.find(line)?.groupValues?.get(1)?.toLongOrNull()
/** Whether a line is one streamed piece of a reply, which is what makes a run of them one row. */
private fun isDelta(line: String): Boolean =
TYPE_IN_LINE.find(line)?.groupValues?.get(1) == "assistantText"
/**
* How much of a file is read at a time when walking it backwards. One block covers a page of a
* transcript comfortably, and the walk stops as soon as the caller has what it asked for.
*/
private const val READ_BLOCK = 64 * 1024
/**
* Calls [onLine] with each non-blank line of [file], **newest first**, along with the byte offset
* it starts at, until [onLine] answers false.
*
* Every question the cache is asked is about the newest end of a chunk, and a live run reaches the
* size of the conversation, so reading forwards means reading a transcript to answer with the last
* eighty lines of it.
*
* Splitting on bytes is safe because the separator is `\n`, which cannot occur inside a multi-byte
* UTF-8 sequence; each line is decoded whole. A missing file yields nothing.
*/
private fun eachLineBackwards(file: File, onLine: (offset: Long, line: String) -> Boolean) {
if (!file.isFile) return
RandomAccessFile(file, "r").use { handle ->
// Bytes below `unread` have not been looked at; `pending` is the oldest line so far, which
// is incomplete until a newline is found before it in an older block.
var unread = handle.length()
var pending = ByteArray(0)
while (unread > 0) {
val take = minOf(READ_BLOCK.toLong(), unread).toInt()
val start = unread - take
val block = ByteArray(take)
handle.seek(start)
handle.readFully(block)
val buffer = if (pending.isEmpty()) block else block + pending
var lineEnd = buffer.size
var at = buffer.size - 1
while (at >= 0) {
if (buffer[at] == NEWLINE) {
val line = String(buffer, at + 1, lineEnd - at - 1, Charsets.UTF_8)
if (line.isNotBlank() && !onLine(start + at + 1, line)) return
lineEnd = at
}
at--
}
pending = buffer.copyOfRange(0, lineEnd)
unread = start
}
// The first line of a file has no newline before it to be found.
val first = String(pending, Charsets.UTF_8)
if (first.isNotBlank()) onLine(0, first)
}
}
private const val NEWLINE = '\n'.code.toByte()
/**
* Drops a final line that is not one of ours, by truncating the file to where it starts.
*
* This app having died mid-write is the one kind of damage that is repaired rather than discarded:
* the tail of an append-only file is the only place a partial line can be. A second bad line is not
* this, and is left for the read path to notice.
*/
private fun repairTail(file: File) {
var truncateTo = -1L
eachLineBackwards(file) { offset, line ->
if (seqOf(line) == null) truncateTo = offset
false
}
if (truncateTo >= 0) RandomAccessFile(file, "rw").use { it.setLength(truncateTo) }
}
private fun sizeOf(file: File): Long =
if (file.isDirectory) file.listFiles().orEmpty().sumOf { sizeOf(it) } else file.length()
/**
* The disk half of [SessionCache.guard], shared with [TranscriptCache]'s own maintenance.
* [onFailure] is what the caller does about it beyond answering [ifBroken].
*/
private fun <T> guardIo(
ifBroken: T,
warn: (String) -> Unit,
onFailure: () -> Unit = {},
body: () -> T,
): T =
try {
body()
} catch (e: IOException) {
warn("transcript cache unusable: ${e.message}")
onFailure()
ifBroken
} catch (e: SecurityException) {
warn("transcript cache unreadable: ${e.message}")
onFailure()
ifBroken
}
@@ -1,547 +0,0 @@
package com.example.aiapp
import androidx.compose.runtime.Immutable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]).
*
* Events are the only data source, and there is deliberately no second shape for history to drift
* from: a page fetched backwards, a live frame, and a line read out of this phone's own cache are
* all the same events through the same parser. [TranscriptCache] stores the server's lines rather
* than these rows for exactly that reason -- a row is a rendering, and its shape changes whenever
* this file does.
*/
@Immutable
sealed class TranscriptItem {
/**
* The transcript sequence number this row started at, and its identity on screen.
*
* The list is drawn newest-first, so every new message is an insertion at index 0 and every
* page of history is an insertion at the far end. Without an identity that survives both, the
* list is addressed by position: whatever somebody had scrolled to keeps its index while the
* content underneath it slides, which reads as the view scrolling on its own.
*
* A row built from several events keeps the seq of the first, so it holds still while the rest
* of it arrives.
*/
abstract val seq: Long
/**
* This item's identity on screen, which is its [seq] for everything that has one of its own.
*
* Here rather than in [TranscriptRow.Single] because the two items that need something else are
* the two that know why. Asking each item what it is called is also what stops the next such
* item being missed -- a `when` over concrete types would have to gain a case, silently.
*/
open val key: Any
get() = seq
data class UserMsg(
override val seq: Long,
val text: String,
/** Refs of what was attached, drawn inside the bubble. */
val attachments: List<String> = emptyList(),
) : TranscriptItem()
data class AssistantMsg(
override val seq: Long,
val text: String,
/**
* Whether this reply is finished: the session has stopped working since its last delta.
*
* What it buys is the split. [transcriptUnits] keeps the newest reply whole because a
* streaming reply's text changes per delta and splitting a changing text is a parse per
* delta -- but "newest" outlives the turn, so a session that ends on a long reply was
* drawing it as one item indefinitely. Measured on a Pixel 9 Pro XL: one 34,996px reply on
* screen put the frame's draw phase at 13.8ms, 79% of it framework bookkeeping.
*
* Folded from the status event that ended the turn rather than read off the screen's
* status, because rows only change through the held-events gate: the split changes the
* newest row's list identity, and doing that from a status flip while somebody is reading
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
) : TranscriptItem()
data class ToolRun(
override val seq: Long,
val id: String,
/**
* The run of adjacent calls this one belongs to, named once when the call is folded in and
* never recomputed.
*
* Carried rather than derived because a run can gain members at *either* end, so no
* function of its current members is stable. It is the first call's id at the moment the
* run started, which is a name rather than a description: [joinPages] hands it to older
* calls that turn out to belong to the same run.
*/
val runId: String,
val tool: String,
val input: String,
val output: String,
val done: Boolean,
/**
* The questions this call is waiting on, in the order they were asked.
*
* On the call's own row rather than beside it: an ask used to arrive as a second card
* repeating the input verbatim, so the reader saw the same command twice. The backend says
* which call a question is about, so this is a fact rather than a match on the input.
*
* A list because AskUserQuestion asks up to four at once, and a permission is the case of
* exactly one rather than a different shape.
*/
val asks: List<QuestionCard> = emptyList(),
/**
* Images this call's result carried, drawn under it. Beside it they had to be paired by
* position, and position is what a page boundary breaks.
*/
val images: List<String> = emptyList(),
) : TranscriptItem() {
/** A run, not a seq: see [TranscriptRow.key] for what that identity has to survive. */
override val key: Any
get() = runId
}
data class QuestionCard(
override val seq: Long,
val id: String,
val prompt: String,
/** A few words naming what this is about, when the asker offered one. */
val header: String?,
val options: List<QuestionOption>,
/** Whether several options may be chosen at once. */
val multiSelect: Boolean,
/** What was chosen, once something was; empty until then. */
val answers: List<String>,
) : TranscriptItem()
data class ErrorMsg(override val seq: Long, val message: String) : TranscriptItem()
/** An image by server-side ref, fetched from the session's files route. */
data class ImageItem(override val seq: Long, val ref: String) : TranscriptItem()
/**
* A message another agent sent this session. Its own row rather than a [UserMsg]: see
* [PeerMessageRow] for why the voice matters.
*/
data class PeerNote(
override val seq: Long,
val from: String,
val text: String,
/**
* The seq of the event this note came in on, which is what makes it itself.
*
* [seq] is where the note *sorts*, and [placePeerNote] sets it to the seq the turn began
* at. Two messages that arrive during one turn therefore share a seq -- and sharing an
* identity as well killed the app, because the list refuses two items with one key.
*/
val arrived: Long = seq,
) : TranscriptItem() {
override val key: Any
get() = arrived
}
/**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that
* suddenly has half the context, or a session with a new name.
*/
data class CommandRow(override val seq: Long, val text: String) : TranscriptItem()
/** Placeholder row for events this build can't render (newer kinds). */
data class Note(override val seq: Long, val text: String) : TranscriptItem()
/**
* A clear that happened: everything above it left the session's context and stayed on screen.
* Carries only its position, because that is all it means.
*/
data class ClearedNote(override val seq: Long) : TranscriptItem()
/**
* A compaction that happened, and what it recovered.
*
* In the transcript rather than only in the status line, because the status is gone the moment
* it finishes and this is the part worth keeping: the explanation for a gap in the
* conversation.
*
* The wire also says what triggered it, and this deliberately does not carry that -- the row
* says the two sizes and nothing else, so keeping the trigger would be a field nothing can
* read.
*/
data class CompactedNote(
override val seq: Long,
val preTokens: Long?,
val postTokens: Long?,
) : TranscriptItem()
/**
* The account ran out of quota, so the turn stopped here.
*
* A divider rather than an error: nothing failed, and what a reader scrolling back needs from
* it is the same thing a clear or a compaction gives them -- why the conversation stops at this
* line.
*
* [resetsAt] is epoch seconds and null where the session was told nothing, which is a state the
* row has words for rather than a time it invents.
*/
data class LimitNote(override val seq: Long, val resetsAt: Double?) : TranscriptItem()
}
/**
* The run a call joins: the one it lands next to, or a new one named after itself.
*
* Only ever consulted when the call is first folded in. That is what makes the name stable -- a run
* keeps whatever it was called when it started, however many calls arrive at either end afterwards.
*
* A question to the reader is in a run of its own, which is what puts it on the transcript as a row
* rather than inside a collapsed "Called 6 tools" card. Two things follow: it is always visible,
* since a run of one is drawn as itself; and the calls around it fall into a group before it and a
* group after it, so where the reader was asked something is legible in the shape of the transcript
* without opening anything.
*/
private fun runIdFor(items: List<TranscriptItem>, id: String, tool: String): String {
val previous = items.lastOrNull() as? TranscriptItem.ToolRun ?: return id
if (tool == ASK_USER_QUESTION || previous.tool == ASK_USER_QUESTION) return id
return previous.runId
}
/**
* Puts a page of older items in front of the ones already loaded, healing whatever the page
* boundary cut in two.
*
* Two things straddle a boundary: a tool call separated from its result, and a message separated
* from the rest of itself. Both were one thing before the transcript was cut into pages.
*
* A boundary lands wherever it lands, and roughly half the time that is between a call and its
* result. The newer page then holds a `ToolEnd` whose start it never saw, which [foldEvent] draws
* as a row of its own -- correctly, because a call that renders as nothing is indistinguishable
* from one that never happened. When the older page arrives it brings the real `ToolStart`, and
* concatenating the two lists left *both*: the same call twice.
*
* Merged by the call's own id rather than by position, because position is exactly what a page
* boundary destroys. The older row wins on what a start knows and the newer on what an end knows,
* which is the only way round that loses nothing.
*
* The third thing is the *run*, and it is the one that used to be missed. Every page ends up here,
* but [adoptRun] only ran on the path where a split call had been found -- so the boundary that
* falls cleanly between two finished calls, which is most of them, left the older page's calls
* under the run name they were folded with. On screen: one run of tool calls drawn as two groups,
* with the seam wherever the reader happened to have paged.
*/
fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<TranscriptItem> {
val (older, newer) = healSplitMessage(earlier, later)
val startedEarlier =
older.filterIsInstance<TranscriptItem.ToolRun>().mapTo(mutableSetOf()) { it.id }
val endedLater =
newer
.filterIsInstance<TranscriptItem.ToolRun>()
.associateBy { it.id }
.filterKeys { it in startedEarlier }
val healed = older.map { row ->
val half = (row as? TranscriptItem.ToolRun)?.let { endedLater[it.id] }
if (row is TranscriptItem.ToolRun && half != null) {
row.copy(
output = half.output,
done = half.done,
// Kept from both halves: a question or an image can be attached to either,
// depending on which side of the boundary its event fell.
asks = row.asks + half.asks,
images = row.images + half.images,
)
} else {
row
}
}
val kept = newer.filterNot { it is TranscriptItem.ToolRun && it.id in endedLater }
return adoptRun(healed, kept) + kept
}
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* answer as two with a paragraph break through the middle of a sentence.
*
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
* loaded, so the growth extends off the top of the screen.
*/
private fun healSplitMessage(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): Pair<List<TranscriptItem>, List<TranscriptItem>> {
val head = earlier.lastOrNull()
val tail = later.firstOrNull()
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
/**
* Hands the older calls at the join the name of the run they are joining.
*
* The two pages were folded separately, so a run split by the boundary came back as two runs with
* two names. Naming the joined run after the *older* half would be the obvious way round and is
* wrong: the newer half is the part already on screen, and renaming it is renaming the row the
* reader is looking at, which is how a list loses its anchor.
*/
private fun adoptRun(
earlier: List<TranscriptItem>,
later: List<TranscriptItem>,
): List<TranscriptItem> {
val first = later.firstOrNull() as? TranscriptItem.ToolRun ?: return earlier
// A question is in a run of its own on both sides of the join, the same as it would be had the
// two pages been folded as one. Without this the heal would merge a group straight through the
// row the reader was asked something on.
if (first.tool == ASK_USER_QUESTION) return earlier
val joining = first.runId
val tail = earlier.takeLastWhile {
it is TranscriptItem.ToolRun && it.tool != ASK_USER_QUESTION
}
if (tail.isEmpty()) return earlier
return earlier.dropLast(tail.size) +
tail.map { (it as TranscriptItem.ToolRun).copy(runId = joining) }
}
/**
* A peer message goes above the turn it started, not where it happened to arrive.
*
* The live Claude Code path cannot record it in place: the CLI says nothing about a peer message
* until the turn's `result`, so the event lands below the whole reply it caused. The server stamps
* it with where that turn began and the note takes that seq.
*
* Taking the turn's opening seq as its own is also what keeps the list sorted, which anchors and
* paging both depend on. It is only a *position*, though, and the note keeps its own arrival seq as
* its identity ([TranscriptItem.PeerNote.arrived]). The argument for sharing was that the turn's
* seq belongs to a status change and a status draws no row -- true, and it answered the wrong
* question: what two notes stamped with the same turn collide with is each other.
*
* Without a stamp -- a message replayed out of a session file -- it stays where it arrived.
*/
private fun placePeerNote(
items: List<TranscriptItem>,
seq: Long,
event: SessionEvent.PeerMessage,
): List<TranscriptItem> {
val at = event.turnStart ?: return items + TranscriptItem.PeerNote(seq, event.from, event.text)
val note = TranscriptItem.PeerNote(at, event.from, event.text, arrived = seq)
val index = items.indexOfFirst { it.seq > at }
if (index < 0) return items + note
val behind = (items.getOrNull(index - 1) as? TranscriptItem.ToolRun)?.runId
return items.subList(0, index) + note + splitRun(items.subList(index, items.size), behind)
}
/**
* The calls the note now sits in front of, renamed if they were sharing a run with the calls behind
* it.
*
* A run is named from what a call landed next to, and nothing there knows about turns -- so a turn
* opening with a tool call, straight after one that ended with one, folds them into a single run.
* Left alone, [groupToolRuns] would flush at the note and hand both halves the same name: two rows
* with one key, which a keyed list cannot draw at all.
*
* The later half is the one renamed, which is the opposite of a page join ([adoptRun]) and right
* for the opposite reason: there the two halves were always one run, here they were never one
* turn's work.
*/
private fun splitRun(tail: List<TranscriptItem>, behind: String?): List<TranscriptItem> {
val first = tail.firstOrNull() as? TranscriptItem.ToolRun ?: return tail
if (behind == null || first.runId != behind) return tail
val run = tail.takeWhile { it is TranscriptItem.ToolRun && it.runId == behind }
return run.map { (it as TranscriptItem.ToolRun).copy(runId = first.id) } + tail.drop(run.size)
}
fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem> =
when (val event = entry.event) {
is SessionEvent.UserMessage ->
items + TranscriptItem.UserMsg(entry.seq, event.text, event.attachments)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming, which keeps the seq of the
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
// A message growing again is not finished, whatever a status said in between.
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
} else {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.ToolStart ->
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
runIdFor(items, event.id, event.tool),
event.tool,
event.input,
"",
done = false,
)
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
is SessionEvent.ToolEnd ->
// Created when its start is not here, rather than dropped. A fold that only ever
// *updates* loses the whole call when the start fell outside the loaded window, and a
// tool call that renders as nothing is indistinguishable from one that never happened.
// Loading the page before this one replaces the row with the real thing.
if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) {
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
} else {
items +
TranscriptItem.ToolRun(
entry.seq,
event.id,
// The name is not known from an end alone, so a call that was an ask cannot
// be recognised as one here; the page before this replaces the row.
runIdFor(items, event.id, "tool"),
"tool",
"",
event.output,
done = true,
)
}
is SessionEvent.Question -> {
val card =
TranscriptItem.QuestionCard(
entry.seq,
event.id,
event.prompt,
event.header,
event.options,
event.multiSelect,
emptyList(),
)
// A question with no tool behind it -- AskUserQuestion, or an ask whose call fell
// outside the loaded window -- is a card of its own.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(asks = it.asks + card) }
} else {
items + card
}
}
is SessionEvent.Answered ->
// Resolved wherever it is drawn: a card of its own, or a tool row's ask. Missing the
// second left an Allow/Deny pair live on a question already answered from another
// device.
items.map {
when {
it is TranscriptItem.QuestionCard && it.id == event.id ->
it.copy(answers = event.answers)
it is TranscriptItem.ToolRun && it.asks.any { ask -> ask.id == event.id } ->
it.copy(
asks =
it.asks.map { ask ->
if (ask.id == event.id) ask.copy(answers = event.answers)
else ask
}
)
else -> it
}
}
is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
// No row of its own: a message that is still waiting is drawn as a pending bubble below the
// transcript, and becomes an ordinary one where the session read it.
is SessionEvent.MessageQueued -> items
// The bubble goes away and nothing takes its place: the message was never read, so there is
// nothing it belongs above.
is SessionEvent.MessageDropped -> items
is SessionEvent.Settings -> items
is SessionEvent.Status -> settleReply(items, event.state)
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(entry.seq, event.message)
is SessionEvent.Image ->
// Under the call that produced it when there is one, and a row of its own when there is
// not -- a person's own attachment belongs to no call, and neither does one whose call
// fell outside the loaded window.
if (
event.about != null &&
items.any { it is TranscriptItem.ToolRun && it.id == event.about }
) {
updateTool(items, event.about) { it.copy(images = it.images + event.ref) }
} else {
items + TranscriptItem.ImageItem(entry.seq, event.ref)
}
is SessionEvent.LimitReached -> items + TranscriptItem.LimitNote(entry.seq, event.resetsAt)
is SessionEvent.Cleared -> items + TranscriptItem.ClearedNote(entry.seq)
is SessionEvent.Compacted ->
items + TranscriptItem.CompactedNote(entry.seq, event.preTokens, event.postTokens)
is SessionEvent.Unknown -> items + TranscriptItem.Note(entry.seq, "[${event.type}]")
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.UsageDelta -> items
}
/**
* A status saying the session stopped working is the moment its newest reply is finished.
*
* See [TranscriptItem.AssistantMsg.settled]. Status changes are transcript events with seqs of
* their own, so a replayed session settles its replies the same way a live one does.
*/
private fun settleReply(items: List<TranscriptItem>, state: String): List<TranscriptItem> {
if (sessionWorking(state)) return items
val last = items.lastOrNull() as? TranscriptItem.AssistantMsg ?: return items
if (last.settled) return items
return items.dropLast(1) + last.copy(settled = true)
}
private fun updateTool(
items: List<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
/**
* Where markdown is parsed ahead of being drawn: two threads, never all of them.
*
* The default dispatcher sizes itself to the machine, which is right for work somebody is waiting
* on and wrong for work nobody is. A page of history is hundreds of parses arriving at once, and
* taking every core for them leaves the thread that draws the frame queueing behind one -- measured
* on a Pixel 9 Pro XL as 21ms of `waited` at the 90th percentile.
*/
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
private val parsingThreads = Dispatchers.Default.limitedParallelism(2)
/**
* Parses the markdown among [rows], off whatever thread is drawing.
*
* Called where a page of transcript is folded rather than where a row is composed, which is the
* whole point: the work happens seconds before the reader reaches the rows it was done for.
*
* What is warmed mirrors what the rows draw -- each prose part of a reply, a memory note, a peer
* message -- because a string warmed under a key no row ever looks up is a miss that nothing
* reports; see [transcriptUnits], which is the flatten this has to agree with. It reads the same
* [ParsedReplies.partsOf] cache the flatten does, so a message is scanned once however many pages
* hand it back through here.
*
* Every kind of row that draws markdown belongs in the `when` below. That is the rule the peer
* message was missing: this used to filter for assistant replies alone, so the one row type nobody
* had thought about paid its whole parse in the frame it appeared in.
*/
suspend fun warm(replies: ParsedReplies, rows: List<TranscriptItem>) {
withContext(parsingThreads) {
val texts = rows.flatMap { row ->
when (row) {
is TranscriptItem.AssistantMsg -> replies.partsOf(row.text).map { it.text }
// A message from another agent is markdown too, and it is the longest thing in a
// transcript often enough that leaving it out was the whole of why one cost a fifth
// of a second to open.
is TranscriptItem.PeerNote -> listOf(row.text)
else -> emptyList()
}
}
if (texts.isNotEmpty()) replies.warm(texts)
// After the parses exist, not before: [ParsedReplies.splitReady] is the flatten's licence
// to draw these as blocks on the composing thread.
rows.forEach { if (it is TranscriptItem.AssistantMsg) replies.markSplitReady(it.text) }
}
}
@@ -1,128 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.text.selection.SelectionState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* The transcript: a lazy list of [TranscriptUnit]s, laid out in reverse.
*
* Reverse layout is what makes the two insertions this list gets free rather than corrected. Item
* zero is the newest content and sits at the bottom, so a message arriving extends the end the
* viewport is pinned to and following it is not an effect -- and a page of older history lands at
* indices past everything visible, which moves nothing on screen. The keyboard is the same case
* from the other side: the viewport shrinks and the anchored item stays against its bottom edge.
*
* The lazy list is also the whole of the windowing. Only what is near the viewport is composed and
* alive, so the per-frame cost is bounded by the screen rather than by how much is loaded -- the
* property a plain column here had to approximate with retained ranges and stand-in spacers, each
* of which was a way to flicker.
*
* What keeps a unit's arrival cheap enough to happen mid-fling: a unit is at most one block of a
* reply, and its parse is already made by [warm] before the fold that introduces it.
*
* The whole list sits in a [SelectionContainer], which is what makes every word selectable by the
* platform's own press-and-hold. Here rather than at each place text is drawn: a transcript is one
* body of text to a reader, and a container per row would mean a selection could never cross from a
* reply into the tool output that follows it -- and would leave whatever was drawn without one
* silently unselectable. Rows keep their tap handlers: selection is a long press.
*
* [selection] is the container's own state, held by the caller rather than made here, because the
* rows have to be able to ask whether anything is selected before they act on a tap.
*/
@Composable
fun TranscriptList(
units: List<TranscriptUnit>,
state: LazyListState,
moreHistory: Boolean,
selection: SelectionState,
modifier: Modifier = Modifier,
below: @Composable () -> Unit,
unit: @Composable (TranscriptUnit) -> Unit,
) {
SelectionContainer(selection) {
LazyColumn(
state = state,
reverseLayout = true,
contentPadding = TRANSCRIPT_PADDING,
modifier =
// Timed in two halves because the frame's draw phase is where Compose's measurement
// lands, and "draw is high while nothing is being recorded" does not say which
// half. Measure includes composing the items that scrolled in.
modifier
.layout { measurable, constraints ->
val started = System.nanoTime()
val placeable = measurable.measure(constraints)
DebugStats.record(
"measure: the whole transcript",
System.nanoTime() - started,
)
layout(placeable.width, placeable.height) {
val placing = System.nanoTime()
placeable.place(0, 0)
DebugStats.record(
"place: the whole transcript",
System.nanoTime() - placing,
)
}
}
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record("draw: the whole transcript", System.nanoTime() - started)
},
) {
// The bottom of the screen: what is waiting to be read sits under the newest message.
// The one item before the units, which is what [UNITS_START] counts.
item(key = "below", contentType = "below") { below() }
items(count = units.size, key = { units[it].key }, contentType = { units[it]::class }) {
val u = units[it]
DebugStats.count("unit composed")
Box(Modifier.fillMaxWidth().padding(top = u.gap)) { unit(u) }
}
// Standing in for everything not fetched yet. Only here while there is more -- its
// appearance at the top edge is also roughly when the next page is asked for, so what
// it reports is a fetch in flight rather than an end reached.
if (moreHistory) {
item(key = "history", contentType = "history") {
Box(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
CircularProgressIndicator(
Modifier.align(Alignment.Center).size(HISTORY_SPINNER)
)
}
}
}
}
}
}
/**
* How many of the list's own items come before the first unit -- the waiting-messages slot.
*
* Named once because two things count on it: the list building itself, and [visibleUnits] turning a
* list index back into the unit that was drawn there. Read off by hand at the second of those, it
* is an off-by-one that misnames every row in a report and looks like a plausible answer.
*/
const val UNITS_START = 1
/** The gap between rows, and the room around the whole conversation. */
val TRANSCRIPT_SPACING: Dp = 8.dp
val TRANSCRIPT_PADDING: PaddingValues = PaddingValues(16.dp)
/** Smaller than the whole-screen loading spinner: it stands in for a page, not for everything. */
private val HISTORY_SPINNER = 24.dp
@@ -1,177 +0,0 @@
package com.example.aiapp
import android.content.Context
import java.io.File
import java.util.concurrent.atomic.AtomicReference
/**
* Where the session screen gets a transcript from: this phone's copy first, the server for the
* rest.
*
* One seam rather than a cache the screen has to remember to consult. Everything it fetched before
* is asked of this, and everything the server sends is written into the cache on the way past, so
* the screen never learns which side answered. What it does learn, through [DebugStats], is how
* often each one did.
*
* See TRANSCRIPT_CACHE.md. The one rule worth keeping in mind: the cache is never load-bearing.
* Every read has a network path beside it producing the same result.
*/
class TranscriptSource(
private val settings: ServerSettings,
private val address: TranscriptAddress,
val cache: SessionCache,
) {
private val stream = AtomicReference<EventStream?>(null)
/**
* The cached opening window, or null when there is nothing usable to draw.
*
* Drawn *before* [probe] returns, which is the whole point of the feature: the rows are on
* screen while the check that they are still the server's rows is in flight, and a failed check
* replaces them exactly as a `reset` does.
*/
fun cachedOpening(limit: Int = OPENING_WINDOW): List<SeqEvent>? {
if (cache.tail() == null) return null
val lines = cache.newest(limit)
if (lines.isEmpty()) return null
return try {
lines.map { parseSeqEvent(it) }
} catch (e: org.json.JSONException) {
// Lines this build cannot read at all, which the cache's own checks cannot see: it
// reads a seq off a line, not an event. Nothing to serve, so a cold open.
cache.purge()
null
}
}
/**
* Whether the server's event at the cached cursor is still the cached one.
*
* The screen must not resume a stream from a cached seq unless it is the same conversation. A
* transcript is append-only in ordinary use, but the file can be replaced or truncated -- a
* sandbox re-seeded with the same ids, a backup restored, a session re-imported -- and the
* server's catch-up on such a file would hand this phone a continuation of a *different*
* conversation, spliced onto the cached one with no seam. Caught with one request of a few
* hundred bytes, in the slot the opening page's request used to be in.
*
* False purges the cache and means "open cold". A throw is the server not being askable, which
* is neither: the cached rows stay on screen and the caller tries again on the reconnect
* schedule.
*
* What this cannot see is a line changed in the middle of the file with the tail intact. That
* is what the Reload button in session settings is for.
*/
suspend fun probe(): Boolean {
val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there.
val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches =
answer.size == 1 &&
try {
answer[0].second == parseSeqEvent(tail.line)
} catch (e: org.json.JSONException) {
false
}
if (!matches) cache.purge()
return matches
}
/**
* Today's opening fetch, kept as the start of the live run. Only called when the cache has
* nothing to open with, or when [probe] said what it had was not the server's.
*/
suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush()
return page.map { it.second }
}
/**
* The page before [before]: from the cache when it holds it, otherwise from the server bounded
* by what the cache already has.
*
* The bound is what keeps the cache worth having. A coalesced page reaches back as far as its
* row count takes it -- a single reply is hundreds of lines -- so a page fetched after the
* reader has been away would run straight past the cached run and overlap it, and an
* overlapping page cannot be stored. Told where this phone's copy starts, the server stops
* there instead.
*/
suspend fun page(before: Long, limit: Int, coalesce: Boolean): List<SeqEvent> {
cache.page(before, limit, rows = coalesce)?.let { lines ->
DebugStats.count("transcript page from cache")
return lines.map { parseSeqEvent(it) }
}
DebugStats.count("transcript page from server")
val page =
fetchTranscript(
settings,
address,
before = before,
limit = limit,
coalesce = coalesce,
after = cache.coveredUpTo(before)?.minus(1),
)
if (page.isNotEmpty()) {
// `before` rather than the newest line's seq: a coalesced page covers everything up to
// the cursor it was asked with, and nothing in its lines says so.
cache.storePage(page.map { it.first }, page.first().second.seq, before, rows = coalesce)
}
return page.map { it.second }
}
/**
* [EventStream.run], with every frame written to the cache before [onEvent] sees it.
*
* Before, so that an event held back for a reader who is scrolled away is already on disk --
* what the cache holds is what the server sent, not what the screen has got round to drawing.
* Flushed on each status change, which is a turn's boundary and the granularity a crash may as
* well lose.
*/
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close()
try {
opened.run(after, onOpen, onReset) { raw, entry ->
cache.append(raw, entry.seq)
if (entry.event is SessionEvent.Status) cache.flush()
onEvent(entry)
}
} finally {
cache.flush()
}
}
/** Ends the stream, from any thread, and leaves the cache with everything it was given. */
fun close() {
stream.getAndSet(null)?.close()
cache.flush()
}
}
/**
* How many events the screen opens with, cached or fetched.
*
* The server's own default for a page, named here because the cached opening has to be the same
* size as the fetched one -- a reader must not get a shorter first screen for having been here
* before.
*/
private const val OPENING_WINDOW = 80
/**
* Where this server's cached transcripts live.
*
* Under `cacheDir` because that is exactly what it is for: bytes the phone can regenerate from the
* server, which Android may delete under storage pressure without asking. Keyed by host and port
* because two servers can hold a session with the same id, and a line from one shown against the
* other is the whole invariant broken. `v1` is the layout's version.
*/
fun cacheRoot(context: Context, settings: ServerSettings): File {
val transcripts = File(context.cacheDir, "transcripts")
transcripts.listFiles()?.forEach { if (it.name != CACHE_VERSION) it.deleteRecursively() }
return File(transcripts, "$CACHE_VERSION/${settings.host}_${settings.port}")
}
private const val CACHE_VERSION = "v1"
@@ -1,478 +0,0 @@
package com.example.aiapp
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* One item of the transcript list: a whole row, or one block of a settled reply.
*
* The unit of laziness is deliberately smaller than a message. A lazy list pays to compose an item
* at the moment it scrolls into view, and that cost is proportional to the item -- a reply can be
* twenty-five screens of markdown, which as one item is a hundred-millisecond frame exactly when
* the list is moving fastest. A *block* is a paragraph, a fence, a table: bounded, so the worst
* frame is bounded. This is the piece that was missing when a lazy list was last tried here.
*
* Everything else about the row model is unchanged: rows come from [groupToolRuns], and a unit
* points back at its row. The list draws units; anchors and paging still speak seq.
*/
@Immutable
sealed class TranscriptUnit {
/** The list identity; must survive pages landing at either end. See [TranscriptRow.key]. */
abstract val key: Any
/** Where this unit's row starts in the transcript -- the anchor identity, never the key. */
abstract val seq: Long
/**
* This unit's position within its row, counted from the row's oldest end. What a saved scroll
* position carries besides the seq: a reply split into forty blocks needs more than "somewhere
* in this row" to put a reader back where they stopped.
*/
abstract val ordinal: Int
/** The gap drawn above this unit -- between rows, or between blocks of one reply. */
abstract val gap: Dp
/** A row drawn as itself: a bubble, a tool card, a group -- or the reply still arriving. */
data class Whole(val row: TranscriptRow, override val gap: Dp) : TranscriptUnit() {
override val key: Any
get() = row.key
override val seq: Long
get() = row.startSeq
override val ordinal: Int
get() = 0
}
/** One [Piece] of a settled reply; [text] is the prose it is a piece of. */
data class Block(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "b$seq:$ordinal"
}
/**
* The heading of a message from another agent: who sent it, and the control that opens it.
*
* A peer message is the one row whose *opened* size is unbounded -- these are the longest
* things a transcript holds -- so it is flattened the same way a settled reply is, and for the
* same reason. Measured on the emulator, opening a 43KB one took the transcript's share of the
* draw phase from 0.81ms a frame to 3.85ms, and the framework's own per-frame bookkeeping from
* 0.39ms to 3.15ms.
*
* The card is drawn in pieces rather than given up: a filled Material card is elevation zero,
* so it has no shadow to break, and each piece paints the same fill with only the corners it
* owns.
*/
data class PeerHead(
override val seq: Long,
val item: TranscriptItem.PeerNote,
val open: Boolean,
override val gap: Dp,
) : TranscriptUnit() {
/**
* The note's own key, so opening and shutting does not change what the list is anchored on
* -- and so two notes stamped with one turn's seq are still two items.
*/
override val key: Any
get() = item.key
override val ordinal: Int
get() = 0
}
/**
* One [Piece] of an opened peer message; [last] is the piece that closes the card. Its [gap] is
* always zero -- the pieces are one card -- so the room between blocks is [spacing], drawn
* inside the piece where the card's fill covers it.
*/
data class PeerBlock(
override val seq: Long,
override val ordinal: Int,
val text: String,
val piece: Piece,
val last: Boolean,
val spacing: Dp,
override val gap: Dp,
/** The note this block belongs to; its key, not its seq. See [TranscriptItem.PeerNote]. */
val note: Any,
) : TranscriptUnit() {
override val key: Any
get() = "p$note:$ordinal"
}
/**
* One slice of a long user message; see [userChunks].
*
* A user message is plain text, so cutting it costs a scan rather than a parse -- but the
* reason is the same as for a settled reply: as one item, a pasted log is a hundred thousand
* pixels of `Text` whose layout lands in the frame the row scrolls into.
*/
data class UserChunk(
override val seq: Long,
override val ordinal: Int,
val text: String,
val first: Boolean,
val last: Boolean,
/** The message's attachments, drawn under the words -- so only the last slice has any. */
val attachments: List<String>,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "u$seq:$ordinal"
}
/**
* The "Show all N lines" under a message drawn only as far as [TextCap.MESSAGE_LINES].
*
* Its own unit rather than something inside the row above it, because the row above it is a
* *bounded* item now and this is what says so -- and because a control that lives inside the
* thing it reveals moves the moment it is pressed.
*/
data class ShowAll(
override val seq: Long,
override val ordinal: Int,
/** The row this belongs to; what goes into the set of rows shown whole. */
val row: Any,
/** The line count of the whole message, which is what the offer says. */
val lines: Int,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "s$row"
}
/** One memory note of a settled reply; see [MemoryNote]. */
data class Memory(
override val seq: Long,
override val ordinal: Int,
val part: MessagePart.Remembered,
override val gap: Dp,
) : TranscriptUnit() {
override val key: Any
get() = "m$seq:$ordinal"
}
}
/**
* A message row cut to [TextCap]'s worth of itself, and the line count of the whole of it.
*
* The cut happens **before** the flatten below decides how to draw the row, so everything after it
* -- pieces, chunks, warming -- sees a shorter message and needs to know nothing about caps. The
* shortened row keeps its key and its seq, so the list's identity and every saved scroll anchor are
* untouched by a reader opening or closing one.
*
* A reply still arriving is never capped: it grows by deltas, and a row that stopped growing at two
* hundred lines while the model was plainly still writing would read as the stream having died.
* `iris`'s `row::build_row` states the same rule for the same reason.
*/
private fun capRow(row: TranscriptRow, shownWhole: Set<Any>): Pair<TranscriptRow, Int?> {
val item = (row as? TranscriptRow.Single)?.item ?: return row to null
if (row.key in shownWhole) return row to null
val cut =
when {
item is TranscriptItem.UserMsg ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
item is TranscriptItem.AssistantMsg && item.settled ->
cutText(item.text, TextCap.MESSAGE_LINES, TextCap.MESSAGE_BYTES)?.let {
it to TranscriptRow.Single(item.copy(text = it.shown))
}
else -> null
} ?: return row to null
return cut.second to cut.first.lines
}
/**
* The rows flattened into list units, newest first -- index zero is the item at the bottom of the
* screen, which is what a reversed lazy list calls the start.
*
* Every settled reply is cut into its pieces (via the caches on [replies] so a message is only ever
* cut once), and so is an *opened* peer message -- [openNotes] is which ones those are. A shut one
* is a single heading and cannot be worth splitting. The reply still arriving stays whole: its text
* changes with every delta, and splitting it here would parse the whole message per delta on
* whichever thread is composing. Once settled it splits like every other reply, which is what
* bounds the newest row's cost after a session ends on a long one.
*
* Runs per fold, so it must stay proportional to what is loaded with no parsing in it on the warm
* path: [ParsedReplies.partsOf] and [ParsedReplies.piecesOf] are lookups for any text [warm] has
* seen, and a miss -- the one message that just finished streaming -- costs its parse exactly once.
*/
fun transcriptUnits(
rows: List<TranscriptRow>,
replies: ParsedReplies,
openNotes: Set<Long>,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptUnit> {
val started = System.nanoTime()
val units = ArrayList<TranscriptUnit>(rows.size)
rows.forEachIndexed { index, whole ->
val rowGap = if (index == 0) 0.dp else TRANSCRIPT_SPACING
val (row, hidden) = capRow(whole, shownWhole)
val rowStart = units.size
val item = (row as? TranscriptRow.Single)?.item
if (item is TranscriptItem.PeerNote) {
val open = item.seq in openNotes
units += TranscriptUnit.PeerHead(row.startSeq, item, open, rowGap)
// No gap between the pieces: they are one card, and a card with a stripe through it is
// what any spacing here would draw.
if (open) {
val pieces = replies.piecesOf(item.text)
var previous: Piece? = null
pieces.forEachIndexed { at, piece ->
units +=
TranscriptUnit.PeerBlock(
row.startSeq,
at + 1,
item.text,
piece,
last = at == pieces.lastIndex,
spacing = gapBefore(previous, piece),
gap = 0.dp,
note = item.key,
)
previous = piece
}
}
} else if (item is TranscriptItem.UserMsg && item.text.length > USER_SPLIT_CHARS) {
// A scan, not a parse, so it is cheap enough for the fold path -- and cached like the
// markdown splits so the scan happens once per message rather than once per fold.
val chunks = replies.chunksOf(item.text)
chunks.forEachIndexed { at, chunk ->
units +=
TranscriptUnit.UserChunk(
row.startSeq,
at,
chunk,
first = at == 0,
last = at == chunks.lastIndex,
attachments = if (at == chunks.lastIndex) item.attachments else emptyList(),
gap = if (at == 0) rowGap else 0.dp,
)
}
} else if (
item is TranscriptItem.AssistantMsg &&
splitWanted(item, index, rows.lastIndex) &&
replies.splitReady(item.text)
) {
var ordinal = 0
fun gap(within: Dp) = if (ordinal == 0) rowGap else within
replies.partsOf(item.text).forEach { part ->
when (part) {
is MessagePart.Prose -> {
var previous: Piece? = null
replies.piecesOf(part.text).forEach { piece ->
units +=
TranscriptUnit.Block(
row.startSeq,
ordinal,
part.text,
piece,
gap(gapBefore(previous, piece)),
)
ordinal++
previous = piece
}
}
is MessagePart.Remembered -> {
units +=
TranscriptUnit.Memory(row.startSeq, ordinal, part, gap(BLOCK_SPACING))
ordinal++
}
}
}
} else {
units += TranscriptUnit.Whole(row, rowGap)
}
if (hidden != null) {
units +=
TranscriptUnit.ShowAll(
row.startSeq,
units.size - rowStart,
row.key,
hidden,
BLOCK_SPACING,
)
}
}
units.reverse()
reportDuplicateKeys(units)
// Timed because this runs per fold on the composing thread: "loading messages feels bumpy" is
// this number growing, and it was invisible until it was written down.
DebugStats.record("units flattened", System.nanoTime() - started)
return units
}
/**
* Whether this reply should be drawn as blocks: settled, or anywhere but the newest row.
*
* Wanting is not being ready -- the flatten also asks [ParsedReplies.splitReady], and the two are
* answered by different things: this one by the fold, the other by whether [warm] has run.
*/
private fun splitWanted(item: TranscriptItem.AssistantMsg, index: Int, lastIndex: Int) =
item.settled || index != lastIndex
/**
* The replies among [rows] that should draw as blocks but whose parses are not made yet.
*
* Normally empty: every page's rows are warmed before the fold lands. The one row that can be cold
* is the reply that just finished streaming -- nothing warms live deltas. The session screen warms
* what this returns off-thread and re-flattens, so the whole-to-blocks swap always composes against
* ready parses.
*/
fun unwarmedReplies(
rows: List<TranscriptRow>,
replies: ParsedReplies,
shownWhole: Set<Any> = emptySet(),
): List<TranscriptItem> = rows.mapIndexedNotNull { index, whole ->
// The *capped* row's text, since that is what the flatten will draw and so what has to be
// ready: a capped row draws its head, which is a different string from the message and so a
// different cache entry.
val row = capRow(whole, shownWhole).first
val item = (row as? TranscriptRow.Single)?.item as? TranscriptItem.AssistantMsg
item?.takeIf { splitWanted(it, index, rows.lastIndex) && !replies.splitReady(it.text) }
}
/**
* Above this many characters, a user message is drawn in slices rather than as one bubble.
*
* Not zero, because a bubble's width wraps its content: slices have to fill the row to look like
* one bubble, and forcing that on a short message would visibly widen it. A message past this
* length has lines that wrap, so its bubble is at the full width already and the slices match it
* exactly. Below it, one item of at most a few screens is nothing the list minds composing.
*/
const val USER_SPLIT_CHARS = 4000
/**
* Roughly how much text one slice holds -- bounded, like a markdown block, is the whole point.
*
* About one viewport of wrapped text: a slice is composed whole in the frame it scrolls into, so
* its size is a frame-budget decision, and one screenful keeps that to a few milliseconds on the
* phone. Smaller buys nothing -- the seams are free -- but the units multiply.
*/
private const val USER_CHUNK_CHARS = 1000
/**
* A long user message cut at line starts into slices of roughly [USER_CHUNK_CHARS].
*
* At newlines only, never mid-line: text layout runs per line, so slices that own whole lines stack
* back into exactly the lines the single `Text` drew, and a cut inside one would reflow it. The
* newline at each cut is dropped -- the boundary between two stacked slices *is* that line break. A
* single line longer than a slice (minified JSON, a base64 blob) stays whole in its slice, so a
* slice is bounded by the longest line rather than absolutely.
*/
fun userChunks(text: String): List<String> {
val chunks = ArrayList<String>()
var start = 0
while (start < text.length) {
if (text.length - start <= USER_CHUNK_CHARS) {
chunks += text.substring(start)
break
}
var cut = text.lastIndexOf('\n', start + USER_CHUNK_CHARS)
if (cut <= start) cut = text.indexOf('\n', start + USER_CHUNK_CHARS)
if (cut < 0) {
chunks += text.substring(start)
break
}
chunks += text.substring(start, cut)
start = cut + 1
}
return chunks
}
/**
* Says which two units share a key, before the list dies of it.
*
* A duplicate key is fatal -- `LazyColumn` throws, and the app goes down in the middle of somebody
* reading a conversation -- and all the framework's message carries is the key. When that key is a
* seq it names neither row, and there is no way back from it to how the two came to share one: it
* took an afternoon and a fixture that could reproduce it. Two lines here answered it immediately,
* naming both rows and the field they had in common ([TranscriptItem.PeerNote.arrived]).
*
* Always on, for the same reason [DebugStats] is: an instrument that is only in the build nobody is
* holding when it breaks is not an instrument. It costs one map over the units that were just
* built, beside a loop that already allocates one entry per unit.
*/
private fun reportDuplicateKeys(units: List<TranscriptUnit>) {
val seen = HashMap<Any, TranscriptUnit>()
units.forEach { unit ->
val had = seen.put(unit.key, unit)
if (had != null) {
android.util.Log.w("ai-app", "duplicate unit key ${unit.key}: $had AND $unit")
}
}
}
/**
* What is on screen right now, a unit at a time: what each one is and how tall it is.
*
* For the render report, and it is the line every "it is slow here" report has needed. The
* framework's own per-frame cost grows with how many nodes are *alive* rather than how many are on
* screen, so a screen holding one enormous item is slow in a way that no counter of ours
* distinguishes from a screen holding twenty ordinary ones -- and "2 units visible" says one of
* them is enormous without saying which. This says which.
*
* [first] is the index the list gave the first *unit*: the list also holds the waiting-messages
* slot at index zero and the history spinner past the end, and both are named here rather than
* silently reported as whichever unit is nearest.
*/
fun visibleUnits(units: List<TranscriptUnit>, visible: List<LazyListItemInfo>, first: Int): String =
if (visible.isEmpty()) " nothing on screen"
else
" on screen: " +
visible.joinToString(", ") { info ->
"${units.getOrNull(info.index - first).kind} ${info.size}px"
}
/** What a unit is, in a word, for [visibleUnits]. Null is one of the list's own non-unit items. */
private val TranscriptUnit?.kind: String
get() =
when (this) {
null -> "the list's own"
is TranscriptUnit.Block ->
if (piece.item == Piece.WHOLE_BLOCK) "reply block" else "list item"
is TranscriptUnit.PeerHead -> if (open) "peer heading (open)" else "peer heading"
is TranscriptUnit.PeerBlock -> "peer block"
is TranscriptUnit.UserChunk -> "user slice"
is TranscriptUnit.Memory -> "memory note"
is TranscriptUnit.ShowAll -> "show all"
is TranscriptUnit.Whole ->
when (val row = row) {
is TranscriptRow.Tools -> "tool group"
// The class name rather than a word per kind: this is a diagnostic, and a
// `when` here would be one more place that has to gain a case whenever the
// transcript does -- silently naming a new row after an old one until somebody
// noticed.
is TranscriptRow.Single -> row.item::class.simpleName.orEmpty()
}
}
/**
* Where the unit named by a saved position sits in [units], or null if its row is not loaded.
*
* The row is found by [seq] and the unit within it by [ordinal], settling for the nearest older
* unit when the exact one is gone -- a reply regrouped by a page boundary can split into a
* different number of blocks than it had when the position was saved, and "a little above where
* they stopped" loses less than the newest end does.
*/
fun unitIndexFor(units: List<TranscriptUnit>, 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 }
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more